Release 1.0.1: normalized userinfo and callback helper

This commit is contained in:
Andy Dixon
2026-02-26 09:07:11 +00:00
parent 1c7b7b6717
commit 94c85fc8d5
12 changed files with 403 additions and 45 deletions
+51 -9
View File
@@ -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<string, mixed> $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<string, mixed> $userInfo
*/
public static function resolvePrivilegeLevel(array $userInfo): ?int
{
return UserInfoNormalizer::resolvePrivilegeLevel($userInfo);
}
/**
* @param array<string, mixed> $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);
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth;
final class CallbackResult
{
/**
* @param array<string, mixed> $rawUser
*/
public function __construct(
public readonly TokenSet $tokens,
public readonly UserInfo $user,
public readonly array $rawUser
) {
}
}
+13
View File
@@ -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;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth;
final class UserInfo
{
/**
* @param array<string, mixed> $raw
*/
public function __construct(
public readonly string $email,
public readonly ?int $privilegeLevel,
public readonly ?string $role,
public readonly array $raw
) {
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth;
use BlackWall\Auth\Exception\UserInfoException;
final class UserInfoNormalizer
{
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}
+37
View File
@@ -56,4 +56,41 @@ class BlackWallAuth
{
return $this->client->getUserInfo($accessToken);
}
/**
* @return array{email:string,privilege_level:?int,role:?string,raw:array<string,mixed>}
*/
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<string,mixed> $query
* @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
{
$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,
];
}
}
+3
View File
@@ -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';