Default allow_missing_nonce behavior

This commit is contained in:
Andy Dixon
2026-04-27 10:50:50 +01:00
parent 1d8578c1da
commit b2163ad996
5 changed files with 98 additions and 3 deletions
+10
View File
@@ -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:
+10
View File
@@ -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.
+17 -2
View File
@@ -230,7 +230,7 @@ final class AuthClient
/**
* @param array<string, mixed> $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);
}
+1 -1
View File
@@ -96,7 +96,7 @@ class BlackWallAuth
/**
* @param array<string,mixed> $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<string,mixed>,
* user: array{email:string,privilege_level:?int,role:?string,raw:array<string,mixed>},
+60
View File
@@ -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';