Skip to content

[Client] Add OAuth authorization with the authorization code flow - #666

Draft
chr-hertel wants to merge 52 commits into
modelcontextprotocol:mainfrom
chr-hertel:client-auth-sign-in
Draft

chr-hertel wants to merge 52 commits into
modelcontextprotocol:mainfrom
chr-hertel:client-auth-sign-in

Conversation

@chr-hertel

@chr-hertel chr-hertel commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Part of #316, #319, #320, #321, #322 & #323 - third slice of the client authorization in chr-hertel#53. Follows #663 & #664.

$client->connect(new HttpTransport('https://mcp.example.com/mcp', auth: OAuth::forApplication('My App')->build()));

A 401 now leads to discovery, client registration, the browser, a token and a retry - nothing else changes at the call site. Refresh, step-up and offline_access are included, and so are the checks against a hostile server, see What the Client Refuses in docs/client/authorization.md.

Client id metadata documents, the console handler & machine grants (client credentials, private_key_jwt, cross-app access) follow - their conformance scenarios stay in the baselines. Web applications finishing the flow in a later request are #630 / #660, independent of this.

This also adds the Keycloak end-to-end job.

A configured client needs setExpectedIssuer() - the MCP server names the authorization server, so without it a hostile server could name its own and receive the secret, or relay your client id to the real server and receive the code. Same idea as expectedIssuer in the TypeScript SDK, but required from the start since we didn't release yet.

An authorization endpoint on another origin than the issuer needs the metadata to announce iss, or setExpectedIssuer() - otherwise a hostile server can name the endpoint of a real server with its own token endpoint, so the user signs in at the real address and the code goes to the hostile one (RFC 9700 section 4.4). I first wanted a plain same-origin rule, but 6 of the 51 servers whose metadata I read split those origins, and Figma, Apify & Plaid also register dynamically. Datadog is the one that gets hit, since it registers dynamically and doesn't announce iss => it needs setExpectedIssuer(). I only read the metadata, didn't sign in anywhere. A relay between two tenants on one origin isn't caught tho - only iss or an expected issuer tells them apart.

A remote server also can't name a loopback target anymore, unless it's the expected issuer - that made the client send requests to services that only listen on the user's machine.

Exception messages quote what a server sent, so AuthorizationException removes control characters - no terminal escapes or forged log lines.

AuthenticatingHttpClient & OAuth also turn off redirects on a symfony/http-client client, including one you pass in, since a same-origin 307 carried the token outside the resource path. I know that overrides a max_redirects set on purpose, but I prefer that over sending credentials along. Other PSR-18 clients & Psr18Client before 6.2 can't be switched off, so that part is docs only, see the warning at setHttpClient().

A 403 only leads to a new authorization with insufficient_scope - anything else is a real "no", and signing in again would just open the browser up to three times per request.

CredentialStorageInterface has a synchronize() method, so only one process at a time refreshes a token from a shared storage - with rotating refresh tokens, a second refresh can get the whole token family revoked. The other process then reuses the stored result. I know the lock stays held during the token request, so a hung token endpoint blocks everyone sharing that file until the HTTP client gives up, but that's easier to live with than an extra sign-in.

The loopback handler only accepts a request on the redirect path with the state of the current flow, since anything local can reach that port. That also means an authorization server dropping state ends in the timeout instead of an error. The URL always goes to STDERR as well - a backgrounded launcher can't tell us that no browser opened.

The client registers again after a changed redirect URI or an invalid_client from the token endpoint. A server rejecting the client on its authorization page can't be detected tho, since that page doesn't redirect back => the user runs into the timeout.

On Windows the browser opens via rundll32, since escapeshellarg() strips the % from the URL. CI only checks the command - didn't try it with a real browser yet.

@chr-hertel chr-hertel added Client Issues & PRs related to the Client component auth Issues and PRs related to Authentication / OAuth labels Nov 5, 2026
@chr-hertel chr-hertel added this to the 1.0.0 milestone Nov 5, 2026
@chr-hertel
chr-hertel requested a balanced review from Copilot November 8, 2026 11:30

Copilot AI left a comment

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.

🟡 Changes recommended

A case-sensitive scheme check in the headless authorization handler can send authorization requests to validated uppercase HTTPS endpoints over plaintext TCP instead of TLS.

10 open issues
What changed in this PR

Adds OAuth authorization to the PHP SDK’s HTTP client, building on bearer-token support and metadata discovery so applications can connect to protected MCP servers through the auth parameter.

Changes:

  • Adds authorization-code flows with PKCE, client registration, credential storage, refresh support and scope handling.
  • Integrates authorization failures and bounded retries into HTTP requests.
  • Adds documentation, examples, unit and integration coverage, conformance updates and a Keycloak end-to-end job.
