Skip to content

Use Laravel Passport's OAuth table layout and scope-bound clients - #20154

Merged
brandonkelly merged 2 commits into
feat/backport-mcpfrom
feature/oauth-passport-schema
Oct 29, 2026
Merged

brandonkelly merged 2 commits into
feat/backport-mcpfrom
feature/oauth-passport-schema

Conversation

@brandonkelly

@brandonkelly brandonkelly commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

The goal is an upgrade path for OAuth tokens issued by Craft 5.12 to keep working in Craft 6, which uses Laravel Passport 13 for OAuth. Nobody uses 5.12's OAuth tables yet, so this PR changes them now to match Passport's layout exactly. Craft 6 can then adopt the rows as they are, with no migration.

5.12 before                       5.12 after = Passport 13
oauth_clients                     oauth_clients
  resourceId, redirectUris,         redirect_uris, grant_types, scopes*,
  dateCreated                       owner_type/owner_id, secret, provider,
                                    created_at, updated_at
oauth_access_tokens               oauth_access_tokens
  resourceId, clientId, userId,     client_id, user_id, name, scopes,
  expiresAt                         created_at, updated_at, expires_at
oauth_auth_codes                  oauth_auth_codes
  resourceId, clientId, userId,     client_id, user_id, scopes, expires_at
  expiresAt
oauth_refresh_tokens              oauth_refresh_tokens
  accessTokenId, expiresAt          access_token_id, expires_at

* optional column that Passport's Client::hasScope() reads

There's no resource_id anymore. A Craft OAuth resource (MCP, a plugin API) is now identified the way Passport identifies an API, by its scopes:

token scopes ["mcp:read","mcp:write"]  ->  belongs to the mcp resource
client scopes ["mcp:read","mcp:write","mcp:admin"]  ->  can only use mcp endpoints
client scopes null  ->  may request any scope (Passport's default)

Access tokens can carry over without changes. 5.12 and Craft 6 lock the same league/oauth2-server (9.4.1) and lcobucci/jwt (5.6.0), so a 5.12 token is the same JWT that Passport validates: aud is the client, jti is the token row, and sub is the user. Passport's TokenGuard only needs the client row and the token row, and this PR stores both in the layout it reads. The mcp:* scope names already match 6.x's McpScope, and 6.x's OAuthRegisterController already fills oauth_clients.scopes when the column exists.

Changes:

  • m261007_100000_oauth creates Passport 13's columns plus the optional oauth_clients.scopes. Foreign keys are plain (client_id to oauth_clients.id). Columns 5.12 doesn't use (owner_*, secret, provider, updated_at) are created anyway so Craft 6 doesn't have to add them.
  • OAuth::getResources() throws if two resources define the same scope ID. Docs recommend prefixing scopes with the resource ID.
  • Registering a client through a resource stores that resource's scope IDs in oauth_clients.scopes. craft\oauth\Client::hasScope() works like Passport's. A client with no scope in common with a resource can't use that resource's endpoints, and the authorize and token endpoints reject scopes outside the client's list.
  • Repositories finds tokens, codes and refresh tokens by ID only. A credential is valid for a resource when all of its scopes belong to that resource, the same check that already handled removed scopes.
  • Clients record grant_types the way Passport does, and Client::supportsGrantType() enforces it. Authenticated GraphQL queries via OAuth #20147 needs this for personal access tokens.
  • craft\oauth\AccessToken::$name fills the token's name column, which Passport uses for personal access tokens.
  • docs/oauth.md covers unique scopes, client scopes, and an "Upgrading to Craft 6" note.

Still needed outside this PR:

  • Craft 6 upgrades need no table changes. ensurePassportTables() skips existing tables, so an upgraded site keeps 5.12's tables, including oauth_clients.scopes. Fresh 6.x installs don't create that column, which is fine because Passport treats a missing value as "any scope".
  • Signing keys: 5.12 writes storage/oauth-private.key and storage/oauth-public.key, which is where Passport reads them by default. Move OAuth signing keys into storage/ craft6-revamp#6 moves the keys when CRAFT_STORAGE_PATH points elsewhere.
  • Refresh tokens won't carry over. 5.12 encrypts them with a key derived from securityKey, and Passport uses APP_KEY. Clients authorize again once their access token expires. MCP clients do that on their own.
  • Not tested: I haven't run a 5.12 token against a real Craft 6 install yet.

Evidence

  • OAuthTest::testTablesHavePassportColumns compares each table's columns with Passport 13's migrations (laravel/passport v13.8.0, as locked by 6.x) plus oauth_clients.scopes. It now allows no extra columns.
  • OAuthTest::testClientsCanOnlyRequestTheirScopes: the MCP client gets invalid_client at the API resource's authorize endpoint. A client limited to ["api:read"] gets invalid_scope when it exchanges a code for api:read api:write. A client with null scopes can be granted both.
    • Before: with Client::hasScope() always returning true, the test fails with "Expected OAuth error invalid_scope."
    • After: passes.
  • OAuthTest::testCredentialsCannotCrossResourceBoundaries still passes without resource_id. MCP codes, access tokens and refresh tokens are rejected at the API resource.
  • OAuthTest::testScopesMustBeUniqueAcrossResources: an api resource that also defines mcp:read throws InvalidConfigException.
  • OAuthTest::testClientsCanOnlyUseTheirGrantTypes: a client whose grant_types drops refresh_token gets unauthorized_client when it refreshes.
  • vendor/bin/codecept run unit oauth: OK (22 tests). unit mcp: OK (29 tests). functional: OK (29 tests).

Craft 6 uses Passport, so 5.12’s OAuth tables now use Passport’s column
names and layout (plus resource_id), and clients record their grant
types. Clients can only use the grant types they were registered with.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
@brandonkelly
brandonkelly marked this pull request as ready for review October 29, 2026 04:57
@brandonkelly
brandonkelly requested a review from riasvdv October 29, 2026 05:00
@brandonkelly
brandonkelly marked this pull request as draft October 29, 2026 05:05
@brandonkelly
brandonkelly removed the request for review from riasvdv October 29, 2026 05:05
…by scope like Passport

Tokens belong to the resource that defines their scopes, scope IDs must be
unique across resources, and registered clients store their resource's scopes
in Passport's optional oauth_clients.scopes column.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
@brandonkelly brandonkelly changed the title Match Laravel Passport’s OAuth table layout Use Laravel Passport's OAuth table layout and scope-bound clients Oct 29, 2026
@brandonkelly
brandonkelly marked this pull request as ready for review October 29, 2026 05:18
@brandonkelly
brandonkelly requested a review from riasvdv October 29, 2026 05:18
@brandonkelly
brandonkelly merged commit ed7949a into feat/backport-mcp Oct 29, 2026
11 checks passed
@brandonkelly
brandonkelly deleted the feature/oauth-passport-schema branch October 29, 2026 14:49
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