4 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
Andy Dixon 1d8578c1da Additional Improvements 2026-03-11 07:41:21 +00:00
Andy Dixon 986dc95646 Updated to enforce nonce in example and documentaion 2026-03-10 09:50:34 +00:00
10 changed files with 539 additions and 42 deletions
+2
View File
@@ -0,0 +1,2 @@
vendor
.phpunit.cache
+52 -9
View File
@@ -9,7 +9,8 @@ This package provides:
- UserInfo retrieval
- UserInfo normalisation (`email`, `privilege_level`, `role`)
- Unified callback handling helper (`handleCallback`)
- Session helpers for `state` and `code_verifier`
- First-class OIDC nonce generation, session persistence, and validation helpers
- Session helpers for `state`, `code_verifier`, and `nonce`
- Typed API (`AuthClient`, `TokenSet`) and specific exception classes
## Requirements
@@ -77,23 +78,22 @@ $config = Config::fromArray([
$client = new AuthClient($config);
// Step 1: redirect user to provider
$auth = $client->buildAuthorisationUrl([
'extra' => [
// Some providers require nonce for OIDC requests.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
$auth = $client->buildAuthorisationUrl();
header('Location: ' . $auth['url']);
exit;
```
When the requested scope includes `openid`, `buildAuthorisationUrl()` generates a URL-safe nonce automatically, stores it in `$_SESSION['blackwall_oidc_nonce']` when session persistence is enabled, and includes it in the authorisation request.
For callback handling, see `docs/INTEGRATION_GUIDE.md`.
For provider-side threat/invariant assumptions, see `docs/SECURITY_MODEL.md`.
Minimal callback example:
```php
$result = $client->handleCallback($_GET);
$result = $client->handleCallback($_GET, true, [
'expected_nonce' => $_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null,
]);
$_SESSION['user'] = [
'email' => $result->user->email,
@@ -103,6 +103,49 @@ $_SESSION['user'] = [
$_SESSION['access_token'] = $result->tokens->accessToken;
```
## OIDC nonce handling
Use `nonce` for OpenID Connect authorisation requests. `state` protects the browser redirect flow against CSRF and mix-up issues. `nonce` binds the returned `id_token` to the login request that started in the user's session.
The SDK now treats nonce as a first-class value:
- `buildAuthorisationUrl()` generates a secure nonce automatically when the scope contains `openid`.
- Pass `'nonce' => 'your-value'` if you need to supply your own nonce.
- With default persistence enabled, the SDK stores:
- `$_SESSION['blackwall_oauth_state']`
- `$_SESSION['blackwall_oauth_code_verifier']`
- `$_SESSION['blackwall_oidc_nonce']`
- The returned array from `buildAuthorisationUrl()` now includes `'nonce' => ?string`.
- `assertNonceMatches()` checks a nonce against the stored session value.
- `assertIdTokenNonceMatches()` decodes the JWT payload claims and validates the `nonce` claim against an explicit or session-backed expected nonce.
- `handleCallback()` will validate nonce automatically when both of these are true:
- the token response contains an `id_token`
- an expected nonce is available explicitly or in session
If the provider does not return an `id_token`, callback handling remains graceful and does not fail purely because a nonce was stored. If an `id_token` is returned but the `nonce` claim is missing or mismatched, the SDK throws `BlackWall\Auth\Exception\NonceMismatchException`.
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:
```php
$claims = $client->decodeJwtPayloadClaims($result->tokens->idToken);
```
This helper only decodes the JWT payload. It does not verify the token signature, issuer, audience, expiry, or authorised token use. Nonce matching is still important, and complete ID token verification may require provider JWK handling and additional OIDC checks outside the current SDK scope.
## Backwards compatibility
A wrapper class is retained for older integrations:
@@ -145,7 +188,7 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
- Do not submit parallel approve/reject/cancel decisions for the same approval request ID.
- Apply bounded CSV import sizes and row counts for admin bulk-user operations.
- Never place client secrets, refresh tokens, or access tokens in URL query strings.
- For OIDC providers, include a per-request `nonce` in authorisation requests.
- For OIDC providers, include a per-request `nonce` in authorisation requests and validate it when an `id_token` is returned.
- Ensure requested `scope` values in SDK config/examples (for example `offline_access`) are enabled on the provider-side client before rollout.
- Expect refresh-token exchange to fail (`invalid_grant`) if provider-side client scopes were tightened and the refresh token now carries disallowed scopes.
- For direct Cryptbin API usage, send `key_b64url` on unwrap calls; provider rejects key-mismatch unwrap attempts with `403 Forbidden`.
+68 -8
View File
@@ -25,12 +25,7 @@ $client = new AuthClient(Config::fromArray([
]));
if (!isset($_SESSION['user'])) {
$auth = $client->buildAuthorisationUrl([
'extra' => [
// Some providers require nonce for OIDC requests.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
$auth = $client->buildAuthorisationUrl();
// Prevent stale redirects from being reused from cache.
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
@@ -41,6 +36,8 @@ if (!isset($_SESSION['user'])) {
echo 'Already signed in.';
```
When the requested scope contains `openid`, the SDK generates a secure nonce, persists it in `$_SESSION['blackwall_oidc_nonce']`, and includes `nonce=...` in the provider authorisation URL together with `state`, `code_challenge`, and the rest of the OAuth/OIDC query parameters.
## 2. Handle callback
```php
@@ -72,7 +69,9 @@ if (isset($_GET['error'])) {
exit('Authorisation failed: ' . $description);
}
$result = $client->handleCallback($_GET);
$result = $client->handleCallback($_GET, true, [
'expected_nonce' => $_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null,
]);
$_SESSION['user'] = [
'email' => $result->user->email,
@@ -86,6 +85,66 @@ header('Location: /');
exit;
```
If the provider returns an `id_token`, `handleCallback()` decodes the payload claims and checks the `nonce` claim against the expected value. If no `id_token` is returned, the callback continues normally. If the `nonce` claim is missing or mismatched, the SDK throws `BlackWall\Auth\Exception\NonceMismatchException`.
## OIDC nonce handling
Use `state` and `nonce` for different jobs:
- `state` protects the front-channel redirect and callback correlation.
- `nonce` protects the OpenID Connect authentication result carried by `id_token`.
The SDK stores the following session keys when persistence is enabled:
- `blackwall_oauth_state`
- `blackwall_oauth_code_verifier`
- `blackwall_oidc_nonce`
If you need to supply your own nonce, pass it directly:
```php
$auth = $client->buildAuthorisationUrl([
'nonce' => 'your-application-generated-nonce',
]);
```
You can validate an `id_token` nonce directly:
```php
$claims = $client->assertIdTokenNonceMatches(
$result->tokens->idToken,
$_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null
);
```
You can also inspect JWT payload claims when debugging or doing application-level checks:
```php
$claims = $client->decodeJwtPayloadClaims($result->tokens->idToken);
```
This helper only decodes the JWT payload. It does not verify signature, issuer, audience, expiry, or token use. Full ID token verification may require JWK retrieval and additional OpenID Connect checks outside the current SDK scope.
If a deployment cannot yet enforce nonce validation strictly, a temporary compatibility mode is available:
```php
$result = $client->handleCallback($_GET, true, [
'validate_nonce' => false,
]);
```
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.
@@ -119,6 +178,7 @@ if ($tokens->refreshToken !== null) {
Catch specific exceptions for clearer handling:
- `BlackWall\Auth\Exception\StateMismatchException`
- `BlackWall\Auth\Exception\NonceMismatchException`
- `BlackWall\Auth\Exception\TokenExchangeException`
- `BlackWall\Auth\Exception\UserInfoException`
- `BlackWall\Auth\Exception\TransportException`
@@ -169,7 +229,7 @@ Provider user/project assignment views may exclude disabled projects even if his
- Treat report/export-style provider endpoints as high-cost and expect stricter rate limits.
- Rotate client secrets for confidential clients.
- Do not transport secrets or tokens in URL query parameters; keep them in server-side session or secure storage only.
- Add a unique `nonce` to each OIDC authorisation request.
- Add a unique `nonce` to each OIDC authorisation request and validate it against the `id_token` when one is returned.
- Keep `state`, `nonce`, and PKCE values within standard URL-safe formats/lengths; malformed values can be rejected by provider validation.
- Validate `state` on every callback request.
- Use secure session cookies (`Secure`, `HttpOnly`, `SameSite=Lax` or stricter).
+1
View File
@@ -30,6 +30,7 @@ $user = $client->getUserInfo($tokens->accessToken);
- Token responses are strongly typed via `TokenSet`.
- Errors are represented by specific exception classes.
- HTTP transport is swappable via `HttpClientInterface`.
- OIDC nonce handling is now first-class: `buildAuthorisationUrl()` returns `nonce`, persists it in session by default, and `handleCallback()` can validate it against the returned `id_token`.
## Compatibility status
+8 -1
View File
@@ -16,6 +16,8 @@ foreach ($autoloadCandidates as $file) {
}
use BlackWallSDK\BlackWallAuth;
use BlackWall\Auth\AuthClient;
use BlackWall\Auth\Exception\NonceMismatchException;
use BlackWall\Auth\Exception\UserInfoException;
$auth = new BlackWallAuth([
@@ -36,13 +38,18 @@ if (isset($_GET['error'])) {
}
try {
$result = $auth->handleCallback($_GET);
$result = $auth->handleCallback($_GET, true, [
'expected_nonce' => $_SESSION[AuthClient::NONCE_SESSION_KEY] ?? null,
]);
$_SESSION['access_token'] = $result['tokens']['access_token'] ?? null;
$_SESSION['refresh_token'] = $result['tokens']['refresh_token'] ?? null;
$_SESSION['user'] = $result['raw_user'] ?? null;
header('Location: /');
exit;
} catch (NonceMismatchException $e) {
http_response_code(403);
echo 'Sign-in failed: the OpenID Connect nonce check did not pass. Please try signing in again.';
} catch (UserInfoException $e) {
http_response_code(403);
echo 'User info access failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+4 -2
View File
@@ -16,6 +16,7 @@ foreach ($autoloadCandidates as $file) {
}
use BlackWallSDK\BlackWallAuth;
use BlackWall\Auth\AuthClient;
$auth = new BlackWallAuth([
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
@@ -31,8 +32,9 @@ if (isset($_GET['logout'])) {
$_SESSION['access_token'],
$_SESSION['refresh_token'],
$_SESSION['user'],
$_SESSION['blackwall_oauth_state'],
$_SESSION['blackwall_oauth_code_verifier']
$_SESSION[AuthClient::STATE_SESSION_KEY],
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY],
$_SESSION[AuthClient::NONCE_SESSION_KEY]
);
header('Location: /');
exit;
+149 -8
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace BlackWall\Auth;
use BlackWall\Auth\Exception\NonceMismatchException;
use BlackWall\Auth\Exception\AuthException;
use BlackWall\Auth\Exception\StateMismatchException;
use BlackWall\Auth\Exception\TokenExchangeException;
use BlackWall\Auth\Exception\UserInfoException;
@@ -12,6 +14,10 @@ use BlackWall\Auth\Http\HttpClientInterface;
final class AuthClient
{
public const STATE_SESSION_KEY = 'blackwall_oauth_state';
public const CODE_VERIFIER_SESSION_KEY = 'blackwall_oauth_code_verifier';
public const NONCE_SESSION_KEY = 'blackwall_oidc_nonce';
public function __construct(
private readonly Config $config,
private readonly HttpClientInterface $httpClient = new CurlHttpClient()
@@ -20,7 +26,7 @@ final class AuthClient
/**
* @param array<string, mixed> $options
* @return array{url:string,state:string,code_verifier:string,code_challenge:string}
* @return array{url:string,state:string,code_verifier:string,code_challenge:string,nonce:?string}
*/
public function buildAuthorisationUrl(array $options = []): array
{
@@ -30,10 +36,16 @@ final class AuthClient
$scope = isset($options['scope']) ? (string) $options['scope'] : $this->config->defaultScope;
$extra = isset($options['extra']) && is_array($options['extra']) ? $options['extra'] : [];
$persist = !isset($options['persist']) || (bool) $options['persist'];
$nonce = $this->resolveNonce($scope, $options, $extra);
if ($persist && session_status() === PHP_SESSION_ACTIVE) {
$_SESSION['blackwall_oauth_state'] = $state;
$_SESSION['blackwall_oauth_code_verifier'] = $codeVerifier;
$_SESSION[self::STATE_SESSION_KEY] = $state;
$_SESSION[self::CODE_VERIFIER_SESSION_KEY] = $codeVerifier;
if ($nonce !== null) {
$_SESSION[self::NONCE_SESSION_KEY] = $nonce;
} else {
unset($_SESSION[self::NONCE_SESSION_KEY]);
}
}
$query = array_merge([
@@ -45,6 +57,9 @@ final class AuthClient
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
], $extra);
if ($nonce !== null) {
$query['nonce'] = $nonce;
}
$url = $this->config->authorizeUrl . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
@@ -53,20 +68,29 @@ final class AuthClient
'state' => $state,
'code_verifier' => $codeVerifier,
'code_challenge' => $codeChallenge,
'nonce' => $nonce,
];
}
public function assertStateMatches(string $state): void
{
$sessionState = $_SESSION['blackwall_oauth_state'] ?? null;
$sessionState = $_SESSION[self::STATE_SESSION_KEY] ?? null;
if (!is_string($sessionState) || $state !== $sessionState) {
throw new StateMismatchException('The OAuth state did not match the session value', 'state_mismatch');
}
}
public function assertNonceMatches(string $nonce): void
{
$sessionNonce = $_SESSION[self::NONCE_SESSION_KEY] ?? null;
if (!is_string($sessionNonce) || $nonce !== $sessionNonce) {
throw new NonceMismatchException('The OIDC nonce did not match the session value', 'nonce_mismatch');
}
}
public function exchangeCodeForTokens(string $code, ?string $codeVerifier = null): TokenSet
{
$verifier = $codeVerifier ?? ($_SESSION['blackwall_oauth_code_verifier'] ?? null);
$verifier = $codeVerifier ?? ($_SESSION[self::CODE_VERIFIER_SESSION_KEY] ?? null);
if (!is_string($verifier) || $verifier === '') {
throw new TokenExchangeException('Missing code verifier; pass one explicitly or persist it in session.', 'missing_code_verifier');
}
@@ -157,9 +181,58 @@ final class AuthClient
}
/**
* @param array<string, mixed> $query
* Decodes JWT payload claims only. This does not verify signature, issuer, audience, expiry, or token use.
*
* @return array<string, mixed>
*/
public function handleCallback(array $query, bool $clearPkce = true): CallbackResult
public function decodeJwtPayloadClaims(string $jwt): array
{
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
throw new AuthException('JWT must contain exactly three segments', 'invalid_jwt');
}
$payload = $this->base64UrlDecode($parts[1]);
if ($payload === false) {
throw new AuthException('JWT payload was not valid base64url', 'invalid_jwt');
}
$claims = json_decode($payload, true);
if (!is_array($claims)) {
throw new AuthException('JWT payload was not valid JSON', 'invalid_jwt');
}
return $claims;
}
/**
* @return array<string, mixed>
*/
public function assertIdTokenNonceMatches(string $idToken, ?string $expectedNonce = null): array
{
$claims = $this->decodeJwtPayloadClaims($idToken);
$nonce = $claims['nonce'] ?? null;
if (!is_string($nonce) || $nonce === '') {
throw new NonceMismatchException('The ID token did not contain a nonce claim', 'nonce_missing');
}
$expected = $expectedNonce ?? ($_SESSION[self::NONCE_SESSION_KEY] ?? null);
if (!is_string($expected) || $expected === '') {
throw new NonceMismatchException('Missing expected nonce; pass one explicitly or persist it in session.', 'missing_expected_nonce');
}
if (!hash_equals($expected, $nonce)) {
throw new NonceMismatchException('The ID token nonce did not match the expected value', 'nonce_mismatch');
}
return $claims;
}
/**
* @param array<string, mixed> $query
* @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options
*/
public function handleCallback(array $query, bool $clearPkce = true, array $options = []): CallbackResult
{
if (!isset($query['code'], $query['state'])) {
throw new UserInfoException('Missing code/state', 'missing_callback_params');
@@ -167,6 +240,7 @@ final class AuthClient
$this->assertStateMatches((string) $query['state']);
$tokens = $this->exchangeCodeForTokens((string) $query['code']);
$this->maybeValidateNonce($tokens, $options);
$rawUser = $this->getUserInfo($tokens->accessToken);
$user = UserInfoNormalizer::normalize($rawUser);
@@ -203,7 +277,7 @@ final class AuthClient
public function clearPkceSessionState(): void
{
unset($_SESSION['blackwall_oauth_state'], $_SESSION['blackwall_oauth_code_verifier']);
unset($_SESSION[self::STATE_SESSION_KEY], $_SESSION[self::CODE_VERIFIER_SESSION_KEY], $_SESSION[self::NONCE_SESSION_KEY]);
}
private function base64Url(string $binary): string
@@ -211,6 +285,73 @@ final class AuthClient
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
}
private function base64UrlDecode(string $value): string|false
{
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
return base64_decode(strtr($value, '-_', '+/'), true);
}
/**
* @param array<string, mixed> $options
* @param array<string, mixed> $extra
*/
private function resolveNonce(string $scope, array $options, array $extra): ?string
{
if (array_key_exists('nonce', $options)) {
$nonce = $options['nonce'];
return is_string($nonce) && $nonce !== '' ? $nonce : null;
}
if (isset($extra['nonce']) && is_string($extra['nonce']) && $extra['nonce'] !== '') {
return $extra['nonce'];
}
return $this->scopeIncludesOpenId($scope) ? $this->randomUrlSafe(32) : null;
}
private function scopeIncludesOpenId(string $scope): bool
{
$scopes = preg_split('/\s+/', trim($scope)) ?: [];
return in_array('openid', $scopes, true);
}
/**
* @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options
*/
private function maybeValidateNonce(TokenSet $tokens, array $options): void
{
$validateNonce = !isset($options['validate_nonce']) || (bool) $options['validate_nonce'];
if (!$validateNonce || $tokens->idToken === null) {
return;
}
$expectedNonce = $options['expected_nonce'] ?? ($_SESSION[self::NONCE_SESSION_KEY] ?? null);
if (!is_string($expectedNonce) || $expectedNonce === '') {
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);
}
private function randomUrlSafe(int $length): string
{
return $this->base64Url(random_bytes($length));
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Exception;
class NonceMismatchException extends AuthException
{
}
+27 -3
View File
@@ -26,13 +26,36 @@ class BlackWallAuth
/**
* @param array<string, mixed> $opts
* @return array{url:string,state:string,code_verifier:string,code_challenge:string}
* @return array{url:string,state:string,code_verifier:string,code_challenge:string,nonce:?string}
*/
public function getAuthorizationUrl(array $opts = []): array
{
return $this->client->buildAuthorisationUrl($opts);
}
public function assertNonceMatches(string $nonce): void
{
$this->client->assertNonceMatches($nonce);
}
/**
* @return array<string, mixed>
*/
public function assertIdTokenNonceMatches(string $idToken, ?string $expectedNonce = null): array
{
return $this->client->assertIdTokenNonceMatches($idToken, $expectedNonce);
}
/**
* Decodes JWT payload claims only. This does not perform cryptographic verification.
*
* @return array<string, mixed>
*/
public function decodeJwtPayloadClaims(string $jwt): array
{
return $this->client->decodeJwtPayloadClaims($jwt);
}
/**
* @return array<string, mixed>
*/
@@ -73,15 +96,16 @@ class BlackWallAuth
/**
* @param array<string,mixed> $query
* @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>},
* raw_user: array<string,mixed>
* }
*/
public function handleCallback(array $query, bool $clearPkce = true): array
public function handleCallback(array $query, bool $clearPkce = true, array $options = []): array
{
$result = $this->client->handleCallback($query, $clearPkce);
$result = $this->client->handleCallback($query, $clearPkce, $options);
return [
'tokens' => $result->tokens->raw,
'user' => [
+219 -11
View File
@@ -6,6 +6,7 @@ namespace BlackWall\Auth\Tests;
use BlackWall\Auth\AuthClient;
use BlackWall\Auth\Config;
use BlackWall\Auth\Exception\NonceMismatchException;
use BlackWall\Auth\Exception\StateMismatchException;
use BlackWall\Auth\Exception\TokenExchangeException;
use BlackWall\Auth\Exception\UserInfoException;
@@ -47,14 +48,59 @@ final class AuthClientTest extends TestCase
self::assertArrayHasKey('state', $result);
self::assertArrayHasKey('code_verifier', $result);
self::assertArrayHasKey('code_challenge', $result);
self::assertSame($result['state'], $_SESSION['blackwall_oauth_state']);
self::assertSame($result['code_verifier'], $_SESSION['blackwall_oauth_code_verifier']);
self::assertArrayHasKey('nonce', $result);
self::assertIsString($result['nonce']);
self::assertNotSame('', $result['nonce']);
self::assertSame($result['state'], $_SESSION[AuthClient::STATE_SESSION_KEY]);
self::assertSame($result['code_verifier'], $_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY]);
self::assertSame($result['nonce'], $_SESSION[AuthClient::NONCE_SESSION_KEY]);
self::assertStringContainsString('code_challenge_method=S256', $result['url']);
self::assertStringContainsString('nonce=', $result['url']);
}
public function testBuildAuthorisationUrlDoesNotGenerateNonceWithoutOpenIdScope(): void
{
$result = $this->client->buildAuthorisationUrl([
'scope' => 'profile email',
]);
self::assertNull($result['nonce']);
self::assertArrayNotHasKey(AuthClient::NONCE_SESSION_KEY, $_SESSION);
self::assertStringNotContainsString('nonce=', $result['url']);
}
public function testBuildAuthorisationUrlPersistsProvidedNonce(): void
{
$result = $this->client->buildAuthorisationUrl([
'nonce' => 'nonce-123',
]);
self::assertSame('nonce-123', $result['nonce']);
self::assertSame('nonce-123', $_SESSION[AuthClient::NONCE_SESSION_KEY]);
}
public function testAssertNonceMatchesThrowsOnMismatch(): void
{
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce';
$this->expectException(NonceMismatchException::class);
$this->client->assertNonceMatches('actual-nonce');
}
public function testDecodeJwtPayloadClaimsReturnsClaimsWithoutVerification(): void
{
$claims = $this->client->decodeJwtPayloadClaims($this->makeJwt([
'sub' => 'user-1',
'nonce' => 'nonce-1',
]));
self::assertSame('user-1', $claims['sub']);
self::assertSame('nonce-1', $claims['nonce']);
}
public function testAssertStateMatchesThrowsOnMismatch(): void
{
$_SESSION['blackwall_oauth_state'] = 'expected';
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'expected';
$this->expectException(StateMismatchException::class);
$this->client->assertStateMatches('actual');
@@ -62,7 +108,7 @@ final class AuthClientTest extends TestCase
public function testExchangeCodeForTokensReturnsTypedTokenSet(): void
{
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
$this->http->queuePost([
'status' => 200,
@@ -95,7 +141,7 @@ final class AuthClientTest extends TestCase
public function testExchangeCodeForTokensThrowsOnEndpointError(): void
{
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
$this->http->queuePost([
'status' => 400,
@@ -173,8 +219,66 @@ final class AuthClientTest extends TestCase
public function testHandleCallbackReturnsCallbackResult(): void
{
$_SESSION['blackwall_oauth_state'] = 'state-1';
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'nonce-1';
$this->http->queuePost([
'status' => 200,
'body' => json_encode([
'access_token' => 'access-1',
'refresh_token' => 'refresh-1',
'id_token' => $this->makeJwt(['nonce' => 'nonce-1', '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);
self::assertSame('tutor@example.com', $result->user->email);
self::assertSame(2, $result->user->privilegeLevel);
self::assertArrayNotHasKey(AuthClient::STATE_SESSION_KEY, $_SESSION);
self::assertArrayNotHasKey(AuthClient::CODE_VERIFIER_SESSION_KEY, $_SESSION);
self::assertArrayNotHasKey(AuthClient::NONCE_SESSION_KEY, $_SESSION);
}
public function testHandleCallbackThrowsWhenIdTokenNonceDoesNotMatch(): 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(['nonce' => 'actual-nonce', 'sub' => 'user-1']),
'token_type' => 'Bearer',
], JSON_THROW_ON_ERROR),
]);
$this->expectException(NonceMismatchException::class);
$this->client->handleCallback([
'code' => 'code-1',
'state' => 'state-1',
]);
}
public function testHandleCallbackSucceedsWhenNoIdTokenReturned(): 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,
@@ -196,10 +300,98 @@ final class AuthClientTest extends TestCase
]);
self::assertSame('access-1', $result->tokens->accessToken);
self::assertSame('tutor@example.com', $result->user->email);
self::assertSame(2, $result->user->privilegeLevel);
self::assertArrayNotHasKey('blackwall_oauth_state', $_SESSION);
self::assertArrayNotHasKey('blackwall_oauth_code_verifier', $_SESSION);
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';
$_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(['nonce' => 'different-nonce', '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, [
'validate_nonce' => false,
]);
self::assertSame('access-1', $result->tokens->accessToken);
}
public function testHandleCallbackThrowsOnMissingParams(): void
@@ -207,4 +399,20 @@ final class AuthClientTest extends TestCase
$this->expectException(UserInfoException::class);
$this->client->handleCallback(['state' => 'only-state']);
}
/**
* @param array<string, mixed> $claims
*/
private function makeJwt(array $claims): string
{
$header = $this->base64Url(json_encode(['alg' => 'none', 'typ' => 'JWT'], JSON_THROW_ON_ERROR));
$payload = $this->base64Url(json_encode($claims, JSON_THROW_ON_ERROR));
return $header . '.' . $payload . '.signature';
}
private function base64Url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
}