Skip to content

Authenticated GraphQL queries via OAuth - #20147

Merged
brandonkelly merged 18 commits into
feat/backport-mcpfrom
feature/authenticated-graphql
Oct 29, 2026
Merged

brandonkelly merged 18 commits into
feat/backport-mcpfrom
feature/authenticated-graphql

Conversation

@brandonkelly

@brandonkelly brandonkelly commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Authorization: Bearer <JWT>  ──▶ OAuth::authenticate('graphql') ──▶ user + graphql:read|write
                                         │
                                         ▼
                     Gql::getSchemaForUser($user, $canMutate)
                     components where `authenticated` => true | fn(User): bool
                                         │
                        ┌────────────────┴────────────────┐
                        ▼                                 ▼
          entry/asset queries: editable()      mutations: Elements::canSave/
          (peer entries, drafts, assets)       canDelete/canCreateDrafts($user)

Authorization: Bearer <GqlToken> ──▶ unchanged

Implements CMS-2559 on top of #20145. The GraphQL API is now an OAuth resource (graphql) with graphql:read and graphql:write levels. Requests with an OAuth token get a schema built per request from the schema components available to that user. Built-in components map to user permissions, such as sections.<uid>:read → viewEntries:<uid>. Plugins opt in with an authenticated key on their component config.

Notes for review:

  • The schema isn't stored in the OAuth scope. If the token's scopes were component IDs, deleting a section would invalidate every token that included it, because Repositories rejects tokens with unknown scopes, and new sections would never reach existing tokens.
  • Root entries queries for authenticated users don't return nested entries. Those are reached through their owners.
  • Categories, tags, and global sets are deprecated, so their components aren't available to authenticated requests.
  • Drafts and revisions are available to users with control panel access. Entries' drafts, revisions, currentRevision, parent, ancestors, children, and descendants fields only return entries the user can view, and createDraft always sets the authenticated user as the creator.
  • Backport MCP servers and OAuth to Craft 5.12 #20145 has no UI for getting a token, because MCP clients handle that themselves. This PR adds My Account → Tokens. Any OAuth resource can opt in with Resource::$personalTokens (and a label); graphql does, mcp doesn't. The screen is hidden when no resource offers tokens to the user, so it only appears when enableGql is on or a plugin opts in. New token asks for the password first (elevated session with at least 5 minutes left, as in 2FA setup), then opens a slideout with a name, a checkbox list of the resource's scopes the user may be granted, and an expiry. The token is shown once. The screen also lists apps the user authorized through OAuth, and each grant can be revoked. Personal tokens are JWTs minted by OAuth::createPersonalAccessToken(). As in Laravel Passport, they're issued through one shared personal access client (grant_types: ["personal_access"], provider: "craft", no scopes). That's the client Passport's personalAccessClient() finds in Craft 6, whose Passport guard uses the craft user provider. A client with no provider would only match if Craft 6 configured an api guard. Each token's name is stored on its token row, and its scopes decide which resource it belongs to. Revoking a personal token revokes just that token. This builds on Use Laravel Passport's OAuth table layout and scope-bound clients #20154, which gives the OAuth tables Passport 13's layout, without a resource_id column, so Craft 6 can adopt 5.12 tokens. Craft 6's createToken() would reuse the same client, so a Passport-based Tokens screen there could list 5.12 tokens too.
  • Query results are cached per user, with keys that include a hash of the user's current permissions. Each user's scope is cached too, keyed on configVersion and the same hash, so a permission or content model change rebuilds it.
  • ElementQueryConditionBuilder now applies non-array eager-loading conditions (e.g. editable) as-is instead of intersecting them.

