Additional Improvements
This commit is contained in:
+134
-8
@@ -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} $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,58 @@ 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} $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
|
||||
{
|
||||
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
@@ -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} $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' => [
|
||||
|
||||
Reference in New Issue
Block a user