From 94c85fc8d554e7520c4bcbf0a7d094764b761e8b Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 26 Feb 2026 09:07:11 +0000 Subject: [PATCH] Release 1.0.1: normalized userinfo and callback helper --- README.md | 18 ++++ docs/INTEGRATION_GUIDE.md | 41 +++++---- public_html/callback.php | 25 +----- src/Auth/AuthClient.php | 60 ++++++++++++-- src/Auth/CallbackResult.php | 19 +++++ src/Auth/Exception/AuthException.php | 13 +++ src/Auth/UserInfo.php | 20 +++++ src/Auth/UserInfoNormalizer.php | 119 +++++++++++++++++++++++++++ src/BlackWallAuth.php | 37 +++++++++ src/legacy_autoload.php | 3 + tests/AuthClientTest.php | 60 ++++++++++++++ tests/UserInfoNormalizerTest.php | 33 ++++++++ 12 files changed, 403 insertions(+), 45 deletions(-) create mode 100644 src/Auth/CallbackResult.php create mode 100644 src/Auth/UserInfo.php create mode 100644 src/Auth/UserInfoNormalizer.php create mode 100644 tests/UserInfoNormalizerTest.php diff --git a/README.md b/README.md index 2dfdb28..5d3cdd1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ This package provides: - Code-for-token exchange - Refresh token exchange - UserInfo retrieval +- UserInfo normalisation (`email`, `privilege_level`, `role`) +- Unified callback handling helper (`handleCallback`) - Session helpers for `state` and `code_verifier` - Typed API (`AuthClient`, `TokenSet`) and specific exception classes @@ -82,6 +84,19 @@ exit; For callback handling, see `docs/INTEGRATION_GUIDE.md`. +Minimal callback example: + +```php +$result = $client->handleCallback($_GET); + +$_SESSION['user'] = [ + 'email' => $result->user->email, + 'privilege_level' => $result->user->privilegeLevel, + 'role' => $result->user->role, +]; +$_SESSION['access_token'] = $result->tokens->accessToken; +``` + ## Backwards compatibility A wrapper class is retained for older integrations: @@ -98,6 +113,9 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`. - `BlackWall\Auth\AuthClient` - `BlackWall\Auth\TokenSet` - `BlackWall\Auth\AuthResult` +- `BlackWall\Auth\CallbackResult` +- `BlackWall\Auth\UserInfo` +- `BlackWall\Auth\UserInfoNormalizer` - `BlackWall\Auth\Http\HttpClientInterface` - `BlackWall\Auth\Http\CurlHttpClient` - `BlackWall\Auth\Exception\*` diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 516cbe9..524689b 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -54,27 +54,37 @@ $client = new AuthClient(Config::fromArray([ 'redirectUri' => 'https://your-app.example/callback.php', ])); -if (!isset($_GET['code'], $_GET['state'])) { - http_response_code(400); - echo 'Missing code/state'; - exit; -} +$result = $client->handleCallback($_GET); -$client->assertStateMatches((string) $_GET['state']); -$tokens = $client->exchangeCodeForTokens((string) $_GET['code']); -$user = $client->getUserInfo($tokens->accessToken); - -$_SESSION['user'] = $user; -$_SESSION['access_token'] = $tokens->accessToken; -$_SESSION['refresh_token'] = $tokens->refreshToken; - -$client->clearPkceSessionState(); +$_SESSION['user'] = [ + 'email' => $result->user->email, + 'privilege_level' => $result->user->privilegeLevel, + 'role' => $result->user->role, +]; +$_SESSION['access_token'] = $result->tokens->accessToken; +$_SESSION['refresh_token'] = $result->tokens->refreshToken; header('Location: /'); exit; ``` -## 3. Refresh token (optional) +## 3. Single callback for multiple app roles + +Using one OAuth client and one callback URL is recommended. + +Map users in your application by: + +1. `email` claim to local account record +2. `privilege_level` (or `role`) to application role + +Typical mapping example: + +- `privilege_level = 1` => admin/superadmin +- `privilege_level = 2` => user/tutor + +If `privilege_level` is absent, the SDK also resolves common role strings (`admin`, `superadmin`, `user`, `tutor`). + +## 4. Refresh token (optional) ```php exchangeCodeForTokens($code); } catch (\BlackWall\Auth\Exception\TokenExchangeException $e) { error_log('Token exchange failed: ' . $e->getMessage()); + error_log('Auth code: ' . ($e->authCode() ?? 'none')); http_response_code(502); } ``` diff --git a/public_html/callback.php b/public_html/callback.php index 0cf3ee7..39a6d61 100644 --- a/public_html/callback.php +++ b/public_html/callback.php @@ -25,28 +25,11 @@ $auth = new BlackWallAuth([ 'redirectUri' => 'https://test.dixon.cx/callback.php', ]); -if (!isset($_GET['code'], $_GET['state'])) { - http_response_code(400); - echo 'Missing code/state'; - exit; -} - -if (isset($_SESSION['blackwall_oauth_state']) && $_GET['state'] !== $_SESSION['blackwall_oauth_state']) { - http_response_code(400); - echo 'Invalid state'; - exit; -} - try { - $tokens = $auth->exchangeCodeForTokens($_GET['code']); - $_SESSION['access_token'] = $tokens['access_token'] ?? null; - $_SESSION['refresh_token'] = $tokens['refresh_token'] ?? null; - - if (isset($_SESSION['access_token']) && is_string($_SESSION['access_token'])) { - $_SESSION['user'] = $auth->getUserInfo($_SESSION['access_token']); - } - - unset($_SESSION['blackwall_oauth_state'], $_SESSION['blackwall_oauth_code_verifier']); + $result = $auth->handleCallback($_GET); + $_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; diff --git a/src/Auth/AuthClient.php b/src/Auth/AuthClient.php index c08b965..29d9e8c 100644 --- a/src/Auth/AuthClient.php +++ b/src/Auth/AuthClient.php @@ -60,7 +60,7 @@ final class AuthClient { $sessionState = $_SESSION['blackwall_oauth_state'] ?? null; if (!is_string($sessionState) || $state !== $sessionState) { - throw new StateMismatchException('The OAuth state did not match the session value'); + throw new StateMismatchException('The OAuth state did not match the session value', 'state_mismatch'); } } @@ -68,7 +68,7 @@ final class AuthClient { $verifier = $codeVerifier ?? ($_SESSION['blackwall_oauth_code_verifier'] ?? null); if (!is_string($verifier) || $verifier === '') { - throw new TokenExchangeException('Missing code verifier; pass one explicitly or persist it in session.'); + throw new TokenExchangeException('Missing code verifier; pass one explicitly or persist it in session.', 'missing_code_verifier'); } $payload = [ @@ -88,11 +88,11 @@ final class AuthClient if ($result['status'] >= 400) { $message = is_array($data) ? json_encode($data) : $result['body']; - throw new TokenExchangeException('Token endpoint error (' . $result['status'] . '): ' . $message); + throw new TokenExchangeException('Token endpoint error (' . $result['status'] . '): ' . $message, 'token_exchange_failed'); } if (!is_array($data)) { - throw new TokenExchangeException('Token endpoint returned invalid JSON'); + throw new TokenExchangeException('Token endpoint returned invalid JSON', 'token_response_invalid_json'); } return TokenSet::fromArray($data); @@ -115,11 +115,11 @@ final class AuthClient if ($result['status'] >= 400) { $message = is_array($data) ? json_encode($data) : $result['body']; - throw new TokenExchangeException('Refresh token error (' . $result['status'] . '): ' . $message); + throw new TokenExchangeException('Refresh token error (' . $result['status'] . '): ' . $message, 'refresh_exchange_failed'); } if (!is_array($data)) { - throw new TokenExchangeException('Refresh token response was not valid JSON'); + throw new TokenExchangeException('Refresh token response was not valid JSON', 'refresh_response_invalid_json'); } return TokenSet::fromArray($data); @@ -131,7 +131,7 @@ final class AuthClient public function getUserInfo(string $accessToken): array { if ($this->config->userInfoUrl === null || $this->config->userInfoUrl === '') { - throw new UserInfoException('UserInfo URL has not been configured'); + throw new UserInfoException('UserInfo URL has not been configured', 'userinfo_url_missing'); } $result = $this->httpClient->get($this->config->userInfoUrl, [ @@ -141,16 +141,58 @@ final class AuthClient $data = json_decode($result['body'], true); if ($result['status'] >= 400) { $message = is_array($data) ? json_encode($data) : $result['body']; - throw new UserInfoException('UserInfo endpoint error (' . $result['status'] . '): ' . $message); + throw new UserInfoException('UserInfo endpoint error (' . $result['status'] . '): ' . $message, 'userinfo_request_failed'); } if (!is_array($data)) { - throw new UserInfoException('UserInfo response was not valid JSON'); + throw new UserInfoException('UserInfo response was not valid JSON', 'userinfo_invalid_json'); } return $data; } + public function getNormalizedUserInfo(string $accessToken): UserInfo + { + return UserInfoNormalizer::normalize($this->getUserInfo($accessToken)); + } + + /** + * @param array $query + */ + public function handleCallback(array $query, bool $clearPkce = true): CallbackResult + { + if (!isset($query['code'], $query['state'])) { + throw new UserInfoException('Missing code/state', 'missing_callback_params'); + } + + $this->assertStateMatches((string) $query['state']); + $tokens = $this->exchangeCodeForTokens((string) $query['code']); + $rawUser = $this->getUserInfo($tokens->accessToken); + $user = UserInfoNormalizer::normalize($rawUser); + + if ($clearPkce) { + $this->clearPkceSessionState(); + } + + return new CallbackResult($tokens, $user, $rawUser); + } + + /** + * @param array $userInfo + */ + public static function resolvePrivilegeLevel(array $userInfo): ?int + { + return UserInfoNormalizer::resolvePrivilegeLevel($userInfo); + } + + /** + * @param array $userInfo + */ + public static function resolveRole(array $userInfo): ?string + { + return UserInfoNormalizer::resolveRole($userInfo); + } + public function exchangeCodeAndFetchUser(string $code, ?string $codeVerifier = null): AuthResult { $tokens = $this->exchangeCodeForTokens($code, $codeVerifier); diff --git a/src/Auth/CallbackResult.php b/src/Auth/CallbackResult.php new file mode 100644 index 0000000..f966084 --- /dev/null +++ b/src/Auth/CallbackResult.php @@ -0,0 +1,19 @@ + $rawUser + */ + public function __construct( + public readonly TokenSet $tokens, + public readonly UserInfo $user, + public readonly array $rawUser + ) { + } +} + diff --git a/src/Auth/Exception/AuthException.php b/src/Auth/Exception/AuthException.php index a4dd04a..7501865 100644 --- a/src/Auth/Exception/AuthException.php +++ b/src/Auth/Exception/AuthException.php @@ -6,4 +6,17 @@ namespace BlackWall\Auth\Exception; class AuthException extends \RuntimeException { + public function __construct( + string $message = '', + private readonly ?string $authCode = null, + int $code = 0, + ?\Throwable $previous = null + ) { + parent::__construct($message, $code, $previous); + } + + public function authCode(): ?string + { + return $this->authCode; + } } diff --git a/src/Auth/UserInfo.php b/src/Auth/UserInfo.php new file mode 100644 index 0000000..68259d0 --- /dev/null +++ b/src/Auth/UserInfo.php @@ -0,0 +1,20 @@ + $raw + */ + public function __construct( + public readonly string $email, + public readonly ?int $privilegeLevel, + public readonly ?string $role, + public readonly array $raw + ) { + } +} + diff --git a/src/Auth/UserInfoNormalizer.php b/src/Auth/UserInfoNormalizer.php new file mode 100644 index 0000000..37acacb --- /dev/null +++ b/src/Auth/UserInfoNormalizer.php @@ -0,0 +1,119 @@ + $raw + */ + public static function normalize(array $raw): UserInfo + { + $email = self::extractEmail($raw); + if ($email === null || $email === '') { + throw new UserInfoException('UserInfo payload did not include a valid email.', 'missing_email'); + } + + $privilegeLevel = self::resolvePrivilegeLevel($raw); + $role = self::resolveRole($raw); + + return new UserInfo( + email: strtolower(trim($email)), + privilegeLevel: $privilegeLevel, + role: $role, + raw: $raw + ); + } + + /** + * @param array $raw + */ + public static function resolvePrivilegeLevel(array $raw): ?int + { + $candidates = [ + $raw['privilege_level'] ?? null, + $raw['privilegeLevel'] ?? null, + $raw['privilege'] ?? null, + $raw['level'] ?? null, + $raw['role_level'] ?? null, + $raw['roleLevel'] ?? null, + $raw['role'] ?? null, + ]; + + if (isset($raw['claims']) && is_array($raw['claims'])) { + $candidates[] = $raw['claims']['privilege_level'] ?? null; + $candidates[] = $raw['claims']['role_level'] ?? null; + $candidates[] = $raw['claims']['role'] ?? null; + } + + foreach ($candidates as $value) { + if (is_int($value)) { + return $value; + } + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + if (is_string($value)) { + $role = strtolower(trim($value)); + if (in_array($role, ['admin', 'superadmin', 'super_admin', 'owner'], true)) { + return 1; + } + if (in_array($role, ['user', 'tutor', 'member'], true)) { + return 2; + } + } + } + + return null; + } + + /** + * @param array $raw + */ + public static function resolveRole(array $raw): ?string + { + $candidates = [ + $raw['role'] ?? null, + $raw['role_name'] ?? null, + $raw['roleName'] ?? null, + ]; + + if (isset($raw['claims']) && is_array($raw['claims'])) { + $candidates[] = $raw['claims']['role'] ?? null; + $candidates[] = $raw['claims']['role_name'] ?? null; + } + + foreach ($candidates as $value) { + if (is_string($value) && trim($value) !== '') { + return strtolower(trim($value)); + } + } + + return null; + } + + /** + * @param array $raw + */ + private static function extractEmail(array $raw): ?string + { + $email = $raw['email'] ?? $raw['upn'] ?? null; + if (is_string($email) && trim($email) !== '') { + return $email; + } + + if (isset($raw['claims']) && is_array($raw['claims'])) { + $claimEmail = $raw['claims']['email'] ?? $raw['claims']['upn'] ?? null; + if (is_string($claimEmail) && trim($claimEmail) !== '') { + return $claimEmail; + } + } + + return null; + } +} + diff --git a/src/BlackWallAuth.php b/src/BlackWallAuth.php index 0423b5a..5faef8d 100644 --- a/src/BlackWallAuth.php +++ b/src/BlackWallAuth.php @@ -56,4 +56,41 @@ class BlackWallAuth { return $this->client->getUserInfo($accessToken); } + + /** + * @return array{email:string,privilege_level:?int,role:?string,raw:array} + */ + public function getNormalizedUserInfo(string $accessToken): array + { + $normalized = $this->client->getNormalizedUserInfo($accessToken); + return [ + 'email' => $normalized->email, + 'privilege_level' => $normalized->privilegeLevel, + 'role' => $normalized->role, + 'raw' => $normalized->raw, + ]; + } + + /** + * @param array $query + * @return array{ + * tokens: array, + * user: array{email:string,privilege_level:?int,role:?string,raw:array}, + * raw_user: array + * } + */ + public function handleCallback(array $query, bool $clearPkce = true): array + { + $result = $this->client->handleCallback($query, $clearPkce); + return [ + 'tokens' => $result->tokens->raw, + 'user' => [ + 'email' => $result->user->email, + 'privilege_level' => $result->user->privilegeLevel, + 'role' => $result->user->role, + 'raw' => $result->user->raw, + ], + 'raw_user' => $result->rawUser, + ]; + } } diff --git a/src/legacy_autoload.php b/src/legacy_autoload.php index fa318d0..eb0d974 100644 --- a/src/legacy_autoload.php +++ b/src/legacy_autoload.php @@ -5,6 +5,9 @@ declare(strict_types=1); require_once __DIR__ . '/Auth/Config.php'; require_once __DIR__ . '/Auth/TokenSet.php'; require_once __DIR__ . '/Auth/AuthResult.php'; +require_once __DIR__ . '/Auth/UserInfo.php'; +require_once __DIR__ . '/Auth/UserInfoNormalizer.php'; +require_once __DIR__ . '/Auth/CallbackResult.php'; require_once __DIR__ . '/Auth/Http/HttpClientInterface.php'; require_once __DIR__ . '/Auth/Http/CurlHttpClient.php'; require_once __DIR__ . '/Auth/Exception/AuthException.php'; diff --git a/tests/AuthClientTest.php b/tests/AuthClientTest.php index d3398d3..73c9a48 100644 --- a/tests/AuthClientTest.php +++ b/tests/AuthClientTest.php @@ -147,4 +147,64 @@ final class AuthClientTest extends TestCase $this->expectExceptionMessage('UserInfo endpoint error (401)'); $this->client->getUserInfo('expired-token'); } + + public function testGetNormalizedUserInfoReturnsCanonicalFields(): void + { + $this->http->queueGet([ + 'status' => 200, + 'body' => '{"email":"Admin@Example.com","claims":{"role":"admin"}}', + ]); + + $user = $this->client->getNormalizedUserInfo('access-4'); + + self::assertSame('admin@example.com', $user->email); + self::assertSame(1, $user->privilegeLevel); + self::assertSame('admin', $user->role); + } + + public function testResolvePrivilegeLevelSupportsAliases(): void + { + self::assertSame(1, AuthClient::resolvePrivilegeLevel(['privilege_level' => 1])); + self::assertSame(2, AuthClient::resolvePrivilegeLevel(['claims' => ['role_level' => '2']])); + self::assertSame(1, AuthClient::resolvePrivilegeLevel(['role' => 'superadmin'])); + self::assertSame(2, AuthClient::resolvePrivilegeLevel(['role' => 'tutor'])); + self::assertNull(AuthClient::resolvePrivilegeLevel(['foo' => 'bar'])); + } + + public function testHandleCallbackReturnsCallbackResult(): void + { + $_SESSION['blackwall_oauth_state'] = 'state-1'; + $_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123'; + + $this->http->queuePost([ + 'status' => 200, + 'body' => json_encode([ + 'access_token' => 'access-1', + 'refresh_token' => 'refresh-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('blackwall_oauth_state', $_SESSION); + self::assertArrayNotHasKey('blackwall_oauth_code_verifier', $_SESSION); + } + + public function testHandleCallbackThrowsOnMissingParams(): void + { + $this->expectException(UserInfoException::class); + $this->client->handleCallback(['state' => 'only-state']); + } } diff --git a/tests/UserInfoNormalizerTest.php b/tests/UserInfoNormalizerTest.php new file mode 100644 index 0000000..340bd02 --- /dev/null +++ b/tests/UserInfoNormalizerTest.php @@ -0,0 +1,33 @@ + 'User@Example.com', + 'claims' => [ + 'role' => 'admin', + ], + ]); + + self::assertSame('user@example.com', $normalized->email); + self::assertSame(1, $normalized->privilegeLevel); + self::assertSame('admin', $normalized->role); + } + + public function testThrowsWhenEmailMissing(): void + { + $this->expectException(UserInfoException::class); + UserInfoNormalizer::normalize(['sub' => '123']); + } +} +