2 Commits
Author SHA1 Message Date
Andy DixonandCopilot d287e01b2a Git Ignoreness
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:54:30 +01:00
Andy Dixon b2163ad996 Default allow_missing_nonce behavior 2026-04-27 10:50:50 +01:00
6 changed files with 100 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
vendor
.phpunit.cache
+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. 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 ### JWT payload decoding helper
Use `decodeJwtPayloadClaims()` when you need to inspect an `id_token` payload: 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. 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 ## 3. Single callback for multiple app roles
Using one OAuth client and one callback URL is recommended. 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<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 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 private function maybeValidateNonce(TokenSet $tokens, array $options): void
{ {
@@ -334,6 +334,21 @@ final class AuthClient
return; 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); $this->assertIdTokenNonceMatches($tokens->idToken, $expectedNonce);
} }
+1 -1
View File
@@ -96,7 +96,7 @@ class BlackWallAuth
/** /**
* @param array<string,mixed> $query * @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{ * @return array{
* tokens: array<string,mixed>, * tokens: array<string,mixed>,
* user: array{email:string,privilege_level:?int,role:?string,raw: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); 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 public function testHandleCallbackCanDisableNonceValidationTemporarily(): void
{ {
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1'; $_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';