Skip to content

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

Open
pringelmann wants to merge 3 commits into
feat/noid/app-password-scope-storagefrom
feat/noid/app-password-route-gate
Open

pringelmann wants to merge 3 commits into
feat/noid/app-password-scope-storagefrom
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 now only call the index.php and OCS routes its scopes open. Everything else gets a 403. Tokens without scopes aren't affected. DAV comes in the next PR.

How it works

  • New middleware after the existing security middleware. Tokens without scopes skip it
  • Controllers declare the scope they need with a private #[TokenScope] attribute, on a method or the class. No attribute means refused, so new endpoints start closed
  • #[TokenScope] without a scope is open to any scoped token
  • Files apps need files:write, their read-only controllers files:read. Shares, sharee search and direct links need files:share
  • The calendar and contacts OCS routes in dav need the matching scope
  • Templates, direct editing, conversion, admin-only routes and page renders are refused

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 route table, the middleware and its position in the stack. A test pins the scope of the share, direct link and preview endpoints, and checks that templates, direct editing and conversion stay unannotated. Integration tests cover an allowed call, a refused share, unified search and a page render 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.

return;
}

if (!$this->reflector->hasAnnotationOrAttribute('PublicPage', PublicPage::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.

An admin's token passes SecurityMiddleware on admin routes. Scoped tokens shouldn't, even if they belong to an admin.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But in the end won’t all tokens be scoped tokens? How does one do admin work with a token then?
There should be an admin scope, no?

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.

No, All access stays. Login flow tokens (desktop, mobile) and existing tokens have no scopes and keep full access, admin rights included, and "All access" stays an option in Settings. Scopes are opt-in for scripts and integrations that only need part of the account, so admin work keeps using an All access token like today.

A narrower admin scope (e.g. only user provisioning) could maybe come later if there's demand, but I'd keep it out of this round.

* 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
Comment thread lib/private/Authentication/Token/TokenRoutes.php Outdated
@pringelmann
pringelmann marked this pull request as draft October 15, 2026 15:47
@pringelmann pringelmann removed the 3. to review Waiting for reviews label Oct 15, 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 from cf1ad78 to c1b26e4 Compare October 16, 2026 10:58
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>
@pringelmann
pringelmann force-pushed the feat/noid/app-password-route-gate branch from c1b26e4 to bdac8ee Compare October 16, 2026 11:11

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