File Description
tests/​Unit/​Client/​Transport/​HttpTransportTest.php Tests authenticated requests and authorization failures.
tests/​Unit/​Client/​Auth/​TokenEndpointTest.php Tests token authentication and response errors.
tests/​Unit/​Client/​Auth/​TokenEndpointAuthMethodTest.php Tests authentication-method selection.
tests/​Unit/​Client/​Auth/​ProtectedResourceMetadataTest.php Tests resource URL boundaries.
tests/​Unit/​Client/​Auth/​PkceTest.php Tests PKCE generation and reconstruction.
tests/​Unit/​Client/​Auth/​OAuthTest.php Tests OAuth builder configuration.
tests/​Unit/​Client/​Auth/​MetadataDiscoveryTest.php Tests discovery locations and issuer validation.
tests/​Unit/​Client/​Auth/​FileCredentialStorageTest.php Tests persistence, isolation and file permissions.
tests/​Unit/​Client/​Auth/​ClientRegistrationTest.php Tests registration metadata and responses.
tests/​Unit/​Client/​Auth/​AuthorizationServerMetadataTest.php Tests metadata parsing and endpoint validation.
tests/​Unit/​Client/​Auth/​AuthorizationHandlerTest.php Tests loopback callbacks and timeouts.
tests/​Unit/​Client/​Auth/​AuthorizationChallengeTest.php Tests Bearer challenge parsing.
tests/​Unit/​Client/​Auth/​AuthenticatingHttpClientTest.php Tests retries, body replay and response cleanup.
tests/​Unit/​Client/​Auth/​AccessTokenTest.php Tests token expiry, scopes and serialization.
tests/​Integration/​Fixture/​oauth/​resource-server.php Provides a token-protected MCP fixture.
tests/​Integration/​Fixture/​oauth/​authorization-server.php Provides registration and PKCE exchange fixtures.
tests/​Integration/​ClientOAuthTest.php Exercises authorization over real HTTP.
tests/​E2E/​server.php Validates Keycloak tokens before serving MCP.
tests/​E2E/​run.sh Starts and runs the end-to-end environment.
tests/​E2E/​README.md Documents the Keycloak test setup.
tests/​E2E/​nginx.conf Routes fixture HTTP requests.
tests/​E2E/​keycloak/​realm.json Defines test clients, user and token audience.
tests/​E2E/​docker-compose.yml Configures Keycloak and MCP containers.
tests/​E2E/​client.php Checks protected access and credential reuse.
tests/​E2E/​BrowserAuthorizationHandler.php Automates the fixture’s login form.
tests/​Conformance/​conformance-baseline-2026-07-28.yml Removes implemented authorization exclusions.
tests/​Conformance/​conformance-baseline-2025-11-25.yml Updates authorization scenario exclusions.
tests/​Conformance/​client.php Enables OAuth for authorization scenarios.
src/​Exception/​AuthorizationException.php Introduces a dedicated authorization exception.
src/​Client/​Transport/​HttpTransport.php Integrates authentication and refusal handling.
src/​Client/​Auth/​TokenEndpointAuthMethod.php Selects token-endpoint authentication methods.
src/​Client/​Auth/​TokenEndpoint.php Exchanges credentials for access tokens.
src/​Client/​Auth/​ProtectedResourceMetadata.php Parses resource metadata and checks URL coverage.
src/​Client/​Auth/​Pkce.php Generates S256 PKCE pairs.
src/​Client/​Auth/​OAuthConfiguration.php Holds application-selected OAuth settings.
src/​Client/​Auth/​OAuth.php Builds the OAuth authenticator and dependencies.
src/​Client/​Auth/​MetadataDiscovery.php Discovers resource and authorization-server metadata.
src/​Client/​Auth/​LoopbackAuthorizationHandler.php Opens the browser and receives callbacks.
src/​Client/​Auth/​InMemoryCredentialStorage.php Stores credentials in memory.
src/​Client/​Auth/​HeadlessAuthorizationHandler.php Reads noninteractive authorization redirects.
src/​Client/​Auth/​Grant.php Defines authorization-code and refresh grants.
src/​Client/​Auth/​FileCredentialStorage.php Persists credentials in private JSON files.
src/​Client/​Auth/​CredentialStorageInterface.php Defines the credential-storage contract.
src/​Client/​Auth/​ClientRegistration.php Models registered client credentials.
src/​Client/​Auth/​ClientRegistrar.php Performs dynamic client registration.
src/​Client/​Auth/​ClientMetadata.php Serializes client registration metadata.
src/​Client/​Auth/​BearerToken.php Attaches externally supplied bearer tokens.
src/​Client/​Auth/​AuthorizationServerMetadata.php Models and validates authorization-server metadata.
src/​Client/​Auth/​AuthorizationHandlerInterface.php Defines authorization callback handling.
src/​Client/​Auth/​AuthorizationChallenge.php Parses Bearer challenge parameters.
src/​Client/​Auth/​AuthenticatorInterface.php Defines authentication and challenge handling.
src/​Client/​Auth/​AuthenticatingHttpClient.php Adds authentication and bounded HTTP retries.
src/​Client/​Auth/​AccessToken.php Models tokens, expiry and granted scopes.
src/​Client.php Documents authorization exceptions on client operations.
mkdocs.yml Adds authorization to documentation navigation.
Makefile Adds the end-to-end test target.
examples/​client/​README.md Documents running the OAuth example.
examples/​client/​oauth_client.php Demonstrates browser authorization and persistence.
docs/​examples.md Links the OAuth client example.
docs/​client/​transports.md Documents transport authentication and retries.
docs/​client/​index.md Links the authorization guide.
docs/​client/​errors.md Documents authorization failure handling.
docs/​client/​authorization.md Explains OAuth setup, behavior and safeguards.
CHANGELOG.md Records OAuth features and exception compatibility changes.
.gitignore Excludes credentials and generated test artifacts.
.github/​workflows/​pipeline.yaml Adds the Keycloak end-to-end CI job.

Review effort: Balanced

Comment thread examples/client/oauth_client.php Resolved
Comment thread examples/client/oauth_client.php Resolved
Comment thread src/Client/Auth/AccessToken.php Resolved
Comment thread src/Client/Auth/ClientRegistrar.php Resolved
Comment thread src/Client/Auth/ClientRegistrar.php Resolved
Comment thread src/Client/Auth/TokenEndpoint.php Resolved
Comment thread tests/E2E/keycloak/realm.json Resolved
@chr-hertel chr-hertel modified the milestones: 1.0.0, 0.10.0 Nov 8, 2026
@chr-hertel

Copy link
Copy Markdown
Member Author

Open points for this one from the review rounds on #664, checked against bd1aca4c by reading the code:

@chr-hertel

Copy link
Copy Markdown
Member Author

One more from #664, and stronger than the redirect point above since the server can trigger it: AuthenticatingHttpClient adds the token before the inner client runs, so a redirect never goes through isWithin(). Over a default Psr18Client, a same-origin 307 sent the Bearer to /admin/steal in a local run - didn't try it against a real server.

#698 only covers clients HttpTransport discovers, so a client passed in is still open, and so is new AuthenticatingHttpClient($yourPsr18Client, $auth) from the docs.

Pass OAuth::forApplication('My App')->build() as `auth` and a client answers a
401 with discovery, client registration, the authorization code flow with PKCE
and a retry. It refreshes tokens, handles step-up challenges and refuses what a
hostile server could send.

Client id metadata documents, the console handler, client credentials,
private_key_jwt and cross-app access follow in their own changes, so their
conformance scenarios stay in the baselines.

Copilot AI left a comment

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.

Comment thread src/Client/Auth/OAuthAuthenticator.php Resolved
Comment thread src/Client/Auth/AccessToken.php Resolved
Comment thread src/Client/Auth/OAuthAuthenticator.php Resolved
Comment thread src/Client/Auth/OAuthAuthenticator.php Resolved
Comment thread src/Client/Auth/OAuthAuthenticator.php Resolved
Comment thread src/Client/Auth/OAuthAuthenticator.php Resolved

Copilot AI left a comment

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.

Comment thread src/Client/Auth/OAuthAuthenticator.php Resolved
Comment thread src/Client/Auth/ClientRegistration.php Resolved
Comment thread src/Client/Auth/OAuthAuthenticator.php Resolved

Copilot AI left a comment

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.

🔵 Needs a closer look

Critical cross-endpoint token leakage and plaintext callback risks, compounded by OAuth lifecycle and handler edge cases, require human security assessment.

No open issues 🎉

4 resolved since last review

Review effort: Balanced

A remote MCP server could name http://127.0.0.1:port as its authorization
server, and a remote authorization server could name loopback endpoints. The
client then sent requests to services that only listen on the user's machine,
including a POST with the registration or token request.

Only a server on loopback may now name loopback, whatever the scheme. An
application that does expect a loopback issuer can allow it with
setExpectedIssuer().
A hostile MCP server can name itself as the issuer, with the authorization
endpoint of a real server and its own token endpoint. The user signs in at the
real address, and if that server sends no iss, nothing shows that the response
isn't from the issuer the flow was started with. The code and the PKCE verifier
then go to the hostile token endpoint.

An authorization endpoint on another origin than the issuer now needs the
metadata to announce iss, which makes the response prove where it comes from,
or an expected issuer. Servers on one origin are not affected.
An error description or a token type comes from a server, and the exception
message quotes it. A terminal escape sequence or a line break in it could change
what an application prints, or forge a line in its log.
The SDK can't see the redirects of a client that follows them, so an
authorization server could send a code or the client credentials anywhere.

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

auth Issues and PRs related to Authentication / OAuth Client Issues & PRs related to the Client component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants