Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b67bf574d | ||
|
|
fc80ee1659 | ||
|
|
1d62f0affd | ||
|
|
ddc08270a7 | ||
|
|
32208c7701 | ||
|
|
62544d86b4 | ||
|
|
35d58ff07b | ||
|
|
464524da27 | ||
|
|
8bb3c1ce12 | ||
|
|
bbcada1317 | ||
|
|
878e2b98e6 | ||
|
|
6cc2ddabd0 | ||
|
|
fb60a1eccb | ||
|
|
ebea82f63d | ||
|
|
3d3493e546 | ||
|
|
822b9b93c5 | ||
|
|
ba8868cc12 | ||
|
|
78606bf751 | ||
|
|
d97638201e | ||
|
|
df7ca1329e | ||
|
|
8b7f11fc6a | ||
|
|
cdef7da4b9 | ||
|
|
f51997016d | ||
|
|
49265eee0f | ||
|
|
4d612b57f4 | ||
|
|
f9323110ce | ||
|
|
12cefe68e8 | ||
|
|
cb0bd43d04 | ||
|
|
78a18ccfab | ||
|
|
7bb919a0a0 | ||
|
|
60f6989595 | ||
|
|
066a0b8fb7 | ||
|
|
d4a6418e5d | ||
|
|
7d0319332f | ||
|
|
44aa9e6f80 | ||
|
|
d8a28dea12 | ||
|
|
f529714f45 | ||
|
|
6ddbcaec70 | ||
|
|
3ff5b7aa26 | ||
|
|
94c85fc8d5 |
@@ -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
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ In the consuming application's `composer.json`:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"dixon/blackwall-auth-sdk": "*"
|
"andydixon/blackwall-auth-sdk": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -75,12 +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`.
|
||||||
|
For provider-side threat/invariant assumptions, see `docs/SECURITY_MODEL.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
|
||||||
|
|
||||||
@@ -98,6 +119,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 +129,33 @@ 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.
|
||||||
|
- Handle provider callback errors (`error`, `error_description`) explicitly; treat `access_denied` as an expected user/authorisation outcome rather than a transport failure.
|
||||||
|
- Treat `invalid_scope` as a client-configuration mismatch: requested scopes must be a subset of scopes allowed on the provider client.
|
||||||
|
- Expect `access_denied` even after the consent page is displayed if provider-side project membership or tenant scope changes before consent submission.
|
||||||
|
- Expect provider token introspection to return `active=false` when user/project/client state is no longer active.
|
||||||
|
- Expect revoked JWT access tokens to be rejected by provider `userinfo` even before token expiry.
|
||||||
|
- Register only HTTPS redirect URIs in provider client settings (HTTP should be used only for localhost loopback during development).
|
||||||
|
- Use OAuth/portal authentication endpoints for end users; provider admin login endpoints enforce separate admin scope checks.
|
||||||
|
- For provider admin enrolment URL export operations, use `POST` with CSRF protection; do not automate them via unauthenticated `GET` links.
|
||||||
|
- Handle provider CSV exports with formula-injection safety in mind if opening them in spreadsheet applications.
|
||||||
|
- Treat generated enrolment URLs as secrets and keep them out of URL query strings and request logs.
|
||||||
|
- For reverse-proxy deployments, harden trusted proxy/IP forwarding configuration to prevent spoofed client IP headers.
|
||||||
|
- Prefer narrowly scoped trusted-proxy entries (explicit IPs/CIDR ranges) instead of broad network trust.
|
||||||
|
- Keep client-side admin mutation retries idempotent; approval workflows can be lock-serialised and should not be assumed to execute twice.
|
||||||
|
- Do not submit parallel approve/reject/cancel decisions for the same approval request ID.
|
||||||
|
- Apply bounded CSV import sizes and row counts for admin bulk-user operations.
|
||||||
|
- Never place client secrets, refresh tokens, or access tokens in URL query strings.
|
||||||
|
- For OIDC providers, include a per-request `nonce` in authorisation requests.
|
||||||
|
- Ensure requested `scope` values in SDK config/examples (for example `offline_access`) are enabled on the provider-side client before rollout.
|
||||||
|
- Expect refresh-token exchange to fail (`invalid_grant`) if provider-side client scopes were tightened and the refresh token now carries disallowed scopes.
|
||||||
|
- For direct Cryptbin API usage, send `key_b64url` on unwrap calls; provider rejects key-mismatch unwrap attempts with `403 Forbidden`.
|
||||||
|
- For newly created Cryptbin items, continue using the creating WebAuthn credential for unwrap/update/delete flows.
|
||||||
- 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
|
||||||
|
|
||||||
|
|||||||
+78
-14
@@ -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,47 @@ $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'])) {
|
||||||
http_response_code(400);
|
$error = (string) $_GET['error'];
|
||||||
echo 'Missing code/state';
|
$description = isset($_GET['error_description']) ? (string) $_GET['error_description'] : 'Unknown provider error';
|
||||||
exit;
|
// access_denied is a normal authorisation outcome (for example, membership
|
||||||
|
// revoked, privilege changed, or user denied consent between request and submit).
|
||||||
|
// invalid_scope usually means the app requested scopes not enabled on the provider client.
|
||||||
|
http_response_code(in_array($error, ['access_denied', 'invalid_scope'], true) ? 403 : 400);
|
||||||
|
exit('Authorisation failed: ' . $description);
|
||||||
}
|
}
|
||||||
|
|
||||||
$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,14 +130,50 @@ 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);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`UserInfoException` with `invalid_token` can occur when provider-side subject scope is no longer active (for example user/project disabled) between token issuance and userinfo retrieval.
|
||||||
|
`TokenExchangeException` with `invalid_grant` can also occur when provider-side client/user/project state is inactive at exchange time.
|
||||||
|
Token introspection can return `active=false` when the token subject (user/project) or client has since been disabled.
|
||||||
|
Provider JWT access tokens are now revocation-aware at `userinfo`; revoked JWTs can fail before natural expiry.
|
||||||
|
Refresh-token exchange can return `invalid_grant` when provider client scope policy has been tightened and stored refresh scopes are no longer allowed.
|
||||||
|
Provider login/authorisation steps can also fail with `access_denied` when account status is disabled before completion.
|
||||||
|
WebAuthn-based login verification can return credential-style failures for disabled accounts even when authenticator assertions are otherwise valid.
|
||||||
|
Existing authenticated sessions may also be invalidated when account status changes to disabled.
|
||||||
|
OAuth authorisation consent submission can return `access_denied` if the account becomes inactive between session establishment and consent completion.
|
||||||
|
OAuth authorisation can also return `invalid_scope` if requested scopes are not explicitly allowed on the provider client configuration.
|
||||||
|
Provider user/project assignment views may exclude disabled projects even if historical assignments exist.
|
||||||
|
|
||||||
## Operational guidance
|
## Operational guidance
|
||||||
|
|
||||||
- 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.
|
||||||
|
- Expect `429 Too Many Requests` from provider control endpoints under abuse protection; implement backoff/retry instead of tight loops.
|
||||||
|
- Apply the same backoff strategy for provider `userinfo` calls that return `429 Too Many Requests`.
|
||||||
|
- For token exchange and refresh, apply bounded retry/backoff on transient provider failures instead of immediate repeated retries.
|
||||||
|
- Use exactly one client authentication method per token/control request (do not send both HTTP Basic and `client_secret` form fields together).
|
||||||
|
- If you call provider WebAuthn login challenge/verify endpoints directly, use `POST` only.
|
||||||
|
- Treat admin enrolment URL export as a state-changing operation: call it with `POST` and include a valid CSRF token.
|
||||||
|
- Treat provider CSV exports as untrusted input in spreadsheet tools; prefer importing into systems that neutralise formula cells.
|
||||||
|
- Treat enrolment URLs as sensitive bearer material: do not place them in redirect query parameters, logs, or analytics tags; keep them in one-time server-side flash/session state.
|
||||||
|
- If your deployment runs behind reverse proxies, ensure trusted-proxy configuration is strict so spoofed forwarding headers cannot bypass IP-based abuse controls.
|
||||||
|
- Trusted proxy allowlists may be expressed as explicit IPs or CIDR ranges; avoid broad ranges and keep proxy chains minimal.
|
||||||
|
- For approval-driven admin mutations, assume provider-side execution is serialised per approval item; client retries should still be idempotent and bounded.
|
||||||
|
- This serialisation applies across approval actions (approve/reject/cancel), so clients should avoid parallel decision submissions for the same request ID.
|
||||||
|
- For provider CSV-based user import operations, enforce strict file-size and row-count limits in automation tooling and surface clear operator errors on limit breaches.
|
||||||
|
- Configure OAuth client redirect URIs as HTTPS in production; reserve HTTP for localhost loopback testing only.
|
||||||
|
- Assume provider rate limits are enforced per source identity (for example IP) and shared across related auth endpoints.
|
||||||
|
- Treat report/export-style provider endpoints as high-cost and expect stricter rate limits.
|
||||||
- Rotate client secrets for confidential clients.
|
- Rotate client secrets for confidential clients.
|
||||||
|
- Do not transport secrets or tokens in URL query parameters; keep them in server-side session or secure storage only.
|
||||||
|
- Add a unique `nonce` to each OIDC authorisation request.
|
||||||
|
- Keep `state`, `nonce`, and PKCE values within standard URL-safe formats/lengths; malformed values can be rejected by provider validation.
|
||||||
- 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`.
|
||||||
|
- If you call Cryptbin unwrap APIs directly, include the original share key as `key_b64url`; unwrap now returns `403 Forbidden` when key proof does not match metadata verifier.
|
||||||
|
- New Cryptbin items may also bind unwrap/update/delete operations to the creating WebAuthn credential; use the same authenticator for follow-up operations.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ $user = $client->getUserInfo($tokens->accessToken);
|
|||||||
## Key differences
|
## Key differences
|
||||||
|
|
||||||
- Method spelling uses British English in the new API: `buildAuthorisationUrl()`.
|
- Method spelling uses British English in the new API: `buildAuthorisationUrl()`.
|
||||||
|
- The legacy method name `getAuthorizationUrl()` is unchanged for backwards compatibility.
|
||||||
- Token responses are strongly typed via `TokenSet`.
|
- Token responses are strongly typed via `TokenSet`.
|
||||||
- Errors are represented by specific exception classes.
|
- Errors are represented by specific exception classes.
|
||||||
- HTTP transport is swappable via `HttpClientInterface`.
|
- HTTP transport is swappable via `HttpClientInterface`.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# BlackWall Provider Security Model (SDK View)
|
||||||
|
|
||||||
|
Last reviewed: 2026-02-26
|
||||||
|
|
||||||
|
This document summarises provider-side security guarantees that SDK integrators should design around.
|
||||||
|
|
||||||
|
## Threat Assumptions
|
||||||
|
|
||||||
|
- Provider is internet-exposed.
|
||||||
|
- Attackers can register and attempt tenant-boundary abuse.
|
||||||
|
- OAuth/WebAuthn endpoints are abuse targets and may rate limit.
|
||||||
|
|
||||||
|
## Invariants Integrators Should Assume
|
||||||
|
|
||||||
|
1. Tenant/project/user/client scope is enforced server-side and can change between request steps.
|
||||||
|
2. OAuth authorize can fail with `invalid_scope` if requested scopes are not currently allowed for the client.
|
||||||
|
3. Token/refresh exchange can fail with `invalid_grant` when subject/client state or scope policy changes.
|
||||||
|
4. UserInfo can fail for revoked/invalid/inactive subjects even before nominal token expiry.
|
||||||
|
5. Admin and portal workflows are distinct scope domains; do not mix them in client UX flows.
|
||||||
|
|
||||||
|
## Integration Guidance
|
||||||
|
|
||||||
|
- Keep requested scopes minimal and aligned to the provider client configuration.
|
||||||
|
- Treat `access_denied`, `invalid_scope`, and `invalid_grant` as expected security outcomes, not transport errors.
|
||||||
|
- Build retries only for transient failures; do not blindly retry authz/authn denials.
|
||||||
|
- Keep enrolment URLs/tokens and OAuth tokens out of URLs, logs, and analytics tags.
|
||||||
|
- Use strict HTTPS and secure session-cookie handling in the relying party app.
|
||||||
+14
-18
@@ -16,6 +16,7 @@ foreach ($autoloadCandidates as $file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
use BlackWallSDK\BlackWallAuth;
|
use BlackWallSDK\BlackWallAuth;
|
||||||
|
use BlackWall\Auth\Exception\UserInfoException;
|
||||||
|
|
||||||
$auth = new BlackWallAuth([
|
$auth = new BlackWallAuth([
|
||||||
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
|
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
|
||||||
@@ -25,31 +26,26 @@ $auth = new BlackWallAuth([
|
|||||||
'redirectUri' => 'https://test.dixon.cx/callback.php',
|
'redirectUri' => 'https://test.dixon.cx/callback.php',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!isset($_GET['code'], $_GET['state'])) {
|
if (isset($_GET['error'])) {
|
||||||
http_response_code(400);
|
$error = (string) $_GET['error'];
|
||||||
echo 'Missing code/state';
|
$description = isset($_GET['error_description']) ? (string) $_GET['error_description'] : 'Authorisation was denied.';
|
||||||
exit;
|
$status = $error === 'access_denied' ? 403 : 400;
|
||||||
}
|
http_response_code($status);
|
||||||
|
echo 'Authorisation failed: ' . htmlspecialchars($description, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
if (isset($_SESSION['blackwall_oauth_state']) && $_GET['state'] !== $_SESSION['blackwall_oauth_state']) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo 'Invalid state';
|
|
||||||
exit;
|
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;
|
||||||
|
} catch (UserInfoException $e) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo 'User info access failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo 'Auth failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
echo 'Auth failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
|||||||
+51
-9
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 $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/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';
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user