Repository navigation
[Client] Add client credentials, private_key_jwt and cross-app access - #669
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
An unresolved critical issuer-binding flaw allows a hostile MCP server to direct service-account authentication to its own authorization server and receive the configured secret.
9 open issues
Verify the issuer before sending service-account credentials · New Catch ConnectionException for authorization failures · New Preserve the valid scope name "0" when parsing grants · New Support confidential-client authentication in IdP exchange · New Fall back to the OAuth client ID for IdP exchange · New Remove unsupported PS256 or use a compatible signer · New Align assertion audience and lifetime with the contract · New Convert PEM keys before EdDSA signing · New Keep the Keycloak audience mapper aligned with the E2E port · New
What changed in this PR
Extends the PHP SDK’s client authorization support with service-account credentials, private-key JWT authentication, and enterprise cross-app access, building on the earlier OAuth work.
Changes:
- Adds machine-to-machine and enterprise authorization options alongside shared discovery, token exchange, and credential storage.
- Integrates authentication and authorization failures into the HTTP transport.
- Adds documentation, examples, unit and integration coverage, and a Keycloak end-to-end environment.
| File | Description |
|---|---|
tests/Unit/Client/Transport/HttpTransportTest.php |
Tests authenticated requests and authorization failures. |
tests/Unit/Client/Auth/TokenEndpointTest.php |
Tests token authentication and JWT signing. |
tests/Unit/Client/Auth/TokenEndpointAuthMethodTest.php |
Tests authentication-method negotiation. |
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 configuration and validation. |
tests/Unit/Client/Auth/FileCredentialStorageTest.php |
Tests credential persistence and permissions. |
tests/Unit/Client/Auth/ClientRegistrationTest.php |
Tests registration requests and responses. |
tests/Unit/Client/Auth/AuthorizationServerMetadataTest.php |
Tests metadata requirements and secure endpoints. |
tests/Unit/Client/Auth/AuthorizationHandlerTest.php |
Tests loopback and console handlers. |
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 protected MCP test server. |
tests/Integration/Fixture/oauth/authorization-server.php |
Provides registration and PKCE test endpoints. |
tests/Integration/ClientOAuthTest.php |
Exercises authorization over real HTTP. |
tests/E2E/server.php |
Validates Keycloak tokens at an MCP server. |
tests/E2E/run.sh |
Starts and runs the end-to-end environment. |
tests/E2E/README.md |
Documents the end-to-end setup. |
tests/E2E/nginx.conf |
Routes requests to the protected server. |
tests/E2E/keycloak/realm.json |
Defines test clients, users, 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 authorization scenarios from the failure baseline. |
tests/Conformance/conformance-baseline-2025-11-25.yml |
Updates authorization failure expectations. |
tests/Conformance/client.php |
Configures authentication for conformance scenarios. |
src/Exception/AuthorizationException.php |
Introduces an authorization-specific exception. |
src/Client/Transport/HttpTransport.php |
Adds authentication and refusal handling. |
src/Client/Auth/TokenEndpointAuthMethod.php |
Defines and negotiates client authentication methods. |
src/Client/Auth/TokenEndpoint.php |
Sends token requests and signs client assertions. |
src/Client/Auth/ProtectedResourceMetadata.php |
Parses metadata and checks resource coverage. |
src/Client/Auth/Pkce.php |
Generates PKCE verifiers and challenges. |
src/Client/Auth/OAuthConfiguration.php |
Holds and validates OAuth configuration. |
src/Client/Auth/MetadataDiscovery.php |
Discovers resource and authorization-server metadata. |
src/Client/Auth/LoopbackAuthorizationHandler.php |
Opens a 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 supported OAuth grant identifiers. |
src/Client/Auth/FileCredentialStorage.php |
Persists credentials through private temporary files. |
src/Client/Auth/CrossAppAccess.php |
Configures enterprise identity-token exchange. |
src/Client/Auth/CredentialStorageInterface.php |
Defines credential storage operations. |
src/Client/Auth/ConsoleAuthorizationHandler.php |
Accepts pasted authorization redirect URLs. |
src/Client/Auth/ClientRegistration.php |
Represents client identity and authentication details. |
src/Client/Auth/ClientRegistrar.php |
Performs dynamic client registration. |
src/Client/Auth/ClientMetadata.php |
Builds client registration metadata. |
src/Client/Auth/BearerToken.php |
Attaches a fixed bearer token. |
src/Client/Auth/AuthorizationServerMetadata.php |
Parses server capabilities and validates endpoints. |
src/Client/Auth/AuthorizationHandlerInterface.php |
Defines the authorization interaction contract. |
src/Client/Auth/AuthorizationChallenge.php |
Parses Bearer challenge parameters. |
src/Client/Auth/AuthenticatorInterface.php |
Defines request authentication and challenge handling. |
src/Client/Auth/AuthenticatingHttpClient.php |
Wraps HTTP requests with authentication retries. |
src/Client/Auth/AccessToken.php |
Represents tokens, expiry, and granted scopes. |
src/Client.php |
Documents authorization exceptions on client operations. |
mkdocs.yml |
Adds authorization to documentation navigation. |
Makefile |
Adds an end-to-end test target. |
examples/client/README.md |
Introduces the OAuth client example. |
examples/client/oauth_client.php |
Demonstrates authorization and credential persistence. |
docs/get-started/installation.md |
Lists the optional JWT signing dependency. |
docs/examples.md |
Links the OAuth client example. |
docs/client/transports.md |
Documents transport authentication and failures. |
docs/client/index.md |
Links the authorization guide. |
docs/client/errors.md |
Explains authorization exception handling. |
docs/client/authorization.md |
Documents OAuth flows and configuration. |
composer.json |
Expands the JWT package suggestion. |
CHANGELOG.md |
Records authorization features and exception changes. |
.gitignore |
Excludes credentials and generated test artifacts. |
.github/workflows/pipeline.yaml |
Adds the authorization end-to-end CI job. |
Review effort: Balanced
6c78d1a to
08385be
Compare
b27b293 to
54a4fc0
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The OAuth exchange drops valid "0" credentials and omits requested scopes from the IdP grant request, potentially causing authentication failures or under-scoped tokens.
3 open issues
8 resolved since last review
Verify the issuer before sending service-account credentials Keep the Keycloak audience mapper aligned with the E2E port Convert PEM keys before EdDSA signing Remove unsupported PS256 or use a compatible signer Fall back to the OAuth client ID for IdP exchange Support confidential-client authentication in IdP exchange Preserve the valid scope name "0" when parsing grants Catch ConnectionException for authorization failures
Review effort: Balanced
There was a problem hiding this comment.
🔵 Needs a closer look
The new credential-signing and cross-IdP authorization flows warrant independent security review of authentication and issuer-audience policies, plus real-provider validation beyond mocked request-shape tests.
2 resolved since last review
Review effort: Balanced
|
Went through the open points once more after the rebase:
|
There was a problem hiding this comment.
🔵 Needs a closer look
Human assessment is needed because cached-token scopes can bypass the configured limit during non-interactive authentication, potentially restoring broader permissions.
No open issues 🎉
1 resolved since last review
Review effort: Balanced
7acb8f1 to
edb9a9c
Compare
edb9a9c to
b4cc9aa
Compare
There was a problem hiding this comment.
🟡 Changes recommended
tests/Conformance/client.php:118 uses PHP's native RuntimeException instead of Mcp\Exception\RuntimeException, violating the repository's exception convention because it does not implement ExceptionInterface.
1 open issue
1 resolved since last review
Review effort: Balanced
There was a problem hiding this comment.
🔵 Needs a closer look
The ID-JAG delegation and private-key authentication flows cross multiple credential-bearing trust boundaries, warranting human security review and real IdP interoperability validation beyond the available static evidence.
No open issues 🎉
1 resolved since last review
Review effort: Balanced
forServiceAccount() runs without a user, setPrivateKeyJwt() replaces the shared secret with a signed assertion and setCrossAppAccess() exchanges an enterprise identity token for a grant. Without a user, the client keeps to the scopes set with setScopes().
ded6dfd to
9d0fa74
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive two-provider ID-JAG flow has only permissive mock validation, leaving real-provider client authentication and grant binding unverified and requiring security/domain review.
No open issues 🎉
Review effort: Balanced


Closes #324, closes #325 - last slice of the client authorization in chr-hertel#53.
setPrivateKeyJwt()signs an assertion instead of sending a secret - withfirebase/php-jwtinstead of the built-in signer orlcobucci/jwtthat [Client] Implement client_credentials grant (client_secret_basic + private_key_jwt) #324 mentions, sinceJwtTokenValidatoralready uses it. It stays a suggestion.setCrossAppAccess()exchanges an enterprise identity token for a grant - experimental until it is tested against a real identity provider, see [Client] Test cross-app access against a real identity provider #720Compared to the prototype, a client with only a key no longer falls back to Basic with an empty secret, a service account needs a secret or a key and never authenticates as a public client, the identity provider's token endpoint has to be https or loopback, and its answer needs the ID-JAG
issued_token_type, so another type of token isn't presented as a grant. Without a user to consent, the client also keeps to the scopes ofsetScopes()- neither a server'sinsufficient_scopenor a stored token widens them. Keying the storage by client is #719.The assertion follows RFC 10070: the issuer as the only
aud, typed asclient-authentication+jwt, and it lives 60 seconds like #324 sketched.PS256&EdDSAare left out for now - the first is missing fromfirebase/php-jwt6.10 & 7.0, the second can't sign with a PEM key.CI's
--suite allleaves out extension scenarios, so the three of them run as their own step now. The E2E suite also gets two service accounts against Keycloak, one with a secret and one with a key.