Skip to content

[6.x] Add a Tokens screen for Passport personal access tokens - #20155

Merged
brandonkelly merged 4 commits into
6.xfrom
feature/personal-access-tokens
Oct 29, 2026
Merged

brandonkelly merged 4 commits into
6.xfrom
feature/personal-access-tokens

Conversation

@brandonkelly

Copy link
Copy Markdown
Member

Summary

Adds My Account → Tokens, the Craft 6 version of the Tokens screen in 5.12's authenticated GraphQL work (#20147). Users create Laravel Passport personal access tokens with the scopes they choose, and can revoke those tokens and the apps they've authorized.

plugin / core API                      PersonalAccessTokens (singleton)              Passport
registerScopeGroup('graphql', fn () => ──► scope groups ──► Passport::tokensCan()   (merged, app's scopes win)
  new ScopeGroup(label, scopes,
    userScopes))                         create()  ──► personal access client      ──► $user->createToken()
                                                       (provider "craft", made         (TTL set for this call:
                                                        on first use)                   7, 30, 90, or 365 days)
                                         grants()  ──► oauth_access_tokens whose scopes all belong to one group
                                         revoke()  ──► same filter, then access + refresh tokens
  • Built on Passport. Tokens come from HasApiTokens::createToken(), through the client ClientRepository::personalAccessClient() returns. If that client doesn't exist yet, it's created with createPersonalAccessGrantClient(). Passport has no per-token expiry, so create() sets Passport::$personalAccessTokensExpireIn for the call and restores it afterward. PersonalAccessTokenFactory builds its authorization server, and reads the expiry, each time it's resolved, so other requests aren't affected.
  • Extensible. APIs offer scopes by registering a ScopeGroup (handle, label, scopes, and an optional userScopes closure) with PersonalAccessTokens::registerScopeGroup(), following UserPermissions::registerPermissionGroup(). Registered scopes are added to Passport::tokensCan() after boot. Existing Passport scopes are kept, and an application's own description wins (per .ai/rules/auth-mcp-providers.md).
  • Hidden when unused. The screen shows only when the user may grant at least one registered scope. MCP doesn't register a group, because MCP clients authorize themselves. So core 6.x shows nothing until an API registers one. Authenticated GraphQL, ported from Authenticated GraphQL queries via OAuth #20147, would be the first.
  • Same flow as 5.12. "New token" requires an elevated session, with up to 5 minutes left, as on the Passkeys screen. The modal asks for a name, the scopes (with an API picker when several groups are available), and an expiration. The token is shown once. Revoking a personal token revokes only that token. Revoking an app revokes its access and refresh tokens for that scope group only.
  • Matches 5.12 data. 5.12 (Use Laravel Passport's OAuth table layout and scope-bound clients #20154, Authenticated GraphQL queries via OAuth #20147) stores tokens in Passport 13's tables and issues personal access tokens through a client with provider = 'craft'. That's the client personalAccessClient('craft') finds here, so tokens created in 5.12 show up on this screen and keep working after an upgrade.

Evidence

tests/Feature/Http/Controllers/User/PersonalAccessTokensControllerTest.php (9 tests):

  • Hidden without a group: myaccount/tokens returns 403.
  • Only grantable scopes: a userScopes closure limits the scopes offered.
  • Elevated session: creating a token without one returns 423.
  • Working tokens: created tokens authenticate on an auth:craft-mcp route. They get the chosen 7- or 365-day expiry, Passport's default (1 year) is restored afterward, and both share one personal_access client.
  • Scope validation: withheld, unregistered, or missing scopes return 422 and no token is created.
  • Revocation: revoking one personal token, then an app, leaves the app's tokens for other scopes (mcp:read) alone.
  • Host scopes kept: an application's existing Passport scope description isn't replaced.

Also run:

  • Full suite: 11,465 passed. The 8 failures come from this orb, not this change: Formatter tests fail on its ICU 72 date format (they fail on 6.x here too), and SQLite backup tests fail for lack of the sqlite3 CLI.
  • Adapter tests (tests-adapter): 772 passed.
  • PHPStan, Pint, Rector, and vp lint / vp fmt on the new page all pass.

I also checked the screen in the workbench with a temporary graphql scope group: empty state, New token modal, validation errors, the created token, and revoking a token.

Related issues

Users create personal access tokens with the scopes they choose, behind an
elevated session, and revoke them along with the apps they've authorized.
APIs offer scopes by registering a ScopeGroup with PersonalAccessTokens, which
adds them to Passport::tokensCan(). The screen is hidden until a group offers
the user at least one scope.
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/ui — open Storybook

No changed components detected in this Storybook.

resources/js — open Storybook

No changed components detected in this Storybook.

@brandonkelly
brandonkelly requested a review from riasvdv October 29, 2026 12:37
@riasvdv

riasvdv commented Oct 29, 2026

Copy link
Copy Markdown
Contributor
image

Should we add it to the user menu when tokens are available to be configured?

riasvdv and others added 2 commits October 29, 2026 12:53
The Tokens, Passkeys, and Addresses screens repeated the page title as an h2.
On Tokens, the empty status region took its own grid gap, pushing the table
away from the intro text, and the empty table message had no padding.

Amp-Thread-ID: https://ampcode.com/threads/T-01a187ab-db62-7282-912a-e7fb7691bea0
Co-authored-by: Amp <amp@ampcode.com>

Copy link
Copy Markdown
Member Author

Good call, done.

Scopes now sit in one field with a group per API. The Tokens table gains
a Type column and an action menu with Edit and Revoke, and the edit
modal shows only the scopes a token has, along with its expiration date.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
@brandonkelly
brandonkelly merged commit 0e1edd0 into 6.x Oct 29, 2026
20 checks passed
@brandonkelly
brandonkelly deleted the feature/personal-access-tokens branch October 29, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants