Repository navigation
[Client] Add OAuth authorization with the authorization code flow - #666
chr-hertel wants to merge 52 commits into
Conversation
b15233f to
5868b01
Compare
There was a problem hiding this comment.
🟡 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
Normalize the endpoint scheme before choosing transport · New Request the Keycloak realm's supported scopes · New Handle connection failures during authorization · New Preserve the valid scope string "0" · New Omit unsupported refresh-token grant from registration · New Treat malformed registration responses as authorization failures · New Reject non-array credential entries safely · New Prevent lost updates across storage instances · New Treat malformed token responses as authorization failures · New Derive the token audience from the configured resource URL · New
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
5868b01 to
bd1aca4
Compare
|
Open points for this one from the review rounds on #664, checked against
|
|
One more from #664, and stronger than the redirect point above since the server can trigger it: #698 only covers clients |
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.
dc73eeb to
f17d11c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Switching resources without clearing the cached token can send one resource’s credentials to another after authorization fails, creating a critical cross-resource token disclosure risk.
9 open issues
Clear cached tokens when rebinding to a different resource · New Avoid treating short-lived tokens as expired immediately · New Normalize malformed issuer entries before saving tokens · New Ignore callback requests that do not match the redirect path · New Display the authorization URL before launching a browser · New Refresh expiring credentials before sending protected requests · New Restore stored scopes before selecting or recording grants · New Preserve valid "0" values when filtering OAuth parameters · New Validate OAuth state and issuer before handling error callbacks · New
10 resolved since last review
Normalize the endpoint scheme before choosing transport Derive the token audience from the configured resource URL Treat malformed token responses as authorization failures Prevent lost updates across storage instances Reject non-array credential entries safely Treat malformed registration responses as authorization failures Omit unsupported refresh-token grant from registration Preserve the valid scope string "0" Handle connection failures during authorization Request the Keycloak realm's supported scopes
Review effort: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
An unresolved critical race allows processes sharing credential storage to refresh concurrently, potentially triggering token-family revocation and invalidating newly saved credentials.
4 open issues
9 resolved since last review
Clear cached tokens when rebinding to a different resource Validate OAuth state and issuer before handling error callbacks Preserve valid "0" values when filtering OAuth parameters Restore stored scopes before selecting or recording grants Refresh expiring credentials before sending protected requests Display the authorization URL before launching a browser Ignore callback requests that do not match the redirect path Normalize malformed issuer entries before saving tokens Avoid treating short-lived tokens as expired immediately
Review effort: Balanced
There was a problem hiding this comment.
🔵 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.


Part of #316, #319, #320, #321, #322 & #323 - third slice of the client authorization in chr-hertel#53. Follows #663 & #664.
A
401now leads to discovery, client registration, the browser, a token and a retry - nothing else changes at the call site. Refresh, step-up andoffline_accessare included, and so are the checks against a hostile server, seeWhat the Client Refusesindocs/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 asexpectedIssuerin 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, orsetExpectedIssuer()- 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 announceiss=> it needssetExpectedIssuer(). I only read the metadata, didn't sign in anywhere. A relay between two tenants on one origin isn't caught tho - onlyissor 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
AuthorizationExceptionremoves control characters - no terminal escapes or forged log lines.AuthenticatingHttpClient&OAuthalso turn off redirects on asymfony/http-clientclient, including one you pass in, since a same-origin307carried the token outside the resource path. I know that overrides amax_redirectsset on purpose, but I prefer that over sending credentials along. Other PSR-18 clients &Psr18Clientbefore 6.2 can't be switched off, so that part is docs only, see the warning atsetHttpClient().A
403only leads to a new authorization withinsufficient_scope- anything else is a real "no", and signing in again would just open the browser up to three times per request.CredentialStorageInterfacehas asynchronize()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
stateof the current flow, since anything local can reach that port. That also means an authorization server droppingstateends in the timeout instead of an error. The URL always goes toSTDERRas well - a backgrounded launcher can't tell us that no browser opened.The client registers again after a changed redirect URI or an
invalid_clientfrom 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, sinceescapeshellarg()strips the%from the URL. CI only checks the command - didn't try it with a real browser yet.