Repository navigation
Authenticated GraphQL queries via OAuth - #20147
Merged
Merged
Conversation
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>
…authenticated-graphql
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>
Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f Co-authored-by: Amp <amp@ampcode.com>
…ed-graphql # Conflicts: # docs/oauth.md
… 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.
Amp-Thread-ID: https://ampcode.com/threads/T-ae777030-d792-49ae-8853-87e83195c88f Co-authored-by: Amp <amp@ampcode.com>
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
marked this pull request as ready for review
October 29, 2026 19:58
brandonkelly
requested review from
riasvdv
and
a balanced review from Copilot
and removed request for
Copilot
October 29, 2026 19:58
Copilot stopped reviewing on behalf of
brandonkelly due to an error
October 29, 2026 19:59
…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
riasvdv
approved these changes
Oct 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements CMS-2559 on top of #20145. The GraphQL API is now an OAuth resource (
graphql) withgraphql:readandgraphql:writelevels. 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 assections.<uid>:read→viewEntries:<uid>. Plugins opt in with anauthenticatedkey on their component config.Notes for review:
entriesqueries for authenticated users don't return nested entries. Those are reached through their owners.drafts,revisions,currentRevision,parent,ancestors,children, anddescendantsfields only return entries the user can view, andcreateDraftalways sets the authenticated user as the creator.Resource::$personalTokens(and alabel);graphqldoes,mcpdoesn't. The screen is hidden when no resource offers tokens to the user, so it only appears whenenableGqlis 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 byOAuth::createPersonalAccessToken(). As in Laravel Passport, they're issued through one shared personal access client (grant_types: ["personal_access"],provider: "craft", noscopes). That's the client Passport'spersonalAccessClient()finds in Craft 6, whose Passport guard uses thecraftuser provider. A client with no provider would only match if Craft 6 configured anapiguard. 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 aresource_idcolumn, so Craft 6 can adopt 5.12 tokens. Craft 6'screateToken()would reuse the same client, so a Passport-based Tokens screen there could list 5.12 tokens too.configVersionand the same hash, so a permission or content model change rebuilds it.ElementQueryConditionBuildernow applies non-array eager-loading conditions (e.g.editable) as-is instead of intersecting them.Evidence
AuthenticatedSchemaTest(fixtures,Gql::executeQuery):viewEntrieson test1 (own only) andviewEntries+viewPeerEntrieson withUri1 →entriesreturns only their own test1 entry plus the withUri1 entriessections.test1:delete, butdeleteEntryon another author's entry →Unable to perform the action.; on their own entry →truedeleteEntryeditable()andcanDeletechecks removed, the query and mutation tests fail. After:OK (4 tests, 14 assertions)GraphqlOAuthCest(real OAuth tokens throughGraphqlController): a token for a user withviewUserscan queryusers, a token for a user without it getsCannot query field "users", and an invalid token gets 401 withWWW-Authenticate: Bearer ... resource_metadata="…/.well-known/oauth-protected-resource/actions/graphql/api".AuthenticatedSchemaTest::testDraftsAreLimitedToUserPermissions: another author's drafts are hidden from a user withoutviewPeerEntryDrafts.GraphqlOAuthCest::testPersonalAccessTokenScopes: GraphQL is the only resource offering tokens; pickinggraphql:writealso grantsgraphql:read; MCP tokens and foreign scopes are rejected; withenableGqloff no resource offers tokens (so the screen is hidden).GraphqlOAuthCest::testPersonalAccessTokensalso checks that two personal tokens share thepersonal_accessclient, the token row stores the name, and revoking one leaves the other working.GraphqlOAuthCest::testPersonalAccessTokens: a personal token queriesusers.getUserGrants()lists it as personal withgraphql:read, alongside an OAuth-authorized client. AfterrevokeUserGrant()the token gets 401 and only the OAuth client remains.{ping}withpong. Revoke confirms and the token then gets 401.vendor/bin/codecept run unit,functional: 4,191 tests. The only errors were the 18OAuthTesttests, because its stub app has nogetGql(); withenableGql => falsein 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):lazyGqlTypes)AuthenticatedSchemaTestalso covers: a second user doesn't get the first user's cached results, losingviewPeerEntriesisn'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