Release 1.0.1: normalized userinfo and callback helper
This commit is contained in:
+51
-9
@@ -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);
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user