3 Commits
16 changed files with 608 additions and 55 deletions
+27 -1
View File
@@ -7,6 +7,8 @@ This package provides:
- Code-for-token exchange - Code-for-token exchange
- Refresh token exchange - Refresh token exchange
- UserInfo retrieval - UserInfo retrieval
- UserInfo normalisation (`email`, `privilege_level`, `role`)
- Unified callback handling helper (`handleCallback`)
- Session helpers for `state` and `code_verifier` - Session helpers for `state` and `code_verifier`
- Typed API (`AuthClient`, `TokenSet`) and specific exception classes - Typed API (`AuthClient`, `TokenSet`) and specific exception classes
@@ -75,13 +77,31 @@ $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' => [
// Some providers require nonce for OIDC requests.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
header('Location: ' . $auth['url']); header('Location: ' . $auth['url']);
exit; exit;
``` ```
For callback handling, see `docs/INTEGRATION_GUIDE.md`. 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 ## Backwards compatibility
A wrapper class is retained for older integrations: A wrapper class is retained for older integrations:
@@ -98,6 +118,9 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
- `BlackWall\Auth\AuthClient` - `BlackWall\Auth\AuthClient`
- `BlackWall\Auth\TokenSet` - `BlackWall\Auth\TokenSet`
- `BlackWall\Auth\AuthResult` - `BlackWall\Auth\AuthResult`
- `BlackWall\Auth\CallbackResult`
- `BlackWall\Auth\UserInfo`
- `BlackWall\Auth\UserInfoNormalizer`
- `BlackWall\Auth\Http\HttpClientInterface` - `BlackWall\Auth\Http\HttpClientInterface`
- `BlackWall\Auth\Http\CurlHttpClient` - `BlackWall\Auth\Http\CurlHttpClient`
- `BlackWall\Auth\Exception\*` - `BlackWall\Auth\Exception\*`
@@ -105,10 +128,13 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
## Security notes ## Security notes
- Always validate OAuth `state` in callback handlers. - Always validate OAuth `state` in callback handlers.
- For OIDC providers, include a per-request `nonce` in authorisation requests.
- Always use HTTPS in production. - Always use HTTPS in production.
- Store refresh tokens securely. - Store refresh tokens securely.
- Keep access tokens out of logs and browser-visible output. - Keep access tokens out of logs and browser-visible output.
- Use short session lifetimes where possible. - Use short session lifetimes where possible.
- `Config::fromArray()` enforces HTTPS URLs by default.
- For localhost-only development over HTTP, set `allowInsecureHttp => true`.
## Repository layout ## Repository layout
+41 -13
View File
@@ -25,7 +25,15 @@ $client = new AuthClient(Config::fromArray([
])); ]));
if (!isset($_SESSION['user'])) { if (!isset($_SESSION['user'])) {
$auth = $client->buildAuthorisationUrl(); $auth = $client->buildAuthorisationUrl([
'extra' => [
// Some providers require nonce for OIDC requests.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
// Prevent stale redirects from being reused from cache.
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Location: ' . $auth['url']); header('Location: ' . $auth['url']);
exit; exit;
} }
@@ -54,27 +62,43 @@ $client = new AuthClient(Config::fromArray([
'redirectUri' => 'https://your-app.example/callback.php', 'redirectUri' => 'https://your-app.example/callback.php',
])); ]));
if (!isset($_GET['code'], $_GET['state'])) { if (isset($_GET['error'])) {
$description = isset($_GET['error_description']) ? (string) $_GET['error_description'] : 'Unknown provider error';
http_response_code(400); http_response_code(400);
echo 'Missing code/state'; exit('Authorization failed: ' . $description);
exit;
} }
$client->assertStateMatches((string) $_GET['state']); $result = $client->handleCallback($_GET);
$tokens = $client->exchangeCodeForTokens((string) $_GET['code']);
$user = $client->getUserInfo($tokens->accessToken);
$_SESSION['user'] = $user; $_SESSION['user'] = [
$_SESSION['access_token'] = $tokens->accessToken; 'email' => $result->user->email,
$_SESSION['refresh_token'] = $tokens->refreshToken; 'privilege_level' => $result->user->privilegeLevel,
'role' => $result->user->role,
$client->clearPkceSessionState(); ];
$_SESSION['access_token'] = $result->tokens->accessToken;
$_SESSION['refresh_token'] = $result->tokens->refreshToken;
header('Location: /'); header('Location: /');
exit; 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 ```php
<?php <?php
@@ -102,6 +126,7 @@ try {
$tokens = $client->exchangeCodeForTokens($code); $tokens = $client->exchangeCodeForTokens($code);
} catch (\BlackWall\Auth\Exception\TokenExchangeException $e) { } catch (\BlackWall\Auth\Exception\TokenExchangeException $e) {
error_log('Token exchange failed: ' . $e->getMessage()); error_log('Token exchange failed: ' . $e->getMessage());
error_log('Auth code: ' . ($e->authCode() ?? 'none'));
http_response_code(502); http_response_code(502);
} }
``` ```
@@ -111,5 +136,8 @@ try {
- Keep provider URLs in environment variables, not hard-coded. - Keep provider URLs in environment variables, not hard-coded.
- Avoid printing tokens in production pages. - Avoid printing tokens in production pages.
- Rotate client secrets for confidential clients. - Rotate client secrets for confidential clients.
- Add a unique `nonce` to each OIDC authorisation request.
- 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).
- HTTPS URLs are enforced by default in `Config::fromArray()`.
- For localhost-only HTTP testing, explicitly set `allowInsecureHttp => true`.
+4 -21
View File
@@ -25,28 +25,11 @@ $auth = new BlackWallAuth([
'redirectUri' => 'https://test.dixon.cx/callback.php', '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 { try {
$tokens = $auth->exchangeCodeForTokens($_GET['code']); $result = $auth->handleCallback($_GET);
$_SESSION['access_token'] = $tokens['access_token'] ?? null; $_SESSION['access_token'] = $result['tokens']['access_token'] ?? null;
$_SESSION['refresh_token'] = $tokens['refresh_token'] ?? null; $_SESSION['refresh_token'] = $result['tokens']['refresh_token'] ?? null;
$_SESSION['user'] = $result['raw_user'] ?? 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']);
header('Location: /'); header('Location: /');
exit; exit;
+51 -9
View File
@@ -60,7 +60,7 @@ final class AuthClient
{ {
$sessionState = $_SESSION['blackwall_oauth_state'] ?? null; $sessionState = $_SESSION['blackwall_oauth_state'] ?? 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'); 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); $verifier = $codeVerifier ?? ($_SESSION['blackwall_oauth_code_verifier'] ?? 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.'); throw new TokenExchangeException('Missing code verifier; pass one explicitly or persist it in session.', 'missing_code_verifier');
} }
$payload = [ $payload = [
@@ -88,11 +88,11 @@ final class AuthClient
if ($result['status'] >= 400) { if ($result['status'] >= 400) {
$message = is_array($data) ? json_encode($data) : $result['body']; $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)) { 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); return TokenSet::fromArray($data);
@@ -115,11 +115,11 @@ final class AuthClient
if ($result['status'] >= 400) { if ($result['status'] >= 400) {
$message = is_array($data) ? json_encode($data) : $result['body']; $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)) { 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); return TokenSet::fromArray($data);
@@ -131,7 +131,7 @@ final class AuthClient
public function getUserInfo(string $accessToken): array public function getUserInfo(string $accessToken): array
{ {
if ($this->config->userInfoUrl === null || $this->config->userInfoUrl === '') { 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, [ $result = $this->httpClient->get($this->config->userInfoUrl, [
@@ -141,16 +141,58 @@ final class AuthClient
$data = json_decode($result['body'], true); $data = json_decode($result['body'], true);
if ($result['status'] >= 400) { if ($result['status'] >= 400) {
$message = is_array($data) ? json_encode($data) : $result['body']; $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)) { 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; 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 public function exchangeCodeAndFetchUser(string $code, ?string $codeVerifier = null): AuthResult
{ {
$tokens = $this->exchangeCodeForTokens($code, $codeVerifier); $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
) {
}
}
+50 -6
View File
@@ -13,7 +13,8 @@ final class Config
public readonly string $redirectUri, public readonly string $redirectUri,
public readonly ?string $userInfoUrl = null, public readonly ?string $userInfoUrl = null,
public readonly ?string $clientSecret = null, public readonly ?string $clientSecret = null,
public readonly string $defaultScope = 'openid profile email' public readonly string $defaultScope = 'openid profile email',
public readonly bool $allowInsecureHttp = false
) { ) {
} }
@@ -28,14 +29,57 @@ final class Config
} }
} }
$allowInsecureHttp = isset($config['allowInsecureHttp']) && (bool) $config['allowInsecureHttp'];
$authorizeUrl = rtrim(trim((string) $config['authorizeUrl']), '/');
$tokenUrl = rtrim(trim((string) $config['tokenUrl']), '/');
$redirectUri = trim((string) $config['redirectUri']);
$userInfoUrl = isset($config['userInfoUrl']) ? rtrim(trim((string) $config['userInfoUrl']), '/') : null;
self::assertSecureUrl($authorizeUrl, 'authorizeUrl', $allowInsecureHttp);
self::assertSecureUrl($tokenUrl, 'tokenUrl', $allowInsecureHttp);
self::assertSecureUrl($redirectUri, 'redirectUri', $allowInsecureHttp);
if ($userInfoUrl !== null && $userInfoUrl !== '') {
self::assertSecureUrl($userInfoUrl, 'userInfoUrl', $allowInsecureHttp);
}
return new self( return new self(
clientId: $config['clientId'], clientId: $config['clientId'],
authorizeUrl: rtrim($config['authorizeUrl'], '/'), authorizeUrl: $authorizeUrl,
tokenUrl: rtrim($config['tokenUrl'], '/'), tokenUrl: $tokenUrl,
redirectUri: $config['redirectUri'], redirectUri: $redirectUri,
userInfoUrl: isset($config['userInfoUrl']) ? rtrim((string) $config['userInfoUrl'], '/') : null, userInfoUrl: $userInfoUrl,
clientSecret: isset($config['clientSecret']) ? (string) $config['clientSecret'] : null, clientSecret: isset($config['clientSecret']) ? (string) $config['clientSecret'] : null,
defaultScope: isset($config['scope']) ? (string) $config['scope'] : 'openid profile email' defaultScope: isset($config['scope']) ? (string) $config['scope'] : 'openid profile email',
allowInsecureHttp: $allowInsecureHttp
); );
} }
private static function assertSecureUrl(string $url, string $field, bool $allowInsecureHttp): void
{
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme'])) {
throw new \InvalidArgumentException("{$field} must be a valid absolute URL");
}
$scheme = strtolower((string) $parts['scheme']);
if (!in_array($scheme, ['https', 'http'], true)) {
throw new \InvalidArgumentException("{$field} must use http or https");
}
if ($scheme === 'https') {
return;
}
if ($allowInsecureHttp && self::isLocalHost((string) ($parts['host'] ?? ''))) {
return;
}
throw new \InvalidArgumentException("{$field} must use https (set allowInsecureHttp=true for localhost development)");
}
private static function isLocalHost(string $host): bool
{
$normalized = strtolower(trim($host));
return in_array($normalized, ['localhost', '127.0.0.1', '::1'], true);
}
} }
+13
View File
@@ -6,4 +6,17 @@ namespace BlackWall\Auth\Exception;
class AuthException extends \RuntimeException 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;
}
} }
+61 -5
View File
@@ -8,15 +8,19 @@ use BlackWall\Auth\Exception\TransportException;
final class CurlHttpClient implements HttpClientInterface final class CurlHttpClient implements HttpClientInterface
{ {
public function __construct(private readonly int $timeoutSeconds = 20) public function __construct(
private readonly int $timeoutSeconds = 20,
private readonly int $connectTimeoutSeconds = 10
)
{ {
} }
public function postForm(string $url, array $fields, array $headers = []): array public function postForm(string $url, array $fields, array $headers = []): array
{ {
$this->assertValidUrl($url);
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
throw new TransportException('Unable to initialise cURL for POST request'); throw new TransportException('Unable to initialise cURL for POST request', 'curl_init_failed');
} }
$headerLines = $this->normaliseHeaders(array_merge([ $headerLines = $this->normaliseHeaders(array_merge([
@@ -29,16 +33,31 @@ final class CurlHttpClient implements HttpClientInterface
CURLOPT_POSTFIELDS => http_build_query($fields, '', '&', PHP_QUERY_RFC3986), CURLOPT_POSTFIELDS => http_build_query($fields, '', '&', PHP_QUERY_RFC3986),
CURLOPT_HTTPHEADER => $headerLines, CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeoutSeconds,
CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
]); ]);
if (defined('CURLOPT_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
if (defined('CURLOPT_REDIR_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
$body = curl_exec($ch); $body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
if ($body === false) { if ($body === false) {
throw new TransportException($error !== '' ? $error : 'Unknown cURL POST error'); throw new TransportException(
$error !== '' ? $error : 'Unknown cURL POST error',
$errno > 0 ? ('curl_errno_' . $errno) : 'curl_post_failed'
);
} }
return ['status' => $status, 'body' => $body]; return ['status' => $status, 'body' => $body];
@@ -46,9 +65,10 @@ final class CurlHttpClient implements HttpClientInterface
public function get(string $url, array $headers = []): array public function get(string $url, array $headers = []): array
{ {
$this->assertValidUrl($url);
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
throw new TransportException('Unable to initialise cURL for GET request'); throw new TransportException('Unable to initialise cURL for GET request', 'curl_init_failed');
} }
$headerLines = $this->normaliseHeaders(array_merge([ $headerLines = $this->normaliseHeaders(array_merge([
@@ -58,16 +78,31 @@ final class CurlHttpClient implements HttpClientInterface
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headerLines, CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeoutSeconds,
CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
]); ]);
if (defined('CURLOPT_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
if (defined('CURLOPT_REDIR_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
$body = curl_exec($ch); $body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
if ($body === false) { if ($body === false) {
throw new TransportException($error !== '' ? $error : 'Unknown cURL GET error'); throw new TransportException(
$error !== '' ? $error : 'Unknown cURL GET error',
$errno > 0 ? ('curl_errno_' . $errno) : 'curl_get_failed'
);
} }
return ['status' => $status, 'body' => $body]; return ['status' => $status, 'body' => $body];
@@ -81,9 +116,30 @@ final class CurlHttpClient implements HttpClientInterface
{ {
$result = []; $result = [];
foreach ($headers as $name => $value) { foreach ($headers as $name => $value) {
if (
str_contains((string) $name, "\r")
|| str_contains((string) $name, "\n")
|| str_contains((string) $value, "\r")
|| str_contains((string) $value, "\n")
) {
throw new TransportException('Header names/values must not contain CR/LF characters', 'invalid_header');
}
$result[] = sprintf('%s: %s', $name, $value); $result[] = sprintf('%s: %s', $name, $value);
} }
return $result; return $result;
} }
private function assertValidUrl(string $url): void
{
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme'])) {
throw new TransportException('Request URL must be an absolute URL', 'invalid_url');
}
$scheme = strtolower((string) $parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
throw new TransportException('Only http/https URLs are supported', 'invalid_url_scheme');
}
}
} }
+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 $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/Config.php';
require_once __DIR__ . '/Auth/TokenSet.php'; require_once __DIR__ . '/Auth/TokenSet.php';
require_once __DIR__ . '/Auth/AuthResult.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/HttpClientInterface.php';
require_once __DIR__ . '/Auth/Http/CurlHttpClient.php'; require_once __DIR__ . '/Auth/Http/CurlHttpClient.php';
require_once __DIR__ . '/Auth/Exception/AuthException.php'; require_once __DIR__ . '/Auth/Exception/AuthException.php';
+60
View File
@@ -147,4 +147,64 @@ final class AuthClientTest extends TestCase
$this->expectExceptionMessage('UserInfo endpoint error (401)'); $this->expectExceptionMessage('UserInfo endpoint error (401)');
$this->client->getUserInfo('expired-token'); $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']);
}
} }
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Tests;
use BlackWall\Auth\Config;
use PHPUnit\Framework\TestCase;
final class ConfigSecurityTest extends TestCase
{
public function testRejectsInsecureHttpForNonLocalhostByDefault(): void
{
$this->expectException(\InvalidArgumentException::class);
Config::fromArray([
'clientId' => 'client-123',
'authorizeUrl' => 'http://provider.example/oauth/authorize',
'tokenUrl' => 'https://provider.example/oauth/token',
'userInfoUrl' => 'https://provider.example/oauth/userinfo',
'redirectUri' => 'https://app.example/callback.php',
]);
}
public function testAllowsInsecureHttpForLocalhostWhenExplicitlyEnabled(): void
{
$config = Config::fromArray([
'clientId' => 'client-123',
'authorizeUrl' => 'http://localhost/oauth/authorize',
'tokenUrl' => 'http://127.0.0.1/oauth/token',
'userInfoUrl' => 'http://localhost/oauth/userinfo',
'redirectUri' => 'http://localhost/callback.php',
'allowInsecureHttp' => true,
]);
self::assertTrue($config->allowInsecureHttp);
self::assertSame('http://localhost/oauth/authorize', $config->authorizeUrl);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Tests;
use BlackWall\Auth\Exception\TransportException;
use BlackWall\Auth\Http\CurlHttpClient;
use PHPUnit\Framework\TestCase;
final class CurlHttpClientSecurityTest extends TestCase
{
public function testRejectsUnsupportedUrlScheme(): void
{
$client = new CurlHttpClient();
$this->expectException(TransportException::class);
$client->get('file:///etc/passwd');
}
public function testRejectsHeaderInjectionAttempt(): void
{
$client = new CurlHttpClient();
$this->expectException(TransportException::class);
$client->get('https://example.com', [
'X-Test' => "ok\r\nInjected: yes",
]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Tests;
use BlackWall\Auth\UserInfoNormalizer;
use BlackWall\Auth\Exception\UserInfoException;
use PHPUnit\Framework\TestCase;
final class UserInfoNormalizerTest extends TestCase
{
public function testNormalizesNestedClaimsAndRoleString(): void
{
$normalized = UserInfoNormalizer::normalize([
'email' => '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']);
}
}