Skip to content

feat(auth): limit scoped app passwords to the routes they open - #65340

Open
pringelmann wants to merge 9 commits into
masterfrom
feat/noid/app-password-route-gate
Open

pringelmann wants to merge 9 commits into
masterfrom
feat/noid/app-password-route-gate

Conversation

@pringelmann

@pringelmann pringelmann commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Second step for limited app passwords: a token with scopes can only call the index.php and OCS routes its scopes open. Everything else gets a 403. Tokens without scopes aren't affected. DAV is in #65552.

How it works

  • New middleware after the security middleware. Tokens without scopes skip it
  • Controllers declare the scope they need with #[TokenScope] on a method or the class (unstable API in NCU\, so apps can use it). A method's attribute replaces the class one. No attribute means refused, so new endpoints start closed
  • Apps not shipped with Nextcloud can only require their own <appid>: scopes
  • Files routes need files:files:read / files:files:write, shares, sharee search and direct links files_sharing:shares:write. Calendar and contacts OCS routes in dav need the matching dav: scope
  • Only deleting and rotating your own app password is open to every scoped token
  • Admin-only routes and pages are refused before the controller runs

Known limit: an admin's calendar or contacts token can import into other users' calendars and address books via the user parameter, and a files token can transfer ownership of files to another account.

Tests

Unit tests for the middleware and its place in the stack. They pin the scopes of the share, direct link, preview and download endpoints, and check that templates, direct editing, conversion and page methods stay unannotated. Integration tests cover allowed and refused calls with a scoped token.

Next step

DAV enforcement, then the access picker in Settings.

Checklist

AI (if applicable)

  • The content of this PR was partly or fully generated using AI (tests, reviewed)

@pringelmann
pringelmann changed the base branch from master to feat/noid/app-password-scope-storage October 9, 2026 11:15
@pringelmann
pringelmann added this pull request to stack #65341 October 9, 2026 11:15
@pringelmann pringelmann added this to the Nextcloud 36 milestone Oct 9, 2026
@pringelmann pringelmann self-assigned this Oct 9, 2026
$dispatcher->registerMiddleware($c->get(PublicShareMiddleware::class));
$dispatcher->registerMiddleware($c->get(AdditionalScriptsMiddleware::class));
// After AdditionalScriptsMiddleware, so pages are refused before their listeners run
$dispatcher->registerMiddleware($c->get(TokenScopeMiddleware::class));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Own middleware instead of one more check in SecurityMiddleware, which is hand-built and already does a lot. Also registered last on purpose, the test in DIContainerTest checks the order.