Evidence

  • AuthenticatedSchemaTest (fixtures, Gql::executeQuery):
    • user with viewEntries on test1 (own only) and viewEntries + viewPeerEntries on withUri1 → entries returns only their own test1 entry plus the withUri1 entries
    • schema includes sections.test1:delete, but deleteEntry on another author's entry → Unable to perform the action.; on their own entry → true
    • read-only schema → no deleteEntry
    • Before: with the editable() and canDelete checks removed, the query and mutation tests fail. After: OK (4 tests, 14 assertions)
  • GraphqlOAuthCest (real OAuth tokens through GraphqlController): a token for a user with viewUsers can query users, a token for a user without it gets Cannot query field "users", and an invalid token gets 401 with WWW-Authenticate: Bearer ... resource_metadata="…/.well-known/oauth-protected-resource/actions/graphql/api".
  • AuthenticatedSchemaTest::testDraftsAreLimitedToUserPermissions: another author's drafts are hidden from a user without viewPeerEntryDrafts.
  • GraphqlOAuthCest::testPersonalAccessTokenScopes: GraphQL is the only resource offering tokens; picking graphql:write also grants graphql:read; MCP tokens and foreign scopes are rejected; with enableGql off no resource offers tokens (so the screen is hidden).
  • GraphqlOAuthCest::testPersonalAccessTokens also checks that two personal tokens share the personal_access client, the token row stores the name, and revoking one leaves the other working.
  • GraphqlOAuthCest::testPersonalAccessTokens: a personal token queries users. getUserGrants() lists it as personal with graphql:read, alongside an OAuth-authorized client. After revokeUserGrant() the token gets 401 and only the OAuth client remains.
  • Tokens screen, checked in a browser: New token prompts for the password, the slideout validates a blank name and no scopes, and a created token is shown once and answers {ping} with pong. Revoke confirms and the token then gets 401.
  • vendor/bin/codecept run unit,functional: 4,191 tests. The only errors were the 18 OAuthTest tests, because its stub app has no getGql(); with enableGql => false in that stub they pass (18 tests, 78 assertions). PHPStan and ECS pass in the pre-commit hook.

Performance (semi-complex site from #20149: 3 sites, 112 fields, 72 entry types, 30 sections; an editor with 197 permissions; entries(limit: 20); median of 11 fresh processes, PostgreSQL):

Uncached result Cached result
Before result/scope caching 351 ms — (not cached)
Result caching only 351 ms 80 ms
Result + scope caching 326 ms 65 ms (54 ms with lazyGqlTypes)

AuthenticatedSchemaTest also covers: a second user doesn't get the first user's cached results, losing viewPeerEntries isn't masked by a cached result, and a permission change rebuilds the cached scope. Both fail if the permissions hash is left out of their cache keys.

Related issues

  • CMS-2559

GraphQL requests with an OAuth access token for the new `graphql` resource now
get a schema built from the components available to the token's user.
Schema components opt in with an `authenticated` key, and built-in
components map to user permissions. Entry and asset queries use the
`editable` param, and mutations check the user's element permissions.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
brandonkelly and others added 10 commits October 28, 2026 15:39
Authenticated query results are cached again, with keys that vary by the
user's current permissions. Users' schema scopes are cached too, and rebuilt
when the project config or their permissions change.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
They're deprecated, so their schema components aren't offered to
authenticated requests.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
Drafts, revisions, and non-enabled elements are available to users with
control panel access. Entries' related-entry fields only return entries the
user can view, and createDraft always uses the authenticated user as the
creator.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
Users can create GraphQL personal access tokens from My Account, and
revoke them along with the apps they've authorized via OAuth.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
OAuth resources opt into personal access tokens via Resource::$personalTokens.
Users pick the scopes to grant, and creating a token requires an elevated
session. The screen is hidden when no resource offers personal tokens.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
brandonkelly and others added 5 commits October 29, 2026 01:51
… client

Personal tokens now share one client per resource with grant_types
["personal_access"], and store their name on the token row, as Laravel
Passport does. Revoking a personal token revokes just that token.
Passport 13 only matches a client with no provider when auth.guards.api.provider
is the user's provider, which Craft 6 doesn't configure, so it wouldn't find a
provider-less 5.12 client.

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>
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 marked this pull request as ready for review October 29, 2026 19:58
@brandonkelly
brandonkelly requested review from riasvdv and a balanced review from Copilot and removed request for Copilot October 29, 2026 19:58
…authenticated-graphql

Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	CHANGELOG.md
@brandonkelly
brandonkelly merged commit 4dcd041 into feat/backport-mcp Oct 29, 2026
11 checks passed
@brandonkelly
brandonkelly deleted the feature/authenticated-graphql branch October 29, 2026 20:47
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