From b2163ad99609b10ba7b1ec1c2da14633c7010431 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 27 Apr 2026 10:50:50 +0100 Subject: [PATCH] Default allow_missing_nonce behavior --- README.md | 10 +++++++ docs/INTEGRATION_GUIDE.md | 10 +++++++ src/Auth/AuthClient.php | 19 +++++++++++-- src/BlackWallAuth.php | 2 +- tests/AuthClientTest.php | 60 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 639c326..25777c2 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,16 @@ If the provider does not return an `id_token`, callback handling remains gracefu Compatibility mode: if you have a temporary deployment constraint and cannot enforce nonce immediately, call `handleCallback($_GET, true, ['validate_nonce' => false])`. Treat this as a short-lived compatibility setting only. +If your provider issues an `id_token` but omits the `nonce` claim, the SDK will accept missing nonce values by default while still enforcing a match when one is present. You can also set it explicitly: + +```php +$result = $client->handleCallback($_GET, true, [ + 'allow_missing_nonce' => true, +]); +``` + +Use this only when you have verified the provider cannot include `nonce`. It weakens replay protection for those tokens. + ### JWT payload decoding helper Use `decodeJwtPayloadClaims()` when you need to inspect an `id_token` payload: diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 2141e9d..88ab07f 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -135,6 +135,16 @@ $result = $client->handleCallback($_GET, true, [ Use this only as a short-term bridge while the application is updated to persist and enforce nonce consistently. +If your provider returns an `id_token` without the `nonce` claim, the SDK will allow missing nonce values by default while still enforcing a match when one is present. You can also set it explicitly: + +```php +$result = $client->handleCallback($_GET, true, [ + 'allow_missing_nonce' => true, +]); +``` + +Use this only after confirming the provider cannot include `nonce`, because it weakens replay protection for those tokens. + ## 3. Single callback for multiple app roles Using one OAuth client and one callback URL is recommended. diff --git a/src/Auth/AuthClient.php b/src/Auth/AuthClient.php index dea3ab6..76e75db 100644 --- a/src/Auth/AuthClient.php +++ b/src/Auth/AuthClient.php @@ -230,7 +230,7 @@ final class AuthClient /** * @param array $query - * @param array{expected_nonce?:?string,validate_nonce?:bool} $options + * @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options */ public function handleCallback(array $query, bool $clearPkce = true, array $options = []): CallbackResult { @@ -320,7 +320,7 @@ final class AuthClient } /** - * @param array{expected_nonce?:?string,validate_nonce?:bool} $options + * @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options */ private function maybeValidateNonce(TokenSet $tokens, array $options): void { @@ -334,6 +334,21 @@ final class AuthClient return; } + $allowMissingNonce = !isset($options['allow_missing_nonce']) || (bool) $options['allow_missing_nonce']; + if ($allowMissingNonce) { + $claims = $this->decodeJwtPayloadClaims($tokens->idToken); + $nonce = $claims['nonce'] ?? null; + if (!is_string($nonce) || $nonce === '') { + return; + } + + if (!hash_equals($expectedNonce, $nonce)) { + throw new NonceMismatchException('The ID token nonce did not match the expected value', 'nonce_mismatch'); + } + + return; + } + $this->assertIdTokenNonceMatches($tokens->idToken, $expectedNonce); } diff --git a/src/BlackWallAuth.php b/src/BlackWallAuth.php index cd733fc..5d4c215 100644 --- a/src/BlackWallAuth.php +++ b/src/BlackWallAuth.php @@ -96,7 +96,7 @@ class BlackWallAuth /** * @param array $query - * @param array{expected_nonce?:?string,validate_nonce?:bool} $options + * @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options * @return array{ * tokens: array, * user: array{email:string,privilege_level:?int,role:?string,raw:array}, diff --git a/tests/AuthClientTest.php b/tests/AuthClientTest.php index cfc5c62..a58a5aa 100644 --- a/tests/AuthClientTest.php +++ b/tests/AuthClientTest.php @@ -303,6 +303,66 @@ final class AuthClientTest extends TestCase self::assertNull($result->tokens->idToken); } + public function testHandleCallbackAllowsMissingNonceClaimWhenEnabled(): void + { + $_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1'; + $_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123'; + $_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce'; + + $this->http->queuePost([ + 'status' => 200, + 'body' => json_encode([ + 'access_token' => 'access-1', + 'refresh_token' => 'refresh-1', + 'id_token' => $this->makeJwt(['sub' => 'user-1']), + 'token_type' => 'Bearer', + ], JSON_THROW_ON_ERROR), + ]); + + $this->http->queueGet([ + 'status' => 200, + 'body' => '{"email":"tutor@example.com","privilege_level":2}', + ]); + + $result = $this->client->handleCallback([ + 'code' => 'code-1', + 'state' => 'state-1', + ], true, [ + 'allow_missing_nonce' => true, + ]); + + self::assertSame('access-1', $result->tokens->accessToken); + } + + public function testHandleCallbackAllowsMissingNonceClaimByDefault(): void + { + $_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1'; + $_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123'; + $_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce'; + + $this->http->queuePost([ + 'status' => 200, + 'body' => json_encode([ + 'access_token' => 'access-1', + 'refresh_token' => 'refresh-1', + 'id_token' => $this->makeJwt(['sub' => 'user-1']), + 'token_type' => 'Bearer', + ], JSON_THROW_ON_ERROR), + ]); + + $this->http->queueGet([ + 'status' => 200, + 'body' => '{"email":"tutor@example.com","privilege_level":2}', + ]); + + $result = $this->client->handleCallback([ + 'code' => 'code-1', + 'state' => 'state-1', + ]); + + self::assertSame('access-1', $result->tokens->accessToken); + } + public function testHandleCallbackCanDisableNonceValidationTemporarily(): void { $_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';