* Page renders run every app's listeners and initial state, so none reach a token with scopes
*/
#[\Override]
public function afterController(Controller $controller, string $methodName, Response $response): Response {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only know it's a page once the controller returns a TemplateResponse, so it has to check after controller runs. Page GETs don't change data, so letting the controller run is fine though. What we block is the render, which pulls in every app's listeners and initial state.

Comment thread lib/private/Authentication/Token/TokenRoutes.php Outdated
Comment thread lib/private/Authentication/Token/TokenRoutes.php Outdated
Comment thread lib/private/Authentication/Token/TokenRoutes.php Outdated
Comment thread tests/lib/Authentication/Token/TokenRoutesTest.php Outdated
@pringelmann
pringelmann marked this pull request as ready for review October 9, 2026 11:25
@pringelmann
pringelmann requested a review from a team as a code owner October 9, 2026 11:25
@pringelmann
pringelmann requested review from Altahrim, icewind1991, leftybournes and provokateurin and removed request for a team October 9, 2026 11:25
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from d4d67af to a2b3655 Compare October 9, 2026 11:25
@pringelmann pringelmann changed the title feat(auth): add app password scope storage feat(auth): limit scoped app passwords to the routes they open Oct 9, 2026
Comment thread lib/private/Authentication/Token/TokenRoutes.php Outdated
@pringelmann pringelmann added the 3. to review Waiting for reviews label Oct 9, 2026
@pringelmann

Copy link
Copy Markdown
Contributor Author

Drafting for now, got feedback that necessitates major changes

@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from bd4dc21 to 0823522 Compare October 15, 2026 20:17
@pringelmann

Copy link
Copy Markdown
Contributor Author

Reworked after a design discussion with @artonge : the route list is gone, controllers now declare their scope with an attribute. Comments on TokenRoutes are outdated because of that.

@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from 0823522 to cf1ad78 Compare October 16, 2026 07:38
@pringelmann pringelmann added the 3. to review Waiting for reviews label Oct 16, 2026
@pringelmann
pringelmann marked this pull request as ready for review October 16, 2026 08:28
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch 2 times, most recently from c1b26e4 to bdac8ee Compare October 16, 2026 11:11
Comment thread core/Controller/AppPasswordController.php
@pringelmann

Copy link
Copy Markdown
Contributor Author

@artonge I think adding multiple scopes later will be quite easy.

For example we can do something like:

  • Any of several scopes -> Simply repeat the attribute. Existing single attributes still work
  • All of several scopes -> Widen the param to string|array, which is backwards compatible too.

Either way I've moved the attribute to NCU so it can stay flexible until 37 cc @CarlSchwan

Base automatically changed from feat/noid/app-password-scope-storage to master October 22, 2026 07:52
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
…opeRequired

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from 2081409 to fab26ff Compare October 22, 2026 08:16
…ble API

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
@pringelmann
pringelmann requested a review from artonge October 22, 2026 13:02
* @psalm-import-type Files_SharingRemoteShare from ResponseDefinitions
* @api
*/
#[TokenScope(scope: TokenScopes::FILES_WRITE)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldnt this be allowing to write shares?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepting mounts the share into your own files, so I decided to go with files write. The sharing write scope is for creating shares only, and a client without it should still be able to accept incoming shares. Happy to change it if you disagree?

Comment thread apps/files_sharing/lib/Controller/ShareController.php Outdated
Comment on lines +59 to +65
/**
* Apps that aren't shipped may only require scopes named after themselves,
* so they can't open their routes to app passwords limited to another app
*/
private function mayRequire(string $scope): bool {
return $this->isShipped() || str_starts_with($scope, $this->appName . ':');
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We kind of need this relaxed. E.g. Talk is integrating with calendar on scheduling meetings. The endpoint in Talk should require dav write, but would currently not be allowed.

Also calendar has the feature to generate a Talk conversation, it should require a talk permission of some kind, but neither Talk nor Calendar is shipped?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, missed those.

In general @artonge and I agreed to keep checks only at the route and collection level for 36, and not try to follow what an app does with other apps' data within the controller. Also, scoped keys are created manually in settings for now, the Talk and Calendar clients still log in with full access, so these integrations keep working. I'd maybe revisit this once clients can request scopes, probably with a list of required scopes on the attribute.

Something like:

#[TokenScope(scope: ['spreed:conversations:write', 'dav:calendar:write'])]
public function scheduleMeeting(…) {

Is OK for you?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably the constraint should be applied to all apps, event those shipped to prevent scope colision.

I am not sure to get your use case Joas. For context, the use case for scopes are mostly scripts, or apps that have really limited needs, so the web UI and the Talk clients should keep the all access token for now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense for collisions, but a strict version would break some shipped routes that need another app's scope: previews in core, trash and versions need files read, the sharing API needs files read/write, etc.

So what about: an app can always use its own scopes, and shipped apps can also use other shipped apps' scopes, but never those of an app store app. That way core can't take deck: or spreed:, and the existing routes keep working as before.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shipped apps can also use other shipped apps' scopes, but never those of an app store app

But do we have a use case where we need that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean a shipped app needing the scope of an app store app? Or shipped apps needing each other's scopes?

Not sure about the former, but the latter we do have a quite a few (listed in my previous comment)

Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scoped app passwords: route gate

4 participants