From 851033520fe7c9b6205ecdf1131600981abb7990 Mon Sep 17 00:00:00 2001 From: danifronter Date: Tue, 28 Jul 2026 18:49:02 -0400 Subject: [PATCH 1/7] feat(identity): add governed high-assurance OIDC profile --- CHANGELOG.md | 12 ++ README.md | 20 ++ docs/CAPABILITY_MATRIX.md | 19 ++ docs/INTEGRATION_LARAVEL.md | 45 +++++ docs/MIGRATION_V1_TO_V2.md | 27 +++ docs/SECURITY_PROFILE.md | 42 ++++ src/Oidc/AuthorizationCodeClient.php | 27 ++- src/Oidc/AuthorizationRequestFactory.php | 32 ++- src/Oidc/AuthorizationResponse.php | 20 ++ src/Oidc/AuthorizationResponseProcessor.php | 49 +++++ src/Oidc/AuthorizationTransaction.php | 26 +++ src/Oidc/ClientAssertionFactory.php | 48 +++++ src/Oidc/DpopKey.php | 25 +++ src/Oidc/DpopProofFactory.php | 44 ++++ src/Oidc/EnterpriseProfileValidator.php | 16 ++ .../JarmAuthorizationResponseValidator.php | 54 +++++ src/Oidc/OidcClientConfiguration.php | 24 +++ src/Oidc/OidcDiscoveryClient.php | 19 +- src/Oidc/OidcDiscoveryDocument.php | 23 +++ src/Oidc/PushedAuthorizationClient.php | 49 +++++ src/Oidc/PushedAuthorizationRequest.php | 13 ++ tests/EnterpriseOidcProfileTest.php | 190 ++++++++++++++++++ 22 files changed, 816 insertions(+), 8 deletions(-) create mode 100644 docs/CAPABILITY_MATRIX.md create mode 100644 docs/INTEGRATION_LARAVEL.md create mode 100644 docs/MIGRATION_V1_TO_V2.md create mode 100644 docs/SECURITY_PROFILE.md create mode 100644 src/Oidc/AuthorizationResponse.php create mode 100644 src/Oidc/AuthorizationResponseProcessor.php create mode 100644 src/Oidc/AuthorizationTransaction.php create mode 100644 src/Oidc/ClientAssertionFactory.php create mode 100644 src/Oidc/DpopKey.php create mode 100644 src/Oidc/DpopProofFactory.php create mode 100644 src/Oidc/EnterpriseProfileValidator.php create mode 100644 src/Oidc/JarmAuthorizationResponseValidator.php create mode 100644 src/Oidc/PushedAuthorizationClient.php create mode 100644 src/Oidc/PushedAuthorizationRequest.php create mode 100644 tests/EnterpriseOidcProfileTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed108e..71932eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2.0.0 - Unreleased + +- Add typed PAR transactions and strict HTTPS transport. +- Add JARM validation pinned to RS256, exact issuer and client audience. +- Require RFC 9207 `iss` and exact `state` on authorization responses. +- Add DPoP proof creation with `htu`, `htm`, `jti`, `iat`, `ath` and nonce. +- Add interactive `private_key_jwt` client authentication. +- Add an opt-in Novvor high-assurance profile with downgrade prevention. +- Preserve 1.x secret/no-secret behavior through the `auto` compatibility mode. +- Expand Discovery parsing without advertising capabilities on behalf of the + authorization server. + ## 1.2.0 - 2026-07-27 - Add confidential workload client-credentials issuance. diff --git a/README.md b/README.md index 67ff1ae..e9f3915 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ Official server-side relying-party SDK for Novvor Cloud Identity. It provides OI discovery, Authorization Code plus PKCE, state and nonce validation, signed ID token validation, JWKS rotation, assurance claims, and logout validation. +The upcoming 2.0 line also provides an opt-in high-assurance profile: +Authorization Code + PKCE S256 + PAR + JARM + RFC 9207 + DPoP + +`private_key_jwt`. It is designed for government and regulated enterprise +integrations without weakening the interoperable standard profile. + It is not an Identity server or an administrative API client. ## Installation @@ -19,6 +24,19 @@ token validator. Never expose a client secret or token in browser code. The administrative API is intentionally excluded from this package. +## Security profiles + +- `standard`: interoperable OIDC relying-party behavior. Existing 1.x + configurations remain compatible; `auto` uses `client_secret_post` only when + a secret was supplied. +- `novvor-high-assurance-v1`: fail-closed profile requiring PAR, JARM, + `private_key_jwt`, RFC 9207 issuer binding and a DPoP-bound access token. + +Never infer high-assurance support from a successful login. Discover metadata, +run `EnterpriseProfileValidator`, store the authorization transaction +server-side, and process callbacks through `AuthorizationResponseProcessor`. +See [the Laravel integration guide](docs/INTEGRATION_LARAVEL.md). + ## Workload authentication `ClientCredentialsClient` issues server-to-server access tokens using the @@ -45,3 +63,5 @@ Only explicit HTTPS endpoints are accepted. JWT signing is pinned to RS256, keys are resolved by `kid`, and issuer, audience, authorized party, nonce, time, and back-channel logout claims are validated. Consumers must prevent replay of logout `jti` values in their shared cache. See [`SECURITY.md`](SECURITY.md). +The capability truth table and downgrade boundaries are documented in +[`docs/SECURITY_PROFILE.md`](docs/SECURITY_PROFILE.md). diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md new file mode 100644 index 0000000..0096dde --- /dev/null +++ b/docs/CAPABILITY_MATRIX.md @@ -0,0 +1,19 @@ +# Capability matrix + +| Capability | SDK 2.0 | Required server evidence | +| --- | --- | --- | +| Authorization Code | Implemented and tested | `response_types_supported` | +| PKCE S256 | Implemented and tested | `code_challenge_methods_supported` | +| RFC 9207 | Implemented and tested | issuer parameter support | +| PAR | Implemented and tested | PAR endpoint | +| JARM query.jwt | Implemented and tested | response mode and RS256 | +| private_key_jwt | Implemented and tested | auth method plus registered key | +| DPoP | Implemented and tested | signing algorithms and DPoP token type | +| ID token validation | Implemented and tested | issuer, JWKS and RS256 | +| Back-channel logout | Implemented and tested | server metadata and replay store | +| Front-channel logout | Not implemented | must not be inferred | +| Dynamic client registration | Not implemented | use governed admin plane | + +“Implemented” describes SDK capability only. Consumers must not advertise or +enable a feature until the target Identity environment proves matching runtime +support. diff --git a/docs/INTEGRATION_LARAVEL.md b/docs/INTEGRATION_LARAVEL.md new file mode 100644 index 0000000..efb22ec --- /dev/null +++ b/docs/INTEGRATION_LARAVEL.md @@ -0,0 +1,45 @@ +# Laravel integration + +## Ownership + +The SDK owns protocol construction and validation. Laravel owns configuration, +session storage, cache, redirects, logging, dependency injection and secret +retrieval. + +Bind one `OidcClientConfiguration` from `config/identity.php`. Do not call +`env()` in controllers and do not provide `.test`, localhost or HTTP defaults +when `APP_ENV=production`. + +Persist `state`, `nonce`, `code_verifier`, the intended destination and a +correlation ID in the encrypted server-side session. Do not serialize private +keys, tokens or authorization codes into the session. + +## High-assurance sequence + +```php +$transaction = $requests->transaction($configuration); +$par = $pushed->push($configuration, $transaction, $discovery->pushedAuthorizationRequestEndpoint); + +session()->put('identity.transaction', [ + 'state' => $transaction->state, + 'nonce' => $transaction->nonce, + 'code_verifier' => $transaction->codeVerifier, +]); + +return redirect()->away($requests->pushedAuthorizationUrl($configuration, $par->requestUri)); +``` + +The callback must use `AuthorizationResponseProcessor`, consume the transaction +once, exchange the code with `AuthorizationCodeClient`, validate the ID token +nonce, regenerate the Laravel session ID, bind the authenticated tenant and +only then restore the allowlisted destination. + +## Operational requirements + +- HTTPS and certificate verification are mandatory. +- Use shared cache for one-time transaction consumption and replay detection. +- Redact tokens, assertions, codes and private keys from logs and telemetry. +- Propagate a validated correlation ID. +- Treat Identity unavailability as a bounded error surface, not a redirect + loop. +- Readiness must verify configuration and key presence without exposing values. diff --git a/docs/MIGRATION_V1_TO_V2.md b/docs/MIGRATION_V1_TO_V2.md new file mode 100644 index 0000000..a64774f --- /dev/null +++ b/docs/MIGRATION_V1_TO_V2.md @@ -0,0 +1,27 @@ +# Migration from 1.x to 2.0 + +## Compatibility + +Existing constructor calls remain valid. The default client authentication mode +is `auto`: a configured secret retains the historical +`client_secret_post` behavior; a client without a secret remains public. + +No existing application is automatically promoted to the high-assurance +profile. Promotion must be coordinated with the Identity client registration. + +## Governed migration + +1. Upgrade the package while retaining `standard`. +2. Replace direct callback parsing with `AuthorizationResponseProcessor`. +3. Require RFC 9207 `iss` in callbacks. +4. Provision a dedicated asymmetric client key and register its public key. +5. Confirm Discovery advertises PAR, `query.jwt`, `private_key_jwt`, DPoP and + authorization-response issuer support. +6. Enable `novvor-high-assurance-v1`. +7. Push the request with `PushedAuthorizationClient`. +8. Redirect with only `client_id` and `request_uri`. +9. Validate JARM and then exchange the code with a DPoP key. +10. Verify negative tests before production promotion. + +Roll back by reverting the client to `standard`; do not retain a partly enabled +profile or selectively disable one of its controls. diff --git a/docs/SECURITY_PROFILE.md b/docs/SECURITY_PROFILE.md new file mode 100644 index 0000000..d54f4a3 --- /dev/null +++ b/docs/SECURITY_PROFILE.md @@ -0,0 +1,42 @@ +# Novvor Identity SDK 2.0 security profile + +## Profile contract + +The `novvor-high-assurance-v1` profile is an explicit, fail-closed contract: + +1. Authorization Code Flow only. +2. PKCE with S256. +3. Pushed Authorization Requests (PAR). +4. JWT Secured Authorization Response Mode (JARM, `query.jwt`). +5. RFC 9207 authorization-response issuer binding. +6. `private_key_jwt` client authentication. +7. DPoP proof at the token endpoint and a `DPoP` token type. +8. Exact redirect URI, state, nonce, issuer, audience, expiry and signing-key + validation. + +The SDK does not silently downgrade to an ordinary query response, bearer +token, shared secret, missing issuer, unknown algorithm or unknown signing key. + +## Threat boundaries + +| Threat | Control | +| --- | --- | +| Authorization request tampering | PAR and server-side transaction state | +| Callback mix-up | RFC 9207 exact issuer plus state | +| Response tampering | JARM RS256 signature and claims | +| Code interception | PKCE S256 | +| Client impersonation | short-lived `private_key_jwt` with unique `jti` | +| Access-token replay | DPoP proof bound to method, URI and token hash | +| Key confusion | RS256 allowlist and exact `kid` | +| Redirect abuse | exact redirect URI registered at Identity | + +DPoP is defense in depth, not a replacement for TLS, strict redirect +registration, short token lifetimes or server-side replay controls. + +## Key custody + +The SDK accepts private key material from the host application but never +persists it. Production applications should obtain keys from their secret +manager or KMS integration, keep distinct keys per client and environment, and +rotate using overlapping public keys. No private key belongs in Git, logs, +exceptions, cache payloads or browser code. diff --git a/src/Oidc/AuthorizationCodeClient.php b/src/Oidc/AuthorizationCodeClient.php index 1b56eb3..e6d70b6 100644 --- a/src/Oidc/AuthorizationCodeClient.php +++ b/src/Oidc/AuthorizationCodeClient.php @@ -10,7 +10,14 @@ public function __construct(private readonly ClientInterface $http) { } - public function exchange(OidcClientConfiguration $configuration, string $code, string $codeVerifier, ?string $correlationId = null): OidcTokenSet + public function exchange( + OidcClientConfiguration $configuration, + string $code, + string $codeVerifier, + ?string $correlationId = null, + ?DpopKey $dpopKey = null, + ?string $dpopNonce = null, + ): OidcTokenSet { if ($code === '' || $codeVerifier === '') { throw new OidcException('Authorization code and PKCE verifier are required.'); @@ -23,15 +30,21 @@ public function exchange(OidcClientConfiguration $configuration, string $code, s 'client_id' => $configuration->clientId, 'code_verifier' => $codeVerifier, ]; - if ($configuration->clientSecret !== null && $configuration->clientSecret !== '') { - $form['client_secret'] = $configuration->clientSecret; + (new ClientAssertionFactory())->authenticate($configuration, $form); + + $headers = ['Accept' => 'application/json']; + if ($dpopKey !== null) { + $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'POST', $configuration->tokenEndpoint, nonce: $dpopNonce); + } + if ($configuration->profile === 'novvor-high-assurance-v1' && $dpopKey === null) { + throw new OidcException('The high-assurance profile requires a DPoP key.'); } try { $response = $this->http->request('POST', $configuration->tokenEndpoint, [ ...OidcHttpRequestOptions::strict( $configuration->httpTimeoutSeconds, - ['Accept' => 'application/json'], + $headers, $correlationId, ), 'form_params' => $form, @@ -50,13 +63,17 @@ public function exchange(OidcClientConfiguration $configuration, string $code, s if ($accessToken === '' || $idToken === '') { throw new OidcException('OIDC token response is missing an access token or ID token.'); } + $tokenType = is_string($payload['token_type'] ?? null) ? $payload['token_type'] : 'Bearer'; + if ($configuration->profile === 'novvor-high-assurance-v1' && strcasecmp($tokenType, 'DPoP') !== 0) { + throw new OidcException('The high-assurance profile requires a DPoP-bound access token.'); + } return new OidcTokenSet( accessToken: $accessToken, idToken: $idToken, refreshToken: is_string($payload['refresh_token'] ?? null) ? $payload['refresh_token'] : null, expiresIn: max(0, (int) ($payload['expires_in'] ?? 0)), - tokenType: is_string($payload['token_type'] ?? null) ? $payload['token_type'] : 'Bearer', + tokenType: $tokenType, ); } } diff --git a/src/Oidc/AuthorizationRequestFactory.php b/src/Oidc/AuthorizationRequestFactory.php index 35aaefb..13437d6 100644 --- a/src/Oidc/AuthorizationRequestFactory.php +++ b/src/Oidc/AuthorizationRequestFactory.php @@ -6,6 +6,18 @@ final class AuthorizationRequestFactory { /** @return array{url: string, state: string, nonce: string, code_verifier: string} */ public function create(OidcClientConfiguration $configuration, ?string $requiredAcr = null, ?int $maxAge = null): array + { + $transaction = $this->transaction($configuration, $requiredAcr, $maxAge); + + return [ + 'url' => $configuration->authorizationEndpoint.'?'.http_build_query($transaction->parameters), + 'state' => $transaction->state, + 'nonce' => $transaction->nonce, + 'code_verifier' => $transaction->codeVerifier, + ]; + } + + public function transaction(OidcClientConfiguration $configuration, ?string $requiredAcr = null, ?int $maxAge = null): AuthorizationTransaction { $state = $this->randomToken(); $nonce = $this->randomToken(); @@ -15,7 +27,7 @@ public function create(OidcClientConfiguration $configuration, ?string $required 'response_type' => 'code', 'client_id' => $configuration->clientId, 'redirect_uri' => $configuration->redirectUri, - 'scope' => 'openid profile email', + 'scope' => implode(' ', $configuration->scopes), 'state' => $state, 'nonce' => $nonce, 'code_challenge' => $challenge, @@ -24,7 +36,23 @@ public function create(OidcClientConfiguration $configuration, ?string $required 'max_age' => $maxAge, ], static fn (mixed $value): bool => $value !== null && $value !== ''); - return ['url' => $configuration->authorizationEndpoint.'?'.http_build_query($query), 'state' => $state, 'nonce' => $nonce, 'code_verifier' => $verifier]; + if ($configuration->profile === 'novvor-high-assurance-v1') { + $query['response_mode'] = 'query.jwt'; + } + + return new AuthorizationTransaction($state, $nonce, $verifier, $query); + } + + public function pushedAuthorizationUrl(OidcClientConfiguration $configuration, string $requestUri): string + { + if ($requestUri === '' || ! str_starts_with($requestUri, 'urn:ietf:params:oauth:request_uri:')) { + throw new OidcException('PAR request URI is invalid.'); + } + + return $configuration->authorizationEndpoint.'?'.http_build_query([ + 'client_id' => $configuration->clientId, + 'request_uri' => $requestUri, + ]); } public function assertState(string $expectedState, string $returnedState): void diff --git a/src/Oidc/AuthorizationResponse.php b/src/Oidc/AuthorizationResponse.php new file mode 100644 index 0000000..7657785 --- /dev/null +++ b/src/Oidc/AuthorizationResponse.php @@ -0,0 +1,20 @@ +code !== null; + } +} diff --git a/src/Oidc/AuthorizationResponseProcessor.php b/src/Oidc/AuthorizationResponseProcessor.php new file mode 100644 index 0000000..f089623 --- /dev/null +++ b/src/Oidc/AuthorizationResponseProcessor.php @@ -0,0 +1,49 @@ + $parameters */ + public function process( + OidcClientConfiguration $configuration, + array $parameters, + string $expectedState, + ?string $correlationId = null, + ): AuthorizationResponse { + if (is_string($parameters['response'] ?? null)) { + if ($this->jarm === null) { + throw new OidcException('JARM response received without a configured validator.'); + } + + $parameters = $this->jarm->validate($configuration, $parameters['response'], $correlationId); + } + + $state = is_string($parameters['state'] ?? null) ? $parameters['state'] : ''; + $issuer = is_string($parameters['iss'] ?? null) ? rtrim($parameters['iss'], '/') : ''; + if ($state === '' || ! hash_equals($expectedState, $state)) { + throw new OidcException('Authorization response state does not match the transaction.'); + } + if ($issuer === '' || ! hash_equals(rtrim($configuration->issuer, '/'), $issuer)) { + throw new OidcException('Authorization response issuer does not match discovery.'); + } + + $code = is_string($parameters['code'] ?? null) && $parameters['code'] !== '' ? $parameters['code'] : null; + $error = is_string($parameters['error'] ?? null) && $parameters['error'] !== '' ? $parameters['error'] : null; + if (($code === null) === ($error === null)) { + throw new OidcException('Authorization response must contain exactly one code or error.'); + } + + return new AuthorizationResponse( + $code, + $error, + is_string($parameters['error_description'] ?? null) ? $parameters['error_description'] : null, + $issuer, + $state, + ); + } +} diff --git a/src/Oidc/AuthorizationTransaction.php b/src/Oidc/AuthorizationTransaction.php new file mode 100644 index 0000000..eafcbae --- /dev/null +++ b/src/Oidc/AuthorizationTransaction.php @@ -0,0 +1,26 @@ + $parameters */ + public function __construct( + public string $state, + public string $nonce, + public string $codeVerifier, + public array $parameters, + ) { + } + + /** @return array{state:string,nonce:string,code_verifier:string,parameters:array} */ + public function toArray(): array + { + return [ + 'state' => $this->state, + 'nonce' => $this->nonce, + 'code_verifier' => $this->codeVerifier, + 'parameters' => $this->parameters, + ]; + } +} diff --git a/src/Oidc/ClientAssertionFactory.php b/src/Oidc/ClientAssertionFactory.php new file mode 100644 index 0000000..f30a845 --- /dev/null +++ b/src/Oidc/ClientAssertionFactory.php @@ -0,0 +1,48 @@ +clientAuthenticationMethod !== 'private_key_jwt') { + throw new OidcException('Client is not configured for private_key_jwt.'); + } + + $now ??= time(); + + return JWT::encode([ + 'iss' => $configuration->clientId, + 'sub' => $configuration->clientId, + 'aud' => $configuration->tokenEndpoint, + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + 60, + 'jti' => bin2hex(random_bytes(16)), + ], (string) $configuration->privateKey, 'RS256', $configuration->privateKeyId); + } + + /** @param array $form */ + public function authenticate(OidcClientConfiguration $configuration, array &$form): void + { + $method = $configuration->clientAuthenticationMethod; + if ($method === 'auto') { + $method = ($configuration->clientSecret ?? '') !== '' ? 'client_secret_post' : 'none'; + } + + if ($method === 'none') { + return; + } + if ($method === 'client_secret_post') { + $form['client_secret'] = (string) $configuration->clientSecret; + + return; + } + + $form['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + $form['client_assertion'] = $this->create($configuration); + } +} diff --git a/src/Oidc/DpopKey.php b/src/Oidc/DpopKey.php new file mode 100644 index 0000000..87a6fa1 --- /dev/null +++ b/src/Oidc/DpopKey.php @@ -0,0 +1,25 @@ + $publicJwk */ + public function __construct( + public string $privateKey, + public array $publicJwk, + public string $algorithm = 'ES256', + ) { + if ($privateKey === '' || ! in_array($algorithm, ['ES256', 'RS256'], true)) { + throw new OidcException('DPoP requires an ES256 or RS256 private key.'); + } + foreach (['d', 'p', 'q', 'dp', 'dq', 'qi', 'k'] as $privateParameter) { + if (array_key_exists($privateParameter, $publicJwk)) { + throw new OidcException('DPoP public JWK contains private key material.'); + } + } + if (! isset($publicJwk['kty'])) { + throw new OidcException('DPoP public JWK is incomplete.'); + } + } +} diff --git a/src/Oidc/DpopProofFactory.php b/src/Oidc/DpopProofFactory.php new file mode 100644 index 0000000..c5a4b58 --- /dev/null +++ b/src/Oidc/DpopProofFactory.php @@ -0,0 +1,44 @@ + bin2hex(random_bytes(16)), + 'htm' => $method, + 'htu' => $htu, + 'iat' => $now ?? time(), + ]; + if ($accessToken !== null && $accessToken !== '') { + $claims['ath'] = rtrim(strtr(base64_encode(hash('sha256', $accessToken, true)), '+/', '-_'), '='); + } + if ($nonce !== null && $nonce !== '') { + $claims['nonce'] = $nonce; + } + + return JWT::encode($claims, $key->privateKey, $key->algorithm, null, [ + 'typ' => 'dpop+jwt', + 'jwk' => $key->publicJwk, + ]); + } +} diff --git a/src/Oidc/EnterpriseProfileValidator.php b/src/Oidc/EnterpriseProfileValidator.php new file mode 100644 index 0000000..b685bda --- /dev/null +++ b/src/Oidc/EnterpriseProfileValidator.php @@ -0,0 +1,16 @@ +profile !== 'novvor-high-assurance-v1') { + return; + } + if (! $discovery->supportsHighAssuranceProfile()) { + throw new OidcException('Identity provider does not prove the Novvor high-assurance profile.'); + } + } +} diff --git a/src/Oidc/JarmAuthorizationResponseValidator.php b/src/Oidc/JarmAuthorizationResponseValidator.php new file mode 100644 index 0000000..0b930b5 --- /dev/null +++ b/src/Oidc/JarmAuthorizationResponseValidator.php @@ -0,0 +1,54 @@ + */ + public function validate(OidcClientConfiguration $configuration, string $responseJwt, ?string $correlationId = null): array + { + $parts = explode('.', $responseJwt); + $header = count($parts) === 3 ? json_decode((string) base64_decode(strtr($parts[0].str_repeat('=', (4 - strlen($parts[0]) % 4) % 4), '-_', '+/'), true), true) : null; + if (! is_array($header) || ($header['alg'] ?? null) !== 'RS256' || ! is_string($header['kid'] ?? null) || $header['kid'] === '') { + throw new OidcException('JARM response does not use an allowed signing algorithm.'); + } + + try { + $httpResponse = $this->http->request('GET', $configuration->jwksUri, OidcHttpRequestOptions::strict($configuration->httpTimeoutSeconds, [], $correlationId)); + $jwks = json_decode((string) $httpResponse->getBody(), true); + $keys = is_array($jwks) ? JWK::parseKeySet($jwks) : []; + $key = $keys[$header['kid']] ?? null; + if ($httpResponse->getStatusCode() !== 200 || ! $key instanceof Key) { + throw new OidcException('JARM signing key is unknown.'); + } + $claims = (array) JWT::decode($responseJwt, $key); + } catch (OidcException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new OidcException('JARM signature is invalid.', 0, $exception); + } + + $aud = $claims['aud'] ?? null; + $audiences = is_string($aud) ? [$aud] : (is_array($aud) ? $aud : []); + $now = time(); + if (rtrim((string) ($claims['iss'] ?? ''), '/') !== rtrim($configuration->issuer, '/') + || ! in_array($configuration->clientId, $audiences, true) + || ! is_numeric($claims['exp'] ?? null) + || (int) $claims['exp'] <= $now + || ! is_numeric($claims['iat'] ?? null) + || (int) $claims['iat'] > $now + 30) { + throw new OidcException('JARM protocol claims are invalid.'); + } + + return $claims; + } +} diff --git a/src/Oidc/OidcClientConfiguration.php b/src/Oidc/OidcClientConfiguration.php index 9fb7157..07b6b17 100644 --- a/src/Oidc/OidcClientConfiguration.php +++ b/src/Oidc/OidcClientConfiguration.php @@ -6,6 +6,7 @@ final readonly class OidcClientConfiguration { + /** @param list $scopes */ public function __construct( public string $issuer, public string $clientId, @@ -15,6 +16,11 @@ public function __construct( public string $jwksUri, public ?string $clientSecret = null, public int $httpTimeoutSeconds = 5, + public string $clientAuthenticationMethod = 'auto', + public ?string $privateKey = null, + public ?string $privateKeyId = null, + public array $scopes = ['openid', 'profile', 'email'], + public string $profile = 'standard', ) { foreach ([$issuer, $redirectUri, $authorizationEndpoint, $tokenEndpoint, $jwksUri] as $url) { if (filter_var($url, FILTER_VALIDATE_URL) === false || parse_url($url, PHP_URL_SCHEME) !== 'https') { @@ -27,5 +33,23 @@ public function __construct( if ($httpTimeoutSeconds < 1 || $httpTimeoutSeconds > 30) { throw new InvalidArgumentException('Identity HTTP timeout must be between one and thirty seconds.'); } + if (! in_array($clientAuthenticationMethod, ['auto', 'none', 'client_secret_post', 'private_key_jwt'], true)) { + throw new InvalidArgumentException('Unsupported OIDC client authentication method.'); + } + if ($clientAuthenticationMethod === 'client_secret_post' && ($clientSecret === null || $clientSecret === '')) { + throw new InvalidArgumentException('client_secret_post requires a client secret.'); + } + if ($clientAuthenticationMethod === 'private_key_jwt' && (($privateKey ?? '') === '' || ($privateKeyId ?? '') === '')) { + throw new InvalidArgumentException('private_key_jwt requires a private key and key ID.'); + } + if ($scopes === [] || in_array('openid', $scopes, true) === false) { + throw new InvalidArgumentException('OIDC scopes must include openid.'); + } + if (! in_array($profile, ['standard', 'novvor-high-assurance-v1'], true)) { + throw new InvalidArgumentException('Unsupported OIDC security profile.'); + } + if ($profile === 'novvor-high-assurance-v1' && $clientAuthenticationMethod !== 'private_key_jwt') { + throw new InvalidArgumentException('The Novvor high-assurance profile requires private_key_jwt.'); + } } } diff --git a/src/Oidc/OidcDiscoveryClient.php b/src/Oidc/OidcDiscoveryClient.php index fddb01b..759385e 100644 --- a/src/Oidc/OidcDiscoveryClient.php +++ b/src/Oidc/OidcDiscoveryClient.php @@ -38,7 +38,7 @@ public function discover(string $issuer, int $timeoutSeconds = 5, ?string $corre } } - foreach (['userinfo_endpoint', 'end_session_endpoint'] as $field) { + foreach (['userinfo_endpoint', 'end_session_endpoint', 'pushed_authorization_request_endpoint'] as $field) { if (isset($document[$field]) && (! is_string($document[$field]) || ! self::isHttpsUrl($document[$field]))) { throw new OidcException('OIDC discovery document contains an invalid '.$field.'.'); } @@ -51,6 +51,13 @@ public function discover(string $issuer, int $timeoutSeconds = 5, ?string $corre jwksUri: (string) $document['jwks_uri'], userinfoEndpoint: is_string($document['userinfo_endpoint'] ?? null) ? $document['userinfo_endpoint'] : null, endSessionEndpoint: is_string($document['end_session_endpoint'] ?? null) ? $document['end_session_endpoint'] : null, + pushedAuthorizationRequestEndpoint: is_string($document['pushed_authorization_request_endpoint'] ?? null) ? $document['pushed_authorization_request_endpoint'] : null, + responseModesSupported: self::stringList($document['response_modes_supported'] ?? []), + grantTypesSupported: self::stringList($document['grant_types_supported'] ?? []), + tokenEndpointAuthMethodsSupported: self::stringList($document['token_endpoint_auth_methods_supported'] ?? []), + dpopSigningAlgValuesSupported: self::stringList($document['dpop_signing_alg_values_supported'] ?? []), + authorizationResponseIssuerParameterSupported: ($document['authorization_response_iss_parameter_supported'] ?? false) === true, + metadata: $document, ); } @@ -58,4 +65,14 @@ private static function isHttpsUrl(string $url): bool { return filter_var($url, FILTER_VALIDATE_URL) !== false && parse_url($url, PHP_URL_SCHEME) === 'https'; } + + /** @return list */ + private static function stringList(mixed $value): array + { + if (! is_array($value) || ! array_is_list($value)) { + return []; + } + + return array_values(array_filter($value, static fn (mixed $item): bool => is_string($item) && $item !== '')); + } } diff --git a/src/Oidc/OidcDiscoveryDocument.php b/src/Oidc/OidcDiscoveryDocument.php index 3947955..d62cc91 100644 --- a/src/Oidc/OidcDiscoveryDocument.php +++ b/src/Oidc/OidcDiscoveryDocument.php @@ -4,6 +4,13 @@ final readonly class OidcDiscoveryDocument { + /** + * @param list $responseModesSupported + * @param list $grantTypesSupported + * @param list $tokenEndpointAuthMethodsSupported + * @param list $dpopSigningAlgValuesSupported + * @param array $metadata + */ public function __construct( public string $issuer, public string $authorizationEndpoint, @@ -11,6 +18,13 @@ public function __construct( public string $jwksUri, public ?string $userinfoEndpoint = null, public ?string $endSessionEndpoint = null, + public ?string $pushedAuthorizationRequestEndpoint = null, + public array $responseModesSupported = [], + public array $grantTypesSupported = [], + public array $tokenEndpointAuthMethodsSupported = [], + public array $dpopSigningAlgValuesSupported = [], + public bool $authorizationResponseIssuerParameterSupported = false, + public array $metadata = [], ) { } @@ -27,4 +41,13 @@ public function configureClient(string $clientId, string $redirectUri, ?string $ httpTimeoutSeconds: $timeoutSeconds, ); } + + public function supportsHighAssuranceProfile(): bool + { + return $this->pushedAuthorizationRequestEndpoint !== null + && in_array('query.jwt', $this->responseModesSupported, true) + && in_array('private_key_jwt', $this->tokenEndpointAuthMethodsSupported, true) + && $this->dpopSigningAlgValuesSupported !== [] + && $this->authorizationResponseIssuerParameterSupported; + } } diff --git a/src/Oidc/PushedAuthorizationClient.php b/src/Oidc/PushedAuthorizationClient.php new file mode 100644 index 0000000..e406b42 --- /dev/null +++ b/src/Oidc/PushedAuthorizationClient.php @@ -0,0 +1,49 @@ + (string) $value, $transaction->parameters); + (new ClientAssertionFactory())->authenticate($configuration, $form); + + try { + $response = $this->http->request('POST', $endpoint, [ + ...OidcHttpRequestOptions::strict( + $configuration->httpTimeoutSeconds, + ['Accept' => 'application/json'], + $correlationId, + ), + 'form_params' => $form, + ]); + } catch (\Throwable $exception) { + throw new OidcException('Pushed authorization request failed.', 0, $exception); + } + + $payload = json_decode((string) $response->getBody(), true); + if (! in_array($response->getStatusCode(), [200, 201], true) || ! is_array($payload)) { + throw new OidcException('Authorization server rejected the pushed authorization request.'); + } + + return new PushedAuthorizationRequest( + is_string($payload['request_uri'] ?? null) ? $payload['request_uri'] : '', + (int) ($payload['expires_in'] ?? 0), + ); + } +} diff --git a/src/Oidc/PushedAuthorizationRequest.php b/src/Oidc/PushedAuthorizationRequest.php new file mode 100644 index 0000000..5f7b7f0 --- /dev/null +++ b/src/Oidc/PushedAuthorizationRequest.php @@ -0,0 +1,13 @@ +rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(201, [], '{"request_uri":"urn:ietf:params:oauth:request_uri:abc","expires_in":90}')])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + $http = new Client(['handler' => $stack]); + $configuration = $this->configuration($privateKey); + $transaction = (new AuthorizationRequestFactory())->transaction($configuration); + + $pushed = (new PushedAuthorizationClient($http))->push( + $configuration, + $transaction, + 'https://identity.example.com/oauth/par', + 'correlation-1', + ); + + self::assertSame('urn:ietf:params:oauth:request_uri:abc', $pushed->requestUri); + self::assertSame(90, $pushed->expiresIn); + parse_str((string) $history[0]['request']->getBody(), $body); + $request = $history[0]['request']; + self::assertSame($configuration->clientId, $body['client_id']); + self::assertArrayHasKey('client_assertion', $body); + self::assertArrayNotHasKey('client_secret', $body); + self::assertSame('correlation-1', $request->getHeaderLine('X-Correlation-ID')); + } + + public function test_rfc9207_requires_exact_issuer_and_state(): void + { + $processor = new AuthorizationResponseProcessor(); + $result = $processor->process($this->configuration(), [ + 'code' => 'code-1', + 'state' => 'state-1', + 'iss' => 'https://identity.example.com', + ], 'state-1'); + self::assertTrue($result->succeeded()); + + $this->expectException(OidcException::class); + $processor->process($this->configuration(), [ + 'code' => 'code-1', + 'state' => 'state-1', + 'iss' => 'https://attacker.example', + ], 'state-1'); + } + + public function test_jarm_validates_signature_issuer_audience_expiry_and_state(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $jwt = JWT::encode([ + 'iss' => 'https://identity.example.com', + 'aud' => 'client', + 'iat' => time(), + 'exp' => time() + 60, + 'code' => 'code-1', + 'state' => 'state-1', + ], $privateKey, 'RS256', 'test-key'); + $http = $this->http([new Response(200, [], json_encode(['keys' => [$jwk]], JSON_THROW_ON_ERROR))]); + + $result = (new AuthorizationResponseProcessor(new JarmAuthorizationResponseValidator($http))) + ->process($this->configuration(), ['response' => $jwt], 'state-1'); + + self::assertSame('code-1', $result->code); + } + + public function test_dpop_proof_is_bound_to_method_uri_and_access_token(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $jwt = (new DpopProofFactory())->create( + new DpopKey($privateKey, $jwk, 'RS256'), + 'post', + 'https://api.example.com/token?ignored=1', + 'access-token', + 'nonce-1', + 1700000000, + ); + [$header, $claims] = $this->decode($jwt); + + self::assertSame('dpop+jwt', $header['typ']); + self::assertSame('POST', $claims['htm']); + self::assertSame('https://api.example.com/token', $claims['htu']); + self::assertSame('nonce-1', $claims['nonce']); + self::assertSame(rtrim(strtr(base64_encode(hash('sha256', 'access-token', true)), '+/', '-_'), '='), $claims['ath']); + self::assertArrayNotHasKey('d', $header['jwk']); + } + + public function test_high_assurance_profile_rejects_secret_authentication_downgrade(): void + { + $this->expectException(\InvalidArgumentException::class); + new OidcClientConfiguration( + 'https://identity.example.com', 'client', 'https://app.example.com/callback', + 'https://identity.example.com/authorize', 'https://identity.example.com/token', + 'https://identity.example.com/jwks', 'secret', 5, 'client_secret_post', + profile: 'novvor-high-assurance-v1', + ); + } + + public function test_auto_authentication_keeps_v1_secret_compatibility(): void + { + $configuration = new OidcClientConfiguration( + 'https://identity.example.com', 'client', 'https://app.example.com/callback', + 'https://identity.example.com/authorize', 'https://identity.example.com/token', + 'https://identity.example.com/jwks', 'legacy-secret', + ); + $form = []; + (new ClientAssertionFactory())->authenticate($configuration, $form); + self::assertSame('legacy-secret', $form['client_secret']); + } + + private function configuration(?string $privateKey = null): OidcClientConfiguration + { + return new OidcClientConfiguration( + 'https://identity.example.com', 'client', 'https://app.example.com/callback', + 'https://identity.example.com/authorize', 'https://identity.example.com/token', + 'https://identity.example.com/jwks', null, 5, + $privateKey === null ? 'none' : 'private_key_jwt', + $privateKey, + $privateKey === null ? null : 'client-key', + profile: $privateKey === null ? 'standard' : 'novvor-high-assurance-v1', + ); + } + + /** @return array{0: string, 1: array} */ + private function rsaKey(): array + { + $resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + self::assertNotFalse($resource); + $privateKey = ''; + self::assertTrue(openssl_pkey_export($resource, $privateKey)); + $details = openssl_pkey_get_details($resource); + self::assertIsArray($details); + + return [$privateKey, [ + 'kty' => 'RSA', + 'kid' => 'test-key', + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $this->base64Url($details['rsa']['n']), + 'e' => $this->base64Url($details['rsa']['e']), + ]]; + } + + /** @param list $responses */ + private function http(array $responses): Client + { + $stack = HandlerStack::create(new MockHandler($responses)); + + return new Client(['handler' => $stack]); + } + + /** @return array{0: array, 1: array} */ + private function decode(string $jwt): array + { + $parts = explode('.', $jwt); + return [json_decode($this->base64UrlDecode($parts[0]), true, flags: JSON_THROW_ON_ERROR), json_decode($this->base64UrlDecode($parts[1]), true, flags: JSON_THROW_ON_ERROR)]; + } + + private function base64Url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $value): string + { + return (string) base64_decode(strtr($value.str_repeat('=', (4 - strlen($value) % 4) % 4), '-_', '+/'), true); + } +} From a7a9803d47825f1ae19b5aab68f3c955b7710ba2 Mon Sep 17 00:00:00 2001 From: danifronter Date: Tue, 28 Jul 2026 18:52:48 -0400 Subject: [PATCH 2/7] fix(oidc): prevent high-assurance protocol downgrade --- src/Oidc/AuthorizationRequestFactory.php | 4 ++++ src/Oidc/AuthorizationResponseProcessor.php | 3 +++ src/Oidc/ClientAssertionFactory.php | 8 ++++---- src/Oidc/PushedAuthorizationClient.php | 2 +- tests/EnterpriseOidcProfileTest.php | 22 +++++++++++++++++++++ 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/Oidc/AuthorizationRequestFactory.php b/src/Oidc/AuthorizationRequestFactory.php index 13437d6..97a4975 100644 --- a/src/Oidc/AuthorizationRequestFactory.php +++ b/src/Oidc/AuthorizationRequestFactory.php @@ -7,6 +7,10 @@ final class AuthorizationRequestFactory /** @return array{url: string, state: string, nonce: string, code_verifier: string} */ public function create(OidcClientConfiguration $configuration, ?string $requiredAcr = null, ?int $maxAge = null): array { + if ($configuration->profile === 'novvor-high-assurance-v1') { + throw new OidcException('The high-assurance profile requires PAR; use transaction(), push(), and pushedAuthorizationUrl().'); + } + $transaction = $this->transaction($configuration, $requiredAcr, $maxAge); return [ diff --git a/src/Oidc/AuthorizationResponseProcessor.php b/src/Oidc/AuthorizationResponseProcessor.php index f089623..0c85c65 100644 --- a/src/Oidc/AuthorizationResponseProcessor.php +++ b/src/Oidc/AuthorizationResponseProcessor.php @@ -15,6 +15,9 @@ public function process( string $expectedState, ?string $correlationId = null, ): AuthorizationResponse { + if ($configuration->profile === 'novvor-high-assurance-v1' && ! is_string($parameters['response'] ?? null)) { + throw new OidcException('The high-assurance profile requires a JARM authorization response.'); + } if (is_string($parameters['response'] ?? null)) { if ($this->jarm === null) { throw new OidcException('JARM response received without a configured validator.'); diff --git a/src/Oidc/ClientAssertionFactory.php b/src/Oidc/ClientAssertionFactory.php index f30a845..c469a9e 100644 --- a/src/Oidc/ClientAssertionFactory.php +++ b/src/Oidc/ClientAssertionFactory.php @@ -6,7 +6,7 @@ final class ClientAssertionFactory { - public function create(OidcClientConfiguration $configuration, ?int $now = null): string + public function create(OidcClientConfiguration $configuration, ?int $now = null, ?string $audience = null): string { if ($configuration->clientAuthenticationMethod !== 'private_key_jwt') { throw new OidcException('Client is not configured for private_key_jwt.'); @@ -17,7 +17,7 @@ public function create(OidcClientConfiguration $configuration, ?int $now = null) return JWT::encode([ 'iss' => $configuration->clientId, 'sub' => $configuration->clientId, - 'aud' => $configuration->tokenEndpoint, + 'aud' => $audience ?? $configuration->tokenEndpoint, 'iat' => $now, 'nbf' => $now, 'exp' => $now + 60, @@ -26,7 +26,7 @@ public function create(OidcClientConfiguration $configuration, ?int $now = null) } /** @param array $form */ - public function authenticate(OidcClientConfiguration $configuration, array &$form): void + public function authenticate(OidcClientConfiguration $configuration, array &$form, ?string $audience = null): void { $method = $configuration->clientAuthenticationMethod; if ($method === 'auto') { @@ -43,6 +43,6 @@ public function authenticate(OidcClientConfiguration $configuration, array &$for } $form['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; - $form['client_assertion'] = $this->create($configuration); + $form['client_assertion'] = $this->create($configuration, audience: $audience); } } diff --git a/src/Oidc/PushedAuthorizationClient.php b/src/Oidc/PushedAuthorizationClient.php index e406b42..065b71c 100644 --- a/src/Oidc/PushedAuthorizationClient.php +++ b/src/Oidc/PushedAuthorizationClient.php @@ -21,7 +21,7 @@ public function push( } $form = array_map(static fn (string|int $value): string => (string) $value, $transaction->parameters); - (new ClientAssertionFactory())->authenticate($configuration, $form); + (new ClientAssertionFactory())->authenticate($configuration, $form, $endpoint); try { $response = $this->http->request('POST', $endpoint, [ diff --git a/tests/EnterpriseOidcProfileTest.php b/tests/EnterpriseOidcProfileTest.php index 83746ad..2028807 100644 --- a/tests/EnterpriseOidcProfileTest.php +++ b/tests/EnterpriseOidcProfileTest.php @@ -47,6 +47,10 @@ public function test_par_uses_private_key_jwt_and_returns_typed_request(): void self::assertArrayHasKey('client_assertion', $body); self::assertArrayNotHasKey('client_secret', $body); self::assertSame('correlation-1', $request->getHeaderLine('X-Correlation-ID')); + $assertionJwt = $body['client_assertion'] ?? null; + self::assertIsString($assertionJwt); + [, $assertion] = $this->decode($assertionJwt); + self::assertSame('https://identity.example.com/oauth/par', $assertion['aud']); } public function test_rfc9207_requires_exact_issuer_and_state(): void @@ -118,6 +122,24 @@ public function test_high_assurance_profile_rejects_secret_authentication_downgr ); } + public function test_high_assurance_profile_rejects_direct_authorization(): void + { + [$privateKey] = $this->rsaKey(); + $this->expectException(OidcException::class); + (new AuthorizationRequestFactory())->create($this->configuration($privateKey)); + } + + public function test_high_assurance_profile_rejects_plain_callback(): void + { + [$privateKey] = $this->rsaKey(); + $this->expectException(OidcException::class); + (new AuthorizationResponseProcessor())->process($this->configuration($privateKey), [ + 'code' => 'code-1', + 'state' => 'state-1', + 'iss' => 'https://identity.example.com', + ], 'state-1'); + } + public function test_auto_authentication_keeps_v1_secret_compatibility(): void { $configuration = new OidcClientConfiguration( From d20b426416bf070d719287ce042188a3dbf38748 Mon Sep 17 00:00:00 2001 From: danifronter Date: Tue, 28 Jul 2026 19:06:41 -0400 Subject: [PATCH 3/7] feat(oidc): complete governed token lifecycle clients --- CHANGELOG.md | 3 + docs/CAPABILITY_MATRIX.md | 5 ++ src/Oidc/AuthorizationCodeClient.php | 16 +--- src/Oidc/OAuthEndpointException.php | 17 +++++ src/Oidc/OidcClientConfiguration.php | 12 +++ src/Oidc/OidcTokenSet.php | 3 +- src/Oidc/RefreshTokenClient.php | 66 ++++++++++++++++ src/Oidc/TokenEndpointResponse.php | 25 ++++++ src/Oidc/TokenIntrospectionClient.php | 50 ++++++++++++ src/Oidc/TokenIntrospectionResult.php | 9 +++ src/Oidc/TokenRevocationClient.php | 48 ++++++++++++ src/Oidc/UserInfoClient.php | 64 ++++++++++++++++ src/Oidc/UserInfoResponse.php | 9 +++ tests/EnterpriseOidcProfileTest.php | 106 ++++++++++++++++++++++++++ 14 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 src/Oidc/OAuthEndpointException.php create mode 100644 src/Oidc/RefreshTokenClient.php create mode 100644 src/Oidc/TokenEndpointResponse.php create mode 100644 src/Oidc/TokenIntrospectionClient.php create mode 100644 src/Oidc/TokenIntrospectionResult.php create mode 100644 src/Oidc/TokenRevocationClient.php create mode 100644 src/Oidc/UserInfoClient.php create mode 100644 src/Oidc/UserInfoResponse.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 71932eb..61656fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - Require RFC 9207 `iss` and exact `state` on authorization responses. - Add DPoP proof creation with `htu`, `htm`, `jti`, `iat`, `ath` and nonce. - Add interactive `private_key_jwt` client authentication. +- Add typed refresh rotation, UserInfo, token introspection and revocation clients. +- Bind refresh and UserInfo requests to DPoP keys and enforce UserInfo subject matching. +- Surface OAuth errors with redacted correlation and DPoP nonce metadata. - Add an opt-in Novvor high-assurance profile with downgrade prevention. - Preserve 1.x secret/no-secret behavior through the `auto` compatibility mode. - Expand Discovery parsing without advertising capabilities on behalf of the diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index 0096dde..d86c496 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -10,6 +10,11 @@ | private_key_jwt | Implemented and tested | auth method plus registered key | | DPoP | Implemented and tested | signing algorithms and DPoP token type | | ID token validation | Implemented and tested | issuer, JWKS and RS256 | +| Refresh token rotation | Implemented and tested | `refresh_token` grant and replacement token | +| UserInfo | Implemented and tested | UserInfo endpoint and subject binding | +| Token introspection | Implemented and tested | introspection endpoint and client authentication | +| Token revocation | Implemented and tested | revocation endpoint and client authentication | +| DPoP nonce automatic retry | Deferred | server nonce challenge metadata and runtime evidence | | Back-channel logout | Implemented and tested | server metadata and replay store | | Front-channel logout | Not implemented | must not be inferred | | Dynamic client registration | Not implemented | use governed admin plane | diff --git a/src/Oidc/AuthorizationCodeClient.php b/src/Oidc/AuthorizationCodeClient.php index e6d70b6..d5b2d2e 100644 --- a/src/Oidc/AuthorizationCodeClient.php +++ b/src/Oidc/AuthorizationCodeClient.php @@ -58,22 +58,12 @@ public function exchange( throw new OidcException('OIDC token endpoint rejected the authorization code.'); } - $accessToken = is_string($payload['access_token'] ?? null) ? $payload['access_token'] : ''; - $idToken = is_string($payload['id_token'] ?? null) ? $payload['id_token'] : ''; - if ($accessToken === '' || $idToken === '') { - throw new OidcException('OIDC token response is missing an access token or ID token.'); - } - $tokenType = is_string($payload['token_type'] ?? null) ? $payload['token_type'] : 'Bearer'; + $tokenSet = TokenEndpointResponse::tokenSet($payload); + $tokenType = $tokenSet->tokenType; if ($configuration->profile === 'novvor-high-assurance-v1' && strcasecmp($tokenType, 'DPoP') !== 0) { throw new OidcException('The high-assurance profile requires a DPoP-bound access token.'); } - return new OidcTokenSet( - accessToken: $accessToken, - idToken: $idToken, - refreshToken: is_string($payload['refresh_token'] ?? null) ? $payload['refresh_token'] : null, - expiresIn: max(0, (int) ($payload['expires_in'] ?? 0)), - tokenType: $tokenType, - ); + return $tokenSet; } } diff --git a/src/Oidc/OAuthEndpointException.php b/src/Oidc/OAuthEndpointException.php new file mode 100644 index 0000000..c75222d --- /dev/null +++ b/src/Oidc/OAuthEndpointException.php @@ -0,0 +1,17 @@ + 30) { throw new InvalidArgumentException('Identity HTTP timeout must be between one and thirty seconds.'); } + foreach ([$userinfoEndpoint, $introspectionEndpoint, $revocationEndpoint] as $optionalUrl) { + if ($optionalUrl !== null && (filter_var($optionalUrl, FILTER_VALIDATE_URL) === false || parse_url($optionalUrl, PHP_URL_SCHEME) !== 'https')) { + throw new InvalidArgumentException('Identity SDK requires HTTPS for optional OIDC endpoints.'); + } + } + if ($jwksCacheTtlSeconds < 30 || $jwksCacheTtlSeconds > 3600) { + throw new InvalidArgumentException('JWKS cache TTL must be between thirty and 3600 seconds.'); + } if (! in_array($clientAuthenticationMethod, ['auto', 'none', 'client_secret_post', 'private_key_jwt'], true)) { throw new InvalidArgumentException('Unsupported OIDC client authentication method.'); } diff --git a/src/Oidc/OidcTokenSet.php b/src/Oidc/OidcTokenSet.php index dcfabbf..38583f3 100644 --- a/src/Oidc/OidcTokenSet.php +++ b/src/Oidc/OidcTokenSet.php @@ -6,10 +6,11 @@ { public function __construct( public string $accessToken, - public string $idToken, + public ?string $idToken, public ?string $refreshToken, public int $expiresIn, public string $tokenType, + public ?string $scope = null, ) { } } diff --git a/src/Oidc/RefreshTokenClient.php b/src/Oidc/RefreshTokenClient.php new file mode 100644 index 0000000..ecf7443 --- /dev/null +++ b/src/Oidc/RefreshTokenClient.php @@ -0,0 +1,66 @@ +profile === 'novvor-high-assurance-v1' && $dpopKey === null) { + throw new OidcException('The high-assurance profile requires a DPoP key.'); + } + + $form = [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'client_id' => $configuration->clientId, + ]; + (new ClientAssertionFactory())->authenticate($configuration, $form); + $headers = ['Accept' => 'application/json']; + if ($dpopKey !== null) { + $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'POST', $configuration->tokenEndpoint, nonce: $dpopNonce); + } + + try { + $response = $this->http->request('POST', $configuration->tokenEndpoint, [ + ...OidcHttpRequestOptions::strict($configuration->httpTimeoutSeconds, $headers, $correlationId), + 'form_params' => $form, + ]); + } catch (\Throwable $exception) { + throw new OidcException('OIDC refresh token request failed.', 0, $exception); + } + + $payload = json_decode((string) $response->getBody(), true); + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300 || ! is_array($payload)) { + throw new OAuthEndpointException( + 'OIDC token endpoint rejected the refresh token.', + is_array($payload) && is_string($payload['error'] ?? null) ? $payload['error'] : null, + is_array($payload) && is_string($payload['error_description'] ?? null) ? $payload['error_description'] : null, + $response->getHeaderLine('X-Correlation-ID') ?: $correlationId, + $response->getHeaderLine('DPoP-Nonce') ?: null, + ); + } + + $tokens = TokenEndpointResponse::tokenSet($payload, false); + if ($tokens->refreshToken === null) { + throw new OidcException('Refresh rotation did not return a replacement refresh token.'); + } + if ($configuration->profile === 'novvor-high-assurance-v1' && strcasecmp($tokens->tokenType, 'DPoP') !== 0) { + throw new OidcException('The high-assurance profile requires a DPoP-bound access token.'); + } + + return $tokens; + } +} diff --git a/src/Oidc/TokenEndpointResponse.php b/src/Oidc/TokenEndpointResponse.php new file mode 100644 index 0000000..7fe8f75 --- /dev/null +++ b/src/Oidc/TokenEndpointResponse.php @@ -0,0 +1,25 @@ + $payload */ + public static function tokenSet(array $payload, bool $idTokenRequired = true): OidcTokenSet + { + $accessToken = is_string($payload['access_token'] ?? null) ? $payload['access_token'] : ''; + $idToken = is_string($payload['id_token'] ?? null) ? $payload['id_token'] : null; + if ($accessToken === '' || ($idTokenRequired && ($idToken === null || $idToken === ''))) { + throw new OidcException('OIDC token response is missing required tokens.'); + } + + return new OidcTokenSet( + accessToken: $accessToken, + idToken: $idToken, + refreshToken: is_string($payload['refresh_token'] ?? null) && $payload['refresh_token'] !== '' ? $payload['refresh_token'] : null, + expiresIn: max(0, (int) ($payload['expires_in'] ?? 0)), + tokenType: is_string($payload['token_type'] ?? null) ? $payload['token_type'] : 'Bearer', + scope: is_string($payload['scope'] ?? null) ? $payload['scope'] : null, + ); + } +} diff --git a/src/Oidc/TokenIntrospectionClient.php b/src/Oidc/TokenIntrospectionClient.php new file mode 100644 index 0000000..6eb4960 --- /dev/null +++ b/src/Oidc/TokenIntrospectionClient.php @@ -0,0 +1,50 @@ +introspectionEndpoint; + if ($endpoint === null) { + throw new OidcException('Token introspection endpoint is not configured.'); + } + if ($token === '') { + throw new OidcException('A token is required for introspection.'); + } + $form = ['token' => $token, 'client_id' => $configuration->clientId]; + if ($tokenTypeHint !== null) { + $form['token_type_hint'] = $tokenTypeHint; + } + (new ClientAssertionFactory())->authenticate($configuration, $form, $endpoint); + + try { + $response = $this->http->request('POST', $endpoint, [ + ...OidcHttpRequestOptions::strict($configuration->httpTimeoutSeconds, ['Accept' => 'application/json'], $correlationId), + 'form_params' => $form, + ]); + } catch (\Throwable $exception) { + throw new OidcException('OIDC token introspection request failed.', 0, $exception); + } + $payload = json_decode((string) $response->getBody(), true); + if ($response->getStatusCode() !== 200 || ! is_array($payload) || ! is_bool($payload['active'] ?? null)) { + throw new OAuthEndpointException( + 'OIDC token introspection endpoint rejected the request.', + is_array($payload) && is_string($payload['error'] ?? null) ? $payload['error'] : null, + is_array($payload) && is_string($payload['error_description'] ?? null) ? $payload['error_description'] : null, + $response->getHeaderLine('X-Correlation-ID') ?: $correlationId, + ); + } + + return new TokenIntrospectionResult($payload['active'], $payload); + } +} diff --git a/src/Oidc/TokenIntrospectionResult.php b/src/Oidc/TokenIntrospectionResult.php new file mode 100644 index 0000000..66c35fd --- /dev/null +++ b/src/Oidc/TokenIntrospectionResult.php @@ -0,0 +1,9 @@ + $claims */ + public function __construct(public bool $active, public array $claims) {} +} diff --git a/src/Oidc/TokenRevocationClient.php b/src/Oidc/TokenRevocationClient.php new file mode 100644 index 0000000..ffc5792 --- /dev/null +++ b/src/Oidc/TokenRevocationClient.php @@ -0,0 +1,48 @@ +revocationEndpoint; + if ($endpoint === null) { + throw new OidcException('Token revocation endpoint is not configured.'); + } + if ($token === '') { + throw new OidcException('A token is required for revocation.'); + } + $form = ['token' => $token, 'client_id' => $configuration->clientId]; + if ($tokenTypeHint !== null) { + $form['token_type_hint'] = $tokenTypeHint; + } + (new ClientAssertionFactory())->authenticate($configuration, $form, $endpoint); + + try { + $response = $this->http->request('POST', $endpoint, [ + ...OidcHttpRequestOptions::strict($configuration->httpTimeoutSeconds, ['Accept' => 'application/json'], $correlationId), + 'form_params' => $form, + ]); + } catch (\Throwable $exception) { + throw new OidcException('OIDC token revocation request failed.', 0, $exception); + } + if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + $payload = json_decode((string) $response->getBody(), true); + throw new OAuthEndpointException( + 'OIDC token revocation endpoint rejected the request.', + is_array($payload) && is_string($payload['error'] ?? null) ? $payload['error'] : null, + is_array($payload) && is_string($payload['error_description'] ?? null) ? $payload['error_description'] : null, + $response->getHeaderLine('X-Correlation-ID') ?: $correlationId, + ); + } + } +} diff --git a/src/Oidc/UserInfoClient.php b/src/Oidc/UserInfoClient.php new file mode 100644 index 0000000..6a6cc7c --- /dev/null +++ b/src/Oidc/UserInfoClient.php @@ -0,0 +1,64 @@ +userinfoEndpoint; + if ($endpoint === null) { + throw new OidcException('UserInfo endpoint is not configured.'); + } + if ($accessToken === '') { + throw new OidcException('UserInfo requires an access token.'); + } + $isDpop = strcasecmp($tokenType, 'DPoP') === 0; + if ($isDpop && $dpopKey === null) { + throw new OidcException('A DPoP-bound token requires its DPoP key.'); + } + $headers = [ + 'Accept' => 'application/json', + 'Authorization' => ($isDpop ? 'DPoP' : 'Bearer').' '.$accessToken, + ]; + if ($dpopKey !== null) { + $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'GET', $endpoint, $accessToken, $dpopNonce); + } + + try { + $response = $this->http->request('GET', $endpoint, OidcHttpRequestOptions::strict( + $configuration->httpTimeoutSeconds, + $headers, + $correlationId, + )); + } catch (\Throwable $exception) { + throw new OidcException('OIDC UserInfo request failed.', 0, $exception); + } + $claims = json_decode((string) $response->getBody(), true); + if ($response->getStatusCode() !== 200 || ! is_array($claims) || ! is_string($claims['sub'] ?? null) || $claims['sub'] === '') { + throw new OAuthEndpointException( + 'OIDC UserInfo endpoint rejected the access token.', + is_array($claims) && is_string($claims['error'] ?? null) ? $claims['error'] : null, + is_array($claims) && is_string($claims['error_description'] ?? null) ? $claims['error_description'] : null, + $response->getHeaderLine('X-Correlation-ID') ?: $correlationId, + $response->getHeaderLine('DPoP-Nonce') ?: null, + ); + } + if ($expectedSubject !== null && ! hash_equals($expectedSubject, $claims['sub'])) { + throw new OidcException('UserInfo subject does not match the ID token subject.'); + } + + return new UserInfoResponse($claims['sub'], $claims); + } +} diff --git a/src/Oidc/UserInfoResponse.php b/src/Oidc/UserInfoResponse.php new file mode 100644 index 0000000..31972f9 --- /dev/null +++ b/src/Oidc/UserInfoResponse.php @@ -0,0 +1,9 @@ + $claims */ + public function __construct(public string $subject, public array $claims) {} +} diff --git a/tests/EnterpriseOidcProfileTest.php b/tests/EnterpriseOidcProfileTest.php index 2028807..68e71fc 100644 --- a/tests/EnterpriseOidcProfileTest.php +++ b/tests/EnterpriseOidcProfileTest.php @@ -18,6 +18,10 @@ use Novvor\IdentitySdk\Oidc\OidcClientConfiguration; use Novvor\IdentitySdk\Oidc\OidcException; use Novvor\IdentitySdk\Oidc\PushedAuthorizationClient; +use Novvor\IdentitySdk\Oidc\RefreshTokenClient; +use Novvor\IdentitySdk\Oidc\TokenIntrospectionClient; +use Novvor\IdentitySdk\Oidc\TokenRevocationClient; +use Novvor\IdentitySdk\Oidc\UserInfoClient; use PHPUnit\Framework\TestCase; final class EnterpriseOidcProfileTest extends TestCase @@ -152,6 +156,105 @@ public function test_auto_authentication_keeps_v1_secret_compatibility(): void self::assertSame('legacy-secret', $form['client_secret']); } + public function test_refresh_rotation_requires_and_returns_a_new_refresh_token(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(200, [], json_encode([ + 'access_token' => 'access-2', + 'refresh_token' => 'refresh-2', + 'token_type' => 'DPoP', + 'expires_in' => 300, + 'scope' => 'openid profile', + ], JSON_THROW_ON_ERROR))])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + $tokens = (new RefreshTokenClient(new Client(['handler' => $stack])))->rotate( + $this->configuration($privateKey), + 'refresh-1', + 'correlation-refresh', + new DpopKey($privateKey, $jwk, 'RS256'), + ); + + self::assertSame('refresh-2', $tokens->refreshToken); + self::assertSame('DPoP', $tokens->tokenType); + self::assertSame('openid profile', $tokens->scope); + parse_str((string) $history[0]['request']->getBody(), $body); + self::assertSame('refresh_token', $body['grant_type']); + self::assertSame('refresh-1', $body['refresh_token']); + self::assertNotSame('', $history[0]['request']->getHeaderLine('DPoP')); + } + + public function test_userinfo_binds_dpop_proof_and_checks_subject(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(200, [], '{"sub":"user-1","email":"user@example.com"}')])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + + $result = (new UserInfoClient(new Client(['handler' => $stack])))->fetch( + $this->configuration($privateKey), + 'access-1', + 'DPoP', + 'user-1', + 'correlation-userinfo', + new DpopKey($privateKey, $jwk, 'RS256'), + ); + + self::assertSame('user-1', $result->subject); + self::assertSame('DPoP access-1', $history[0]['request']->getHeaderLine('Authorization')); + self::assertNotSame('', $history[0]['request']->getHeaderLine('DPoP')); + self::assertSame('correlation-userinfo', $history[0]['request']->getHeaderLine('X-Correlation-ID')); + } + + public function test_userinfo_rejects_subject_substitution(): void + { + $client = new UserInfoClient($this->http([new Response(200, [], '{"sub":"attacker"}')])); + $this->expectException(OidcException::class); + $client->fetch($this->configuration(), 'access-1', expectedSubject: 'user-1'); + } + + public function test_introspection_uses_endpoint_specific_private_key_assertion(): void + { + [$privateKey] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(200, [], '{"active":true,"sub":"user-1"}')])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + + $result = (new TokenIntrospectionClient(new Client(['handler' => $stack])))->introspect( + $this->configuration($privateKey), + 'access-1', + 'access_token', + ); + + self::assertTrue($result->active); + parse_str((string) $history[0]['request']->getBody(), $body); + self::assertArrayHasKey('client_assertion', $body); + $clientAssertion = $body['client_assertion']; + if (! is_string($clientAssertion)) { + self::fail('Client assertion must be a JWT string.'); + } + [, $claims] = $this->decode($clientAssertion); + self::assertSame('https://identity.example.com/oauth/introspect', $claims['aud']); + } + + public function test_revocation_is_idempotent_for_success_response(): void + { + [$privateKey] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(200)])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + + (new TokenRevocationClient(new Client(['handler' => $stack])))->revoke( + $this->configuration($privateKey), + 'refresh-1', + 'refresh_token', + ); + + parse_str((string) $history[0]['request']->getBody(), $body); + self::assertSame('refresh_token', $body['token_type_hint']); + self::assertSame('refresh-1', $body['token']); + } + private function configuration(?string $privateKey = null): OidcClientConfiguration { return new OidcClientConfiguration( @@ -162,6 +265,9 @@ private function configuration(?string $privateKey = null): OidcClientConfigurat $privateKey, $privateKey === null ? null : 'client-key', profile: $privateKey === null ? 'standard' : 'novvor-high-assurance-v1', + userinfoEndpoint: 'https://identity.example.com/oauth/userinfo', + introspectionEndpoint: 'https://identity.example.com/oauth/introspect', + revocationEndpoint: 'https://identity.example.com/oauth/revoke', ); } From 92e80a433fd2dff518d3411ae0e58c7a4d44ba8a Mon Sep 17 00:00:00 2001 From: danifronter Date: Tue, 28 Jul 2026 19:24:55 -0400 Subject: [PATCH 4/7] feat(dpop): generate safe per-session ES256 keys --- src/Oidc/DpopKey.php | 45 +++++++++++++++++++++++++++++++++++++++++++ tests/DpopKeyTest.php | 23 ++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/DpopKeyTest.php diff --git a/src/Oidc/DpopKey.php b/src/Oidc/DpopKey.php index 87a6fa1..3c68b88 100644 --- a/src/Oidc/DpopKey.php +++ b/src/Oidc/DpopKey.php @@ -22,4 +22,49 @@ public function __construct( throw new OidcException('DPoP public JWK is incomplete.'); } } + + public static function generateEs256(): self + { + if (! extension_loaded('openssl')) { + throw new OidcException('The OpenSSL extension is required to generate a DPoP key.'); + } + + $resource = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + if ($resource === false || ! openssl_pkey_export($resource, $privateKey)) { + throw new OidcException('Unable to generate an ES256 DPoP key.'); + } + + $details = openssl_pkey_get_details($resource); + $ec = is_array($details) && is_array($details['ec'] ?? null) ? $details['ec'] : null; + if (! is_array($ec) || ! is_string($ec['x'] ?? null) || ! is_string($ec['y'] ?? null)) { + throw new OidcException('Generated DPoP key is missing its public coordinates.'); + } + + return new self($privateKey, [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => self::base64Url($ec['x']), + 'y' => self::base64Url($ec['y']), + ]); + } + + public function publicThumbprint(): string + { + $canonical = json_encode([ + 'crv' => $this->publicJwk['crv'] ?? null, + 'kty' => $this->publicJwk['kty'] ?? null, + 'x' => $this->publicJwk['x'] ?? null, + 'y' => $this->publicJwk['y'] ?? null, + ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + + return self::base64Url(hash('sha256', $canonical, true)); + } + + private static function base64Url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } } diff --git a/tests/DpopKeyTest.php b/tests/DpopKeyTest.php new file mode 100644 index 0000000..5343b14 --- /dev/null +++ b/tests/DpopKeyTest.php @@ -0,0 +1,23 @@ +algorithm); + self::assertSame('EC', $first->publicJwk['kty']); + self::assertSame('P-256', $first->publicJwk['crv']); + self::assertArrayNotHasKey('d', $first->publicJwk); + self::assertNotSame($first->privateKey, $second->privateKey); + self::assertNotSame($first->publicThumbprint(), $second->publicThumbprint()); + self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]{43}$/', $first->publicThumbprint()); + } +} From aae8efbd5962aeac38d6ce53021335ec59e33517 Mon Sep 17 00:00:00 2001 From: danifronter Date: Tue, 28 Jul 2026 19:39:53 -0400 Subject: [PATCH 5/7] docs(identity): define SDK 2 adoption and release gates --- README.md | 3 ++ docs/RELEASE_2_0_PLAN.md | 43 +++++++++++++++++++++ docs/SDK_2_ADOPTION_AUDIT.md | 73 ++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 docs/RELEASE_2_0_PLAN.md create mode 100644 docs/SDK_2_ADOPTION_AUDIT.md diff --git a/README.md b/README.md index e9f3915..845b8fb 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ Never infer high-assurance support from a successful login. Discover metadata, run `EnterpriseProfileValidator`, store the authorization transaction server-side, and process callbacks through `AuthorizationResponseProcessor`. See [the Laravel integration guide](docs/INTEGRATION_LARAVEL.md). +The cross-package adoption audit and release gates are in +[`docs/SDK_2_ADOPTION_AUDIT.md`](docs/SDK_2_ADOPTION_AUDIT.md) and +[`docs/RELEASE_2_0_PLAN.md`](docs/RELEASE_2_0_PLAN.md). ## Workload authentication diff --git a/docs/RELEASE_2_0_PLAN.md b/docs/RELEASE_2_0_PLAN.md new file mode 100644 index 0000000..fd639b3 --- /dev/null +++ b/docs/RELEASE_2_0_PLAN.md @@ -0,0 +1,43 @@ +# Identity SDK 2.0 release plan + +## Version and publication order + +1. `novvor/identity-contracts` 2.0.0 +2. `novvor/identity-sdk-php` 2.0.0 +3. `novvor/identity-sdk-testing` 2.0.0 +4. `novvor/identity-admin-sdk-php` 2.0.0 +5. `novvor/identity-laravel` 2.0.0 + +Every tag must be immutable, signed when repository policy permits, and created +from a reviewed commit with Composer validation, audit, static analysis and +tests passing. Consumers must depend on semantic tags, never branch aliases. + +## Compatibility policy + +- 2.x may add optional endpoints and typed methods without breaking callers. +- Protocol downgrade, weaker validation or expanded trust is never a compatible + change. +- Claim removal, constructor changes and authentication-method changes require + a major release. +- Security fixes receive a patch release and a coordinated advisory when + disclosure is warranted. + +## Staging gate + +- exact issuer and callbacks; +- TLS verification; +- standard and high-assurance positive flow; +- callback replay rejection; +- wrong issuer/audience/nonce rejection; +- DPoP key mismatch rejection; +- refresh reuse family revocation; +- JWKS rollover; +- tenant mismatch rejection; +- sanitized logs and correlation IDs; +- clean install using only release tags. + +## Rollback + +Keep the previous minor tag installable and retain the previous signing public +key for the governed overlap window. Roll back consumers before retiring a key +or contract version. Never re-tag an existing version. diff --git a/docs/SDK_2_ADOPTION_AUDIT.md b/docs/SDK_2_ADOPTION_AUDIT.md new file mode 100644 index 0000000..0b08277 --- /dev/null +++ b/docs/SDK_2_ADOPTION_AUDIT.md @@ -0,0 +1,73 @@ +# Identity SDK 2.0 adoption audit + +Date: 2026-07-28 + +## Executive verdict + +The PHP core now covers the relying-party protocol surface needed by Laravel +and framework-neutral backend consumers. Adoption was previously fragile +because consumers had to assemble transaction state and advanced protocol +features themselves. The official Laravel adapter now owns that orchestration. + +This is `PASS_LOCAL_INTEGRATION`, not OpenID certification or production proof. + +## Package boundary + +| Package | Public contract | Must not contain | +|---|---|---| +| `identity-contracts` | claim names and security profiles | transport, secrets | +| `identity-sdk-php` | framework-neutral OAuth/OIDC protocol | Laravel session, admin APIs | +| `identity-laravel` | Laravel config, DI and transaction lifecycle | tenant authorization policy | +| `identity-admin-sdk-php` | privileged control-plane transport | user login/session logic | +| `identity-sdk-testing` | truthful fakes and negative fixtures | real keys, tokens, customer data | + +## Required consumer flow + +1. Resolve an explicit canonical issuer and endpoints. +2. Discover metadata and compare the issuer exactly. +3. Validate the selected profile before browser redirect. +4. Generate state, nonce and PKCE S256. +5. For high assurance, create a per-session DPoP key and push the request with + PAR authenticated by `private_key_jwt`. +6. Redirect using only `client_id` and the PAR `request_uri`. +7. Validate JARM RS256, RFC 9207 issuer and state. +8. Consume the one-time transaction before exchanging the code. +9. Exchange with the exact redirect URI, PKCE verifier and DPoP proof. +10. Validate ID Token issuer, audience, `azp`, signature, time and nonce. +11. Bind UserInfo `sub` to the ID Token `sub`. +12. Map tenant and permissions in the application, then regenerate its session. + +Laravel consumers should use `novvor/identity-laravel` rather than reproduce +these steps in controllers. + +## Capability truth + +| Capability | Core | Laravel adapter | Server evidence required | +|---|---:|---:|---| +| Authorization Code | yes | orchestrated | response type | +| PKCE S256 | yes | orchestrated | S256 metadata | +| state / nonce | yes | encrypted session | runtime callback | +| RFC 9207 | yes | enforced | issuer parameter | +| PAR | yes | orchestrated | PAR endpoint | +| JARM | yes | orchestrated | query.jwt + RS256 | +| DPoP | yes | per-session ES256 key | ES256 + DPoP token | +| private_key_jwt | yes | configured | registered public key | +| refresh rotation | yes | bound client | replacement refresh token | +| UserInfo | yes | subject-bound | endpoint | +| introspection/revocation | yes | bound clients | endpoints | +| DPoP nonce auto retry | deferred | deferred | server challenge semantics | +| front-channel logout | no | no | do not advertise | +| dynamic registration | no | no | use admin control plane | + +## Remaining release blockers + +1. Tag `identity-contracts` 2.0. +2. Tag `identity-sdk-php` 2.0. +3. Replace the Laravel adapter's temporary Draft-branch constraint with `^2.0`. +4. Tag `identity-sdk-testing` and `identity-admin-sdk-php` 2.0. +5. Run a clean Composer install using tags only. +6. Validate one Laravel reference consumer end to end. +7. Run negative issuer, callback replay, tenant mismatch and key-rotation tests. +8. Validate staging runtime and an external OpenID conformance profile. + +No package should claim `PASS_RUNTIME` until those external gates have evidence. From ece783181f9a01381eaa1afd89469812f1bc08df Mon Sep 17 00:00:00 2001 From: danifronter Date: Tue, 28 Jul 2026 19:53:46 -0400 Subject: [PATCH 6/7] feat(dpop): govern nonce challenge retries --- CHANGELOG.md | 2 + README.md | 5 ++ docs/CAPABILITY_MATRIX.md | 2 +- docs/SDK_2_ADOPTION_AUDIT.md | 2 +- src/Oidc/AuthorizationCodeClient.php | 59 ++++++++++----- src/Oidc/DpopNonceChallenge.php | 27 +++++++ src/Oidc/OAuthJsonResponse.php | 24 ++++++ src/Oidc/RefreshTokenClient.php | 48 ++++++++---- src/Oidc/UserInfoClient.php | 63 ++++++++++------ tests/EnterpriseOidcProfileTest.php | 109 +++++++++++++++++++++++++++ 10 files changed, 283 insertions(+), 58 deletions(-) create mode 100644 src/Oidc/DpopNonceChallenge.php create mode 100644 src/Oidc/OAuthJsonResponse.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 61656fc..bb8cfd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Add JARM validation pinned to RS256, exact issuer and client audience. - Require RFC 9207 `iss` and exact `state` on authorization responses. - Add DPoP proof creation with `htu`, `htm`, `jti`, `iat`, `ath` and nonce. +- Retry one valid RFC 9449 `use_dpop_nonce` challenge at token, refresh and + UserInfo endpoints; repeated or malformed challenges fail closed. - Add interactive `private_key_jwt` client authentication. - Add typed refresh rotation, UserInfo, token introspection and revocation clients. - Bind refresh and UserInfo requests to DPoP keys and enforce UserInfo subject matching. diff --git a/README.md b/README.md index 845b8fb..16c5b29 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ The administrative API is intentionally excluded from this package. - `novvor-high-assurance-v1`: fail-closed profile requiring PAR, JARM, `private_key_jwt`, RFC 9207 issuer binding and a DPoP-bound access token. +Token, refresh and UserInfo clients automatically answer one valid RFC 9449 +`use_dpop_nonce` challenge. Repeated, unchanged, malformed or oversized nonce +challenges fail closed, so a broken server cannot create an unbounded retry +loop. + Never infer high-assurance support from a successful login. Discover metadata, run `EnterpriseProfileValidator`, store the authorization transaction server-side, and process callbacks through `AuthorizationResponseProcessor`. diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md index d86c496..791bef7 100644 --- a/docs/CAPABILITY_MATRIX.md +++ b/docs/CAPABILITY_MATRIX.md @@ -14,7 +14,7 @@ | UserInfo | Implemented and tested | UserInfo endpoint and subject binding | | Token introspection | Implemented and tested | introspection endpoint and client authentication | | Token revocation | Implemented and tested | revocation endpoint and client authentication | -| DPoP nonce automatic retry | Deferred | server nonce challenge metadata and runtime evidence | +| DPoP nonce challenge | Implemented and tested | RFC 9449 `use_dpop_nonce` plus `DPoP-Nonce` | | Back-channel logout | Implemented and tested | server metadata and replay store | | Front-channel logout | Not implemented | must not be inferred | | Dynamic client registration | Not implemented | use governed admin plane | diff --git a/docs/SDK_2_ADOPTION_AUDIT.md b/docs/SDK_2_ADOPTION_AUDIT.md index 0b08277..e61ecf4 100644 --- a/docs/SDK_2_ADOPTION_AUDIT.md +++ b/docs/SDK_2_ADOPTION_AUDIT.md @@ -55,7 +55,7 @@ these steps in controllers. | refresh rotation | yes | bound client | replacement refresh token | | UserInfo | yes | subject-bound | endpoint | | introspection/revocation | yes | bound clients | endpoints | -| DPoP nonce auto retry | deferred | deferred | server challenge semantics | +| DPoP nonce challenge | yes | delegated to core | `use_dpop_nonce` + `DPoP-Nonce` | | front-channel logout | no | no | do not advertise | | dynamic registration | no | no | use admin control plane | diff --git a/src/Oidc/AuthorizationCodeClient.php b/src/Oidc/AuthorizationCodeClient.php index d5b2d2e..03825c4 100644 --- a/src/Oidc/AuthorizationCodeClient.php +++ b/src/Oidc/AuthorizationCodeClient.php @@ -32,30 +32,53 @@ public function exchange( ]; (new ClientAssertionFactory())->authenticate($configuration, $form); - $headers = ['Accept' => 'application/json']; - if ($dpopKey !== null) { - $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'POST', $configuration->tokenEndpoint, nonce: $dpopNonce); - } if ($configuration->profile === 'novvor-high-assurance-v1' && $dpopKey === null) { throw new OidcException('The high-assurance profile requires a DPoP key.'); } - try { - $response = $this->http->request('POST', $configuration->tokenEndpoint, [ - ...OidcHttpRequestOptions::strict( - $configuration->httpTimeoutSeconds, - $headers, - $correlationId, - ), - 'form_params' => $form, - ]); - } catch (\Throwable $exception) { - throw new OidcException('OIDC token exchange failed.', 0, $exception); + $nonce = $dpopNonce; + for ($attempt = 0; $attempt < 2; $attempt++) { + $headers = ['Accept' => 'application/json']; + if ($dpopKey !== null) { + $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'POST', $configuration->tokenEndpoint, nonce: $nonce); + } + + try { + $response = $this->http->request('POST', $configuration->tokenEndpoint, [ + ...OidcHttpRequestOptions::strict( + $configuration->httpTimeoutSeconds, + $headers, + $correlationId, + ), + 'form_params' => $form, + ]); + } catch (\Throwable $exception) { + throw new OidcException('OIDC token exchange failed.', 0, $exception); + } + + $payload = OAuthJsonResponse::decode((string) $response->getBody()); + if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300 && $payload !== null) { + break; + } + + $challengedNonce = $dpopKey === null ? null : DpopNonceChallenge::from($response, $payload, $nonce); + if ($attempt === 0 && $challengedNonce !== null) { + $nonce = $challengedNonce; + + continue; + } + + throw new OAuthEndpointException( + 'OIDC token endpoint rejected the authorization code.', + is_string($payload['error'] ?? null) ? $payload['error'] : null, + is_string($payload['error_description'] ?? null) ? $payload['error_description'] : null, + $response->getHeaderLine('X-Correlation-ID') ?: $correlationId, + $response->getHeaderLine('DPoP-Nonce') ?: null, + ); } - $payload = json_decode((string) $response->getBody(), true); - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300 || ! is_array($payload)) { - throw new OidcException('OIDC token endpoint rejected the authorization code.'); + if ($payload === null) { + throw new OidcException('OIDC token endpoint returned an invalid response.'); } $tokenSet = TokenEndpointResponse::tokenSet($payload); diff --git a/src/Oidc/DpopNonceChallenge.php b/src/Oidc/DpopNonceChallenge.php new file mode 100644 index 0000000..59fe6fc --- /dev/null +++ b/src/Oidc/DpopNonceChallenge.php @@ -0,0 +1,27 @@ +|null $payload */ + public static function from(ResponseInterface $response, ?array $payload, ?string $previousNonce): ?string + { + if (($payload['error'] ?? null) !== 'use_dpop_nonce') { + return null; + } + + $nonce = $response->getHeaderLine('DPoP-Nonce'); + if ($nonce === '' || strlen($nonce) > 512 || preg_match('/[^\x21-\x7E]/', $nonce) === 1) { + return null; + } + + if ($previousNonce !== null && hash_equals($previousNonce, $nonce)) { + return null; + } + + return $nonce; + } +} diff --git a/src/Oidc/OAuthJsonResponse.php b/src/Oidc/OAuthJsonResponse.php new file mode 100644 index 0000000..f66c20b --- /dev/null +++ b/src/Oidc/OAuthJsonResponse.php @@ -0,0 +1,24 @@ +|null */ + public static function decode(string $body): ?array + { + $payload = json_decode($body, true); + if (! is_array($payload)) { + return null; + } + + $normalized = []; + foreach ($payload as $key => $value) { + if (is_string($key)) { + $normalized[$key] = $value; + } + } + + return $normalized; + } +} diff --git a/src/Oidc/RefreshTokenClient.php b/src/Oidc/RefreshTokenClient.php index ecf7443..ee0cc5b 100644 --- a/src/Oidc/RefreshTokenClient.php +++ b/src/Oidc/RefreshTokenClient.php @@ -28,31 +28,47 @@ public function rotate( 'client_id' => $configuration->clientId, ]; (new ClientAssertionFactory())->authenticate($configuration, $form); - $headers = ['Accept' => 'application/json']; - if ($dpopKey !== null) { - $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'POST', $configuration->tokenEndpoint, nonce: $dpopNonce); - } + $nonce = $dpopNonce; + for ($attempt = 0; $attempt < 2; $attempt++) { + $headers = ['Accept' => 'application/json']; + if ($dpopKey !== null) { + $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'POST', $configuration->tokenEndpoint, nonce: $nonce); + } - try { - $response = $this->http->request('POST', $configuration->tokenEndpoint, [ - ...OidcHttpRequestOptions::strict($configuration->httpTimeoutSeconds, $headers, $correlationId), - 'form_params' => $form, - ]); - } catch (\Throwable $exception) { - throw new OidcException('OIDC refresh token request failed.', 0, $exception); - } + try { + $response = $this->http->request('POST', $configuration->tokenEndpoint, [ + ...OidcHttpRequestOptions::strict($configuration->httpTimeoutSeconds, $headers, $correlationId), + 'form_params' => $form, + ]); + } catch (\Throwable $exception) { + throw new OidcException('OIDC refresh token request failed.', 0, $exception); + } + + $payload = OAuthJsonResponse::decode((string) $response->getBody()); + if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300 && $payload !== null) { + break; + } + + $challengedNonce = $dpopKey === null ? null : DpopNonceChallenge::from($response, $payload, $nonce); + if ($attempt === 0 && $challengedNonce !== null) { + $nonce = $challengedNonce; + + continue; + } - $payload = json_decode((string) $response->getBody(), true); - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300 || ! is_array($payload)) { throw new OAuthEndpointException( 'OIDC token endpoint rejected the refresh token.', - is_array($payload) && is_string($payload['error'] ?? null) ? $payload['error'] : null, - is_array($payload) && is_string($payload['error_description'] ?? null) ? $payload['error_description'] : null, + is_string($payload['error'] ?? null) ? $payload['error'] : null, + is_string($payload['error_description'] ?? null) ? $payload['error_description'] : null, $response->getHeaderLine('X-Correlation-ID') ?: $correlationId, $response->getHeaderLine('DPoP-Nonce') ?: null, ); } + if ($payload === null) { + throw new OidcException('OIDC token endpoint returned an invalid refresh response.'); + } + $tokens = TokenEndpointResponse::tokenSet($payload, false); if ($tokens->refreshToken === null) { throw new OidcException('Refresh rotation did not return a replacement refresh token.'); diff --git a/src/Oidc/UserInfoClient.php b/src/Oidc/UserInfoClient.php index 6a6cc7c..b12e287 100644 --- a/src/Oidc/UserInfoClient.php +++ b/src/Oidc/UserInfoClient.php @@ -28,37 +28,56 @@ public function fetch( if ($isDpop && $dpopKey === null) { throw new OidcException('A DPoP-bound token requires its DPoP key.'); } - $headers = [ - 'Accept' => 'application/json', - 'Authorization' => ($isDpop ? 'DPoP' : 'Bearer').' '.$accessToken, - ]; - if ($dpopKey !== null) { - $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'GET', $endpoint, $accessToken, $dpopNonce); - } + $nonce = $dpopNonce; + for ($attempt = 0; $attempt < 2; $attempt++) { + $headers = [ + 'Accept' => 'application/json', + 'Authorization' => ($isDpop ? 'DPoP' : 'Bearer').' '.$accessToken, + ]; + if ($dpopKey !== null) { + $headers['DPoP'] = (new DpopProofFactory())->create($dpopKey, 'GET', $endpoint, $accessToken, $nonce); + } + + try { + $response = $this->http->request('GET', $endpoint, OidcHttpRequestOptions::strict( + $configuration->httpTimeoutSeconds, + $headers, + $correlationId, + )); + } catch (\Throwable $exception) { + throw new OidcException('OIDC UserInfo request failed.', 0, $exception); + } + $claims = OAuthJsonResponse::decode((string) $response->getBody()); + $subject = $claims['sub'] ?? null; + if ($response->getStatusCode() === 200 && is_string($subject) && $subject !== '') { + break; + } + + $challengedNonce = $dpopKey === null ? null : DpopNonceChallenge::from($response, $claims, $nonce); + if ($attempt === 0 && $challengedNonce !== null) { + $nonce = $challengedNonce; + + continue; + } - try { - $response = $this->http->request('GET', $endpoint, OidcHttpRequestOptions::strict( - $configuration->httpTimeoutSeconds, - $headers, - $correlationId, - )); - } catch (\Throwable $exception) { - throw new OidcException('OIDC UserInfo request failed.', 0, $exception); - } - $claims = json_decode((string) $response->getBody(), true); - if ($response->getStatusCode() !== 200 || ! is_array($claims) || ! is_string($claims['sub'] ?? null) || $claims['sub'] === '') { throw new OAuthEndpointException( 'OIDC UserInfo endpoint rejected the access token.', - is_array($claims) && is_string($claims['error'] ?? null) ? $claims['error'] : null, - is_array($claims) && is_string($claims['error_description'] ?? null) ? $claims['error_description'] : null, + is_string($claims['error'] ?? null) ? $claims['error'] : null, + is_string($claims['error_description'] ?? null) ? $claims['error_description'] : null, $response->getHeaderLine('X-Correlation-ID') ?: $correlationId, $response->getHeaderLine('DPoP-Nonce') ?: null, ); } - if ($expectedSubject !== null && ! hash_equals($expectedSubject, $claims['sub'])) { + + if ($claims === null || ! is_string($claims['sub'] ?? null) || $claims['sub'] === '') { + throw new OidcException('OIDC UserInfo endpoint returned an invalid response.'); + } + + $subject = $claims['sub']; + if ($expectedSubject !== null && ! hash_equals($expectedSubject, $subject)) { throw new OidcException('UserInfo subject does not match the ID token subject.'); } - return new UserInfoResponse($claims['sub'], $claims); + return new UserInfoResponse($subject, $claims); } } diff --git a/tests/EnterpriseOidcProfileTest.php b/tests/EnterpriseOidcProfileTest.php index 68e71fc..61eadcc 100644 --- a/tests/EnterpriseOidcProfileTest.php +++ b/tests/EnterpriseOidcProfileTest.php @@ -184,6 +184,52 @@ public function test_refresh_rotation_requires_and_returns_a_new_refresh_token() self::assertNotSame('', $history[0]['request']->getHeaderLine('DPoP')); } + public function test_token_exchange_retries_one_valid_dpop_nonce_challenge(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([ + new Response(400, ['DPoP-Nonce' => 'server-nonce'], '{"error":"use_dpop_nonce"}'), + new Response(200, [], '{"access_token":"access-1","id_token":"id-1","token_type":"DPoP","expires_in":300}'), + ])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + + $tokens = (new \Novvor\IdentitySdk\Oidc\AuthorizationCodeClient(new Client(['handler' => $stack])))->exchange( + $this->configuration($privateKey), + 'authorization-code', + str_repeat('v', 64), + dpopKey: new DpopKey($privateKey, $jwk, 'RS256'), + ); + + self::assertSame('access-1', $tokens->accessToken); + self::assertArrayHasKey(1, $history); + self::assertArrayNotHasKey(2, $history); + self::assertArrayNotHasKey('nonce', $this->dpopPayload($history[0]['request']->getHeaderLine('DPoP'))); + self::assertSame('server-nonce', $this->dpopPayload($history[1]['request']->getHeaderLine('DPoP'))['nonce']); + } + + public function test_refresh_rotation_retries_one_valid_dpop_nonce_challenge(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([ + new Response(400, ['DPoP-Nonce' => 'refresh-nonce'], '{"error":"use_dpop_nonce"}'), + new Response(200, [], '{"access_token":"access-2","refresh_token":"refresh-2","token_type":"DPoP","expires_in":300}'), + ])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + + $tokens = (new RefreshTokenClient(new Client(['handler' => $stack])))->rotate( + $this->configuration($privateKey), + 'refresh-1', + dpopKey: new DpopKey($privateKey, $jwk, 'RS256'), + ); + + self::assertSame('refresh-2', $tokens->refreshToken); + self::assertArrayHasKey(1, $history); + self::assertArrayNotHasKey(2, $history); + self::assertSame('refresh-nonce', $this->dpopPayload($history[1]['request']->getHeaderLine('DPoP'))['nonce']); + } + public function test_userinfo_binds_dpop_proof_and_checks_subject(): void { [$privateKey, $jwk] = $this->rsaKey(); @@ -206,6 +252,56 @@ public function test_userinfo_binds_dpop_proof_and_checks_subject(): void self::assertSame('correlation-userinfo', $history[0]['request']->getHeaderLine('X-Correlation-ID')); } + public function test_userinfo_retries_one_valid_dpop_nonce_challenge(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([ + new Response(401, ['DPoP-Nonce' => 'userinfo-nonce'], '{"error":"use_dpop_nonce"}'), + new Response(200, [], '{"sub":"user-1"}'), + ])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + + $result = (new UserInfoClient(new Client(['handler' => $stack])))->fetch( + $this->configuration($privateKey), + 'access-1', + 'DPoP', + 'user-1', + dpopKey: new DpopKey($privateKey, $jwk, 'RS256'), + ); + + self::assertSame('user-1', $result->subject); + self::assertArrayHasKey(1, $history); + self::assertArrayNotHasKey(2, $history); + self::assertSame('userinfo-nonce', $this->dpopPayload($history[1]['request']->getHeaderLine('DPoP'))['nonce']); + } + + public function test_dpop_nonce_challenge_is_never_retried_more_than_once(): void + { + [$privateKey, $jwk] = $this->rsaKey(); + $history = []; + $stack = HandlerStack::create(new MockHandler([ + new Response(400, ['DPoP-Nonce' => 'nonce-1'], '{"error":"use_dpop_nonce"}'), + new Response(400, ['DPoP-Nonce' => 'nonce-2'], '{"error":"use_dpop_nonce"}'), + ])); + $stack->push(\GuzzleHttp\Middleware::history($history)); + + try { + (new RefreshTokenClient(new Client(['handler' => $stack])))->rotate( + $this->configuration($privateKey), + 'refresh-1', + dpopKey: new DpopKey($privateKey, $jwk, 'RS256'), + ); + self::fail('A repeated DPoP nonce challenge must fail closed.'); + } catch (\Novvor\IdentitySdk\Oidc\OAuthEndpointException $exception) { + self::assertSame('use_dpop_nonce', $exception->oauthError); + self::assertSame('nonce-2', $exception->dpopNonce); + } + + self::assertArrayHasKey(1, $history); + self::assertArrayNotHasKey(2, $history); + } + public function test_userinfo_rejects_subject_substitution(): void { $client = new UserInfoClient($this->http([new Response(200, [], '{"sub":"attacker"}')])); @@ -299,6 +395,19 @@ private function http(array $responses): Client return new Client(['handler' => $stack]); } + /** @return array */ + private function dpopPayload(string $proof): array + { + $parts = explode('.', $proof); + self::assertCount(3, $parts); + $decoded = base64_decode(strtr($parts[1], '-_', '+/'), true); + self::assertIsString($decoded); + $payload = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($payload); + + return $payload; + } + /** @return array{0: array, 1: array} */ private function decode(string $jwt): array { From 8c37643126a81f3d72690cc34a4735aefa6ec69e Mon Sep 17 00:00:00 2001 From: danifronter Date: Tue, 28 Jul 2026 20:10:11 -0400 Subject: [PATCH 7/7] build(identity): require versioned Identity 2 contracts --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 00bd01f..c04f1b8 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "php": "^8.2", "firebase/php-jwt": "^7.0", "guzzlehttp/guzzle": "^7.0", - "novvor/identity-contracts": "^1.0" + "novvor/identity-contracts": "^2.0" }, "require-dev": { "phpstan/phpstan": "^2.1",