Additional Improvements

This commit is contained in:
Andy Dixon
2026-03-11 07:41:21 +00:00
parent 986dc95646
commit 1d8578c1da
9 changed files with 441 additions and 49 deletions
+41 -10
View File
@@ -9,7 +9,8 @@ This package provides:
- UserInfo retrieval - UserInfo retrieval
- UserInfo normalisation (`email`, `privilege_level`, `role`) - UserInfo normalisation (`email`, `privilege_level`, `role`)
- Unified callback handling helper (`handleCallback`) - 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 - Typed API (`AuthClient`, `TokenSet`) and specific exception classes
## Requirements ## Requirements
@@ -77,17 +78,12 @@ $config = Config::fromArray([
$client = new AuthClient($config); $client = new AuthClient($config);
// Step 1: redirect user to provider // Step 1: redirect user to provider
$auth = $client->buildAuthorisationUrl([ $auth = $client->buildAuthorisationUrl();
'extra' => [
// Include nonce in the authorisation request for OIDC.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
header('Location: ' . $auth['url']); header('Location: ' . $auth['url']);
exit; exit;
``` ```
The generated authorisation request URL will include `nonce=...` alongside `state`, `scope`, and the PKCE parameters. 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 callback handling, see `docs/INTEGRATION_GUIDE.md`.
For provider-side threat/invariant assumptions, see `docs/SECURITY_MODEL.md`. For provider-side threat/invariant assumptions, see `docs/SECURITY_MODEL.md`.
@@ -95,7 +91,9 @@ For provider-side threat/invariant assumptions, see `docs/SECURITY_MODEL.md`.
Minimal callback example: Minimal callback example:
```php ```php
$result = $client->handleCallback($_GET); $result = $client->handleCallback($_GET, true, [
'expected_nonce' => $_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null,
]);
$_SESSION['user'] = [ $_SESSION['user'] = [
'email' => $result->user->email, 'email' => $result->user->email,
@@ -105,6 +103,39 @@ $_SESSION['user'] = [
$_SESSION['access_token'] = $result->tokens->accessToken; $_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.
### 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 ## Backwards compatibility
A wrapper class is retained for older integrations: A wrapper class is retained for older integrations:
@@ -147,7 +178,7 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
- Do not submit parallel approve/reject/cancel decisions for the same approval request ID. - 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. - 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. - 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. - 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. - 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`. - For direct Cryptbin API usage, send `key_b64url` on unwrap calls; provider rejects key-mismatch unwrap attempts with `403 Forbidden`.
+57 -9
View File
@@ -25,12 +25,7 @@ $client = new AuthClient(Config::fromArray([
])); ]));
if (!isset($_SESSION['user'])) { if (!isset($_SESSION['user'])) {
$auth = $client->buildAuthorisationUrl([ $auth = $client->buildAuthorisationUrl();
'extra' => [
// Include nonce in the authorisation request for OIDC.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
// Prevent stale redirects from being reused from cache. // Prevent stale redirects from being reused from cache.
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache'); header('Pragma: no-cache');
@@ -41,7 +36,7 @@ if (!isset($_SESSION['user'])) {
echo 'Already signed in.'; echo 'Already signed in.';
``` ```
The resulting redirect request includes `nonce=...` in the provider authorisation URL, together with `state`, `code_challenge`, and the rest of the OAuth/OIDC query parameters. 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 ## 2. Handle callback
@@ -74,7 +69,9 @@ if (isset($_GET['error'])) {
exit('Authorisation failed: ' . $description); 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'] = [ $_SESSION['user'] = [
'email' => $result->user->email, 'email' => $result->user->email,
@@ -88,6 +85,56 @@ header('Location: /');
exit; 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.
## 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.
@@ -121,6 +168,7 @@ if ($tokens->refreshToken !== null) {
Catch specific exceptions for clearer handling: Catch specific exceptions for clearer handling:
- `BlackWall\Auth\Exception\StateMismatchException` - `BlackWall\Auth\Exception\StateMismatchException`
- `BlackWall\Auth\Exception\NonceMismatchException`
- `BlackWall\Auth\Exception\TokenExchangeException` - `BlackWall\Auth\Exception\TokenExchangeException`
- `BlackWall\Auth\Exception\UserInfoException` - `BlackWall\Auth\Exception\UserInfoException`
- `BlackWall\Auth\Exception\TransportException` - `BlackWall\Auth\Exception\TransportException`
@@ -171,7 +219,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. - Treat report/export-style provider endpoints as high-cost and expect stricter rate limits.
- Rotate client secrets for confidential clients. - 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. - 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. - 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. - Validate `state` on every callback request.
- Use secure session cookies (`Secure`, `HttpOnly`, `SameSite=Lax` or stricter). - 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`. - Token responses are strongly typed via `TokenSet`.
- Errors are represented by specific exception classes. - Errors are represented by specific exception classes.
- HTTP transport is swappable via `HttpClientInterface`. - 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 ## Compatibility status
+8 -1
View File
@@ -16,6 +16,8 @@ foreach ($autoloadCandidates as $file) {
} }
use BlackWallSDK\BlackWallAuth; use BlackWallSDK\BlackWallAuth;
use BlackWall\Auth\AuthClient;
use BlackWall\Auth\Exception\NonceMismatchException;
use BlackWall\Auth\Exception\UserInfoException; use BlackWall\Auth\Exception\UserInfoException;
$auth = new BlackWallAuth([ $auth = new BlackWallAuth([
@@ -36,13 +38,18 @@ if (isset($_GET['error'])) {
} }
try { 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['access_token'] = $result['tokens']['access_token'] ?? null;
$_SESSION['refresh_token'] = $result['tokens']['refresh_token'] ?? null; $_SESSION['refresh_token'] = $result['tokens']['refresh_token'] ?? null;
$_SESSION['user'] = $result['raw_user'] ?? null; $_SESSION['user'] = $result['raw_user'] ?? null;
header('Location: /'); header('Location: /');
exit; 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) { } catch (UserInfoException $e) {
http_response_code(403); http_response_code(403);
echo 'User info access failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); echo 'User info access failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+5 -7
View File
@@ -16,6 +16,7 @@ foreach ($autoloadCandidates as $file) {
} }
use BlackWallSDK\BlackWallAuth; use BlackWallSDK\BlackWallAuth;
use BlackWall\Auth\AuthClient;
$auth = new BlackWallAuth([ $auth = new BlackWallAuth([
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2', 'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
@@ -31,19 +32,16 @@ if (isset($_GET['logout'])) {
$_SESSION['access_token'], $_SESSION['access_token'],
$_SESSION['refresh_token'], $_SESSION['refresh_token'],
$_SESSION['user'], $_SESSION['user'],
$_SESSION['blackwall_oauth_state'], $_SESSION[AuthClient::STATE_SESSION_KEY],
$_SESSION['blackwall_oauth_code_verifier'] $_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY],
$_SESSION[AuthClient::NONCE_SESSION_KEY]
); );
header('Location: /'); header('Location: /');
exit; exit;
} }
if (!isset($_SESSION['user'])) { if (!isset($_SESSION['user'])) {
$authData = $auth->getAuthorizationUrl([ $authData = $auth->getAuthorizationUrl();
'extra' => [
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
header('Location: ' . $authData['url']); header('Location: ' . $authData['url']);
exit; exit;
} }
+134 -8
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace BlackWall\Auth; namespace BlackWall\Auth;
use BlackWall\Auth\Exception\NonceMismatchException;
use BlackWall\Auth\Exception\AuthException;
use BlackWall\Auth\Exception\StateMismatchException; use BlackWall\Auth\Exception\StateMismatchException;
use BlackWall\Auth\Exception\TokenExchangeException; use BlackWall\Auth\Exception\TokenExchangeException;
use BlackWall\Auth\Exception\UserInfoException; use BlackWall\Auth\Exception\UserInfoException;
@@ -12,6 +14,10 @@ use BlackWall\Auth\Http\HttpClientInterface;
final class AuthClient 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( public function __construct(
private readonly Config $config, private readonly Config $config,
private readonly HttpClientInterface $httpClient = new CurlHttpClient() private readonly HttpClientInterface $httpClient = new CurlHttpClient()
@@ -20,7 +26,7 @@ final class AuthClient
/** /**
* @param array<string, mixed> $options * @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 public function buildAuthorisationUrl(array $options = []): array
{ {
@@ -30,10 +36,16 @@ final class AuthClient
$scope = isset($options['scope']) ? (string) $options['scope'] : $this->config->defaultScope; $scope = isset($options['scope']) ? (string) $options['scope'] : $this->config->defaultScope;
$extra = isset($options['extra']) && is_array($options['extra']) ? $options['extra'] : []; $extra = isset($options['extra']) && is_array($options['extra']) ? $options['extra'] : [];
$persist = !isset($options['persist']) || (bool) $options['persist']; $persist = !isset($options['persist']) || (bool) $options['persist'];
$nonce = $this->resolveNonce($scope, $options, $extra);
if ($persist && session_status() === PHP_SESSION_ACTIVE) { if ($persist && session_status() === PHP_SESSION_ACTIVE) {
$_SESSION['blackwall_oauth_state'] = $state; $_SESSION[self::STATE_SESSION_KEY] = $state;
$_SESSION['blackwall_oauth_code_verifier'] = $codeVerifier; $_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([ $query = array_merge([
@@ -45,6 +57,9 @@ final class AuthClient
'code_challenge' => $codeChallenge, 'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256', 'code_challenge_method' => 'S256',
], $extra); ], $extra);
if ($nonce !== null) {
$query['nonce'] = $nonce;
}
$url = $this->config->authorizeUrl . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); $url = $this->config->authorizeUrl . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
@@ -53,20 +68,29 @@ final class AuthClient
'state' => $state, 'state' => $state,
'code_verifier' => $codeVerifier, 'code_verifier' => $codeVerifier,
'code_challenge' => $codeChallenge, 'code_challenge' => $codeChallenge,
'nonce' => $nonce,
]; ];
} }
public function assertStateMatches(string $state): void 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) { if (!is_string($sessionState) || $state !== $sessionState) {
throw new StateMismatchException('The OAuth state did not match the session value', 'state_mismatch'); 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 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 === '') { if (!is_string($verifier) || $verifier === '') {
throw new TokenExchangeException('Missing code verifier; pass one explicitly or persist it in session.', 'missing_code_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} $options
*/
public function handleCallback(array $query, bool $clearPkce = true, array $options = []): CallbackResult
{ {
if (!isset($query['code'], $query['state'])) { if (!isset($query['code'], $query['state'])) {
throw new UserInfoException('Missing code/state', 'missing_callback_params'); throw new UserInfoException('Missing code/state', 'missing_callback_params');
@@ -167,6 +240,7 @@ final class AuthClient
$this->assertStateMatches((string) $query['state']); $this->assertStateMatches((string) $query['state']);
$tokens = $this->exchangeCodeForTokens((string) $query['code']); $tokens = $this->exchangeCodeForTokens((string) $query['code']);
$this->maybeValidateNonce($tokens, $options);
$rawUser = $this->getUserInfo($tokens->accessToken); $rawUser = $this->getUserInfo($tokens->accessToken);
$user = UserInfoNormalizer::normalize($rawUser); $user = UserInfoNormalizer::normalize($rawUser);
@@ -203,7 +277,7 @@ final class AuthClient
public function clearPkceSessionState(): void 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 private function base64Url(string $binary): string
@@ -211,6 +285,58 @@ final class AuthClient
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); 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} $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;
}
$this->assertIdTokenNonceMatches($tokens->idToken, $expectedNonce);
}
private function randomUrlSafe(int $length): string private function randomUrlSafe(int $length): string
{ {
return $this->base64Url(random_bytes($length)); 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 * @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 public function getAuthorizationUrl(array $opts = []): array
{ {
return $this->client->buildAuthorisationUrl($opts); 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> * @return array<string, mixed>
*/ */
@@ -73,15 +96,16 @@ class BlackWallAuth
/** /**
* @param array<string,mixed> $query * @param array<string,mixed> $query
* @param array{expected_nonce?:?string,validate_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>},
* raw_user: 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 [ return [
'tokens' => $result->tokens->raw, 'tokens' => $result->tokens->raw,
'user' => [ 'user' => [
+159 -11
View File
@@ -6,6 +6,7 @@ namespace BlackWall\Auth\Tests;
use BlackWall\Auth\AuthClient; use BlackWall\Auth\AuthClient;
use BlackWall\Auth\Config; use BlackWall\Auth\Config;
use BlackWall\Auth\Exception\NonceMismatchException;
use BlackWall\Auth\Exception\StateMismatchException; use BlackWall\Auth\Exception\StateMismatchException;
use BlackWall\Auth\Exception\TokenExchangeException; use BlackWall\Auth\Exception\TokenExchangeException;
use BlackWall\Auth\Exception\UserInfoException; use BlackWall\Auth\Exception\UserInfoException;
@@ -47,14 +48,59 @@ final class AuthClientTest extends TestCase
self::assertArrayHasKey('state', $result); self::assertArrayHasKey('state', $result);
self::assertArrayHasKey('code_verifier', $result); self::assertArrayHasKey('code_verifier', $result);
self::assertArrayHasKey('code_challenge', $result); self::assertArrayHasKey('code_challenge', $result);
self::assertSame($result['state'], $_SESSION['blackwall_oauth_state']); self::assertArrayHasKey('nonce', $result);
self::assertSame($result['code_verifier'], $_SESSION['blackwall_oauth_code_verifier']); 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('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 public function testAssertStateMatchesThrowsOnMismatch(): void
{ {
$_SESSION['blackwall_oauth_state'] = 'expected'; $_SESSION[AuthClient::STATE_SESSION_KEY] = 'expected';
$this->expectException(StateMismatchException::class); $this->expectException(StateMismatchException::class);
$this->client->assertStateMatches('actual'); $this->client->assertStateMatches('actual');
@@ -62,7 +108,7 @@ final class AuthClientTest extends TestCase
public function testExchangeCodeForTokensReturnsTypedTokenSet(): void public function testExchangeCodeForTokensReturnsTypedTokenSet(): void
{ {
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123'; $_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
$this->http->queuePost([ $this->http->queuePost([
'status' => 200, 'status' => 200,
@@ -95,7 +141,7 @@ final class AuthClientTest extends TestCase
public function testExchangeCodeForTokensThrowsOnEndpointError(): void public function testExchangeCodeForTokensThrowsOnEndpointError(): void
{ {
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123'; $_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
$this->http->queuePost([ $this->http->queuePost([
'status' => 400, 'status' => 400,
@@ -173,8 +219,66 @@ final class AuthClientTest extends TestCase
public function testHandleCallbackReturnsCallbackResult(): void public function testHandleCallbackReturnsCallbackResult(): void
{ {
$_SESSION['blackwall_oauth_state'] = 'state-1'; $_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123'; $_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([ $this->http->queuePost([
'status' => 200, 'status' => 200,
@@ -196,10 +300,38 @@ final class AuthClientTest extends TestCase
]); ]);
self::assertSame('access-1', $result->tokens->accessToken); self::assertSame('access-1', $result->tokens->accessToken);
self::assertSame('tutor@example.com', $result->user->email); self::assertNull($result->tokens->idToken);
self::assertSame(2, $result->user->privilegeLevel); }
self::assertArrayNotHasKey('blackwall_oauth_state', $_SESSION);
self::assertArrayNotHasKey('blackwall_oauth_code_verifier', $_SESSION); 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 public function testHandleCallbackThrowsOnMissingParams(): void
@@ -207,4 +339,20 @@ final class AuthClientTest extends TestCase
$this->expectException(UserInfoException::class); $this->expectException(UserInfoException::class);
$this->client->handleCallback(['state' => 'only-state']); $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), '+/', '-_'), '=');
}
} }