Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d287e01b2a | ||
|
|
b2163ad996 | ||
|
|
1d8578c1da | ||
|
|
986dc95646 | ||
|
|
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 |
@@ -0,0 +1,2 @@
|
||||
vendor
|
||||
.phpunit.cache
|
||||
@@ -7,7 +7,10 @@ This package provides:
|
||||
- Code-for-token exchange
|
||||
- Refresh token exchange
|
||||
- UserInfo retrieval
|
||||
- Session helpers for `state` and `code_verifier`
|
||||
- UserInfo normalisation (`email`, `privilege_level`, `role`)
|
||||
- Unified callback handling helper (`handleCallback`)
|
||||
- First-class OIDC nonce generation, session persistence, and validation helpers
|
||||
- Session helpers for `state`, `code_verifier`, and `nonce`
|
||||
- Typed API (`AuthClient`, `TokenSet`) and specific exception classes
|
||||
|
||||
## Requirements
|
||||
@@ -38,7 +41,7 @@ In the consuming application's `composer.json`:
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"dixon/blackwall-auth-sdk": "*"
|
||||
"andydixon/blackwall-auth-sdk": "*"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -80,7 +83,68 @@ header('Location: ' . $auth['url']);
|
||||
exit;
|
||||
```
|
||||
|
||||
When the requested scope includes `openid`, `buildAuthorisationUrl()` generates a URL-safe nonce automatically, stores it in `$_SESSION['blackwall_oidc_nonce']` when session persistence is enabled, and includes it in the authorisation request.
|
||||
|
||||
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, true, [
|
||||
'expected_nonce' => $_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null,
|
||||
]);
|
||||
|
||||
$_SESSION['user'] = [
|
||||
'email' => $result->user->email,
|
||||
'privilege_level' => $result->user->privilegeLevel,
|
||||
'role' => $result->user->role,
|
||||
];
|
||||
$_SESSION['access_token'] = $result->tokens->accessToken;
|
||||
```
|
||||
|
||||
## OIDC nonce handling
|
||||
|
||||
Use `nonce` for OpenID Connect authorisation requests. `state` protects the browser redirect flow against CSRF and mix-up issues. `nonce` binds the returned `id_token` to the login request that started in the user's session.
|
||||
|
||||
The SDK now treats nonce as a first-class value:
|
||||
|
||||
- `buildAuthorisationUrl()` generates a secure nonce automatically when the scope contains `openid`.
|
||||
- Pass `'nonce' => 'your-value'` if you need to supply your own nonce.
|
||||
- With default persistence enabled, the SDK stores:
|
||||
- `$_SESSION['blackwall_oauth_state']`
|
||||
- `$_SESSION['blackwall_oauth_code_verifier']`
|
||||
- `$_SESSION['blackwall_oidc_nonce']`
|
||||
- The returned array from `buildAuthorisationUrl()` now includes `'nonce' => ?string`.
|
||||
- `assertNonceMatches()` checks a nonce against the stored session value.
|
||||
- `assertIdTokenNonceMatches()` decodes the JWT payload claims and validates the `nonce` claim against an explicit or session-backed expected nonce.
|
||||
- `handleCallback()` will validate nonce automatically when both of these are true:
|
||||
- the token response contains an `id_token`
|
||||
- an expected nonce is available explicitly or in session
|
||||
|
||||
If the provider does not return an `id_token`, callback handling remains graceful and does not fail purely because a nonce was stored. If an `id_token` is returned but the `nonce` claim is missing or mismatched, the SDK throws `BlackWall\Auth\Exception\NonceMismatchException`.
|
||||
|
||||
Compatibility mode: if you have a temporary deployment constraint and cannot enforce nonce immediately, call `handleCallback($_GET, true, ['validate_nonce' => false])`. Treat this as a short-lived compatibility setting only.
|
||||
|
||||
If your provider issues an `id_token` but omits the `nonce` claim, the SDK will accept missing nonce values by default while still enforcing a match when one is present. You can also set it explicitly:
|
||||
|
||||
```php
|
||||
$result = $client->handleCallback($_GET, true, [
|
||||
'allow_missing_nonce' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
Use this only when you have verified the provider cannot include `nonce`. It weakens replay protection for those tokens.
|
||||
|
||||
### JWT payload decoding helper
|
||||
|
||||
Use `decodeJwtPayloadClaims()` when you need to inspect an `id_token` payload:
|
||||
|
||||
```php
|
||||
$claims = $client->decodeJwtPayloadClaims($result->tokens->idToken);
|
||||
```
|
||||
|
||||
This helper only decodes the JWT payload. It does not verify the token signature, issuer, audience, expiry, or authorised token use. Nonce matching is still important, and complete ID token verification may require provider JWK handling and additional OIDC checks outside the current SDK scope.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
@@ -98,6 +162,9 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
|
||||
- `BlackWall\Auth\AuthClient`
|
||||
- `BlackWall\Auth\TokenSet`
|
||||
- `BlackWall\Auth\AuthResult`
|
||||
- `BlackWall\Auth\CallbackResult`
|
||||
- `BlackWall\Auth\UserInfo`
|
||||
- `BlackWall\Auth\UserInfoNormalizer`
|
||||
- `BlackWall\Auth\Http\HttpClientInterface`
|
||||
- `BlackWall\Auth\Http\CurlHttpClient`
|
||||
- `BlackWall\Auth\Exception\*`
|
||||
@@ -105,10 +172,33 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
|
||||
## Security notes
|
||||
|
||||
- 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 and validate it when an `id_token` is returned.
|
||||
- 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.
|
||||
- Store refresh tokens securely.
|
||||
- Keep access tokens out of logs and browser-visible output.
|
||||
- Use short session lifetimes where possible.
|
||||
- `Config::fromArray()` enforces HTTPS URLs by default.
|
||||
- For localhost-only development over HTTP, set `allowInsecureHttp => true`.
|
||||
|
||||
## Repository layout
|
||||
|
||||
|
||||
+137
-13
@@ -26,6 +26,9 @@ $client = new AuthClient(Config::fromArray([
|
||||
|
||||
if (!isset($_SESSION['user'])) {
|
||||
$auth = $client->buildAuthorisationUrl();
|
||||
// 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']);
|
||||
exit;
|
||||
}
|
||||
@@ -33,6 +36,8 @@ if (!isset($_SESSION['user'])) {
|
||||
echo 'Already signed in.';
|
||||
```
|
||||
|
||||
When the requested scope contains `openid`, the SDK generates a secure nonce, persists it in `$_SESSION['blackwall_oidc_nonce']`, and includes `nonce=...` in the provider authorisation URL together with `state`, `code_challenge`, and the rest of the OAuth/OIDC query parameters.
|
||||
|
||||
## 2. Handle callback
|
||||
|
||||
```php
|
||||
@@ -54,27 +59,109 @@ $client = new AuthClient(Config::fromArray([
|
||||
'redirectUri' => 'https://your-app.example/callback.php',
|
||||
]));
|
||||
|
||||
if (!isset($_GET['code'], $_GET['state'])) {
|
||||
http_response_code(400);
|
||||
echo 'Missing code/state';
|
||||
exit;
|
||||
if (isset($_GET['error'])) {
|
||||
$error = (string) $_GET['error'];
|
||||
$description = isset($_GET['error_description']) ? (string) $_GET['error_description'] : 'Unknown provider error';
|
||||
// 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']);
|
||||
$tokens = $client->exchangeCodeForTokens((string) $_GET['code']);
|
||||
$user = $client->getUserInfo($tokens->accessToken);
|
||||
$result = $client->handleCallback($_GET, true, [
|
||||
'expected_nonce' => $_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null,
|
||||
]);
|
||||
|
||||
$_SESSION['user'] = $user;
|
||||
$_SESSION['access_token'] = $tokens->accessToken;
|
||||
$_SESSION['refresh_token'] = $tokens->refreshToken;
|
||||
|
||||
$client->clearPkceSessionState();
|
||||
$_SESSION['user'] = [
|
||||
'email' => $result->user->email,
|
||||
'privilege_level' => $result->user->privilegeLevel,
|
||||
'role' => $result->user->role,
|
||||
];
|
||||
$_SESSION['access_token'] = $result->tokens->accessToken;
|
||||
$_SESSION['refresh_token'] = $result->tokens->refreshToken;
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
```
|
||||
|
||||
## 3. Refresh token (optional)
|
||||
If the provider returns an `id_token`, `handleCallback()` decodes the payload claims and checks the `nonce` claim against the expected value. If no `id_token` is returned, the callback continues normally. If the `nonce` claim is missing or mismatched, the SDK throws `BlackWall\Auth\Exception\NonceMismatchException`.
|
||||
|
||||
## OIDC nonce handling
|
||||
|
||||
Use `state` and `nonce` for different jobs:
|
||||
|
||||
- `state` protects the front-channel redirect and callback correlation.
|
||||
- `nonce` protects the OpenID Connect authentication result carried by `id_token`.
|
||||
|
||||
The SDK stores the following session keys when persistence is enabled:
|
||||
|
||||
- `blackwall_oauth_state`
|
||||
- `blackwall_oauth_code_verifier`
|
||||
- `blackwall_oidc_nonce`
|
||||
|
||||
If you need to supply your own nonce, pass it directly:
|
||||
|
||||
```php
|
||||
$auth = $client->buildAuthorisationUrl([
|
||||
'nonce' => 'your-application-generated-nonce',
|
||||
]);
|
||||
```
|
||||
|
||||
You can validate an `id_token` nonce directly:
|
||||
|
||||
```php
|
||||
$claims = $client->assertIdTokenNonceMatches(
|
||||
$result->tokens->idToken,
|
||||
$_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null
|
||||
);
|
||||
```
|
||||
|
||||
You can also inspect JWT payload claims when debugging or doing application-level checks:
|
||||
|
||||
```php
|
||||
$claims = $client->decodeJwtPayloadClaims($result->tokens->idToken);
|
||||
```
|
||||
|
||||
This helper only decodes the JWT payload. It does not verify signature, issuer, audience, expiry, or token use. Full ID token verification may require JWK retrieval and additional OpenID Connect checks outside the current SDK scope.
|
||||
|
||||
If a deployment cannot yet enforce nonce validation strictly, a temporary compatibility mode is available:
|
||||
|
||||
```php
|
||||
$result = $client->handleCallback($_GET, true, [
|
||||
'validate_nonce' => false,
|
||||
]);
|
||||
```
|
||||
|
||||
Use this only as a short-term bridge while the application is updated to persist and enforce nonce consistently.
|
||||
|
||||
If your provider returns an `id_token` without the `nonce` claim, the SDK will allow missing nonce values by default while still enforcing a match when one is present. You can also set it explicitly:
|
||||
|
||||
```php
|
||||
$result = $client->handleCallback($_GET, true, [
|
||||
'allow_missing_nonce' => true,
|
||||
]);
|
||||
```
|
||||
|
||||
Use this only after confirming the provider cannot include `nonce`, because it weakens replay protection for those tokens.
|
||||
|
||||
## 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
|
||||
@@ -91,6 +178,7 @@ if ($tokens->refreshToken !== null) {
|
||||
Catch specific exceptions for clearer handling:
|
||||
|
||||
- `BlackWall\Auth\Exception\StateMismatchException`
|
||||
- `BlackWall\Auth\Exception\NonceMismatchException`
|
||||
- `BlackWall\Auth\Exception\TokenExchangeException`
|
||||
- `BlackWall\Auth\Exception\UserInfoException`
|
||||
- `BlackWall\Auth\Exception\TransportException`
|
||||
@@ -102,14 +190,50 @@ try {
|
||||
$tokens = $client->exchangeCodeForTokens($code);
|
||||
} catch (\BlackWall\Auth\Exception\TokenExchangeException $e) {
|
||||
error_log('Token exchange failed: ' . $e->getMessage());
|
||||
error_log('Auth code: ' . ($e->authCode() ?? 'none'));
|
||||
http_response_code(502);
|
||||
}
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
- Keep provider URLs in environment variables, not hard-coded.
|
||||
- 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.
|
||||
- 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 and validate it against the `id_token` when one is returned.
|
||||
- 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.
|
||||
- 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,9 +26,11 @@ $user = $client->getUserInfo($tokens->accessToken);
|
||||
## Key differences
|
||||
|
||||
- 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`.
|
||||
- Errors are represented by specific exception classes.
|
||||
- HTTP transport is swappable via `HttpClientInterface`.
|
||||
- OIDC nonce handling is now first-class: `buildAuthorisationUrl()` returns `nonce`, persists it in session by default, and `handleCallback()` can validate it against the returned `id_token`.
|
||||
|
||||
## Compatibility status
|
||||
|
||||
|
||||
@@ -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.
|
||||
+21
-18
@@ -16,6 +16,9 @@ foreach ($autoloadCandidates as $file) {
|
||||
}
|
||||
|
||||
use BlackWallSDK\BlackWallAuth;
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Exception\NonceMismatchException;
|
||||
use BlackWall\Auth\Exception\UserInfoException;
|
||||
|
||||
$auth = new BlackWallAuth([
|
||||
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
|
||||
@@ -25,31 +28,31 @@ $auth = new BlackWallAuth([
|
||||
'redirectUri' => 'https://test.dixon.cx/callback.php',
|
||||
]);
|
||||
|
||||
if (!isset($_GET['code'], $_GET['state'])) {
|
||||
http_response_code(400);
|
||||
echo 'Missing code/state';
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_SESSION['blackwall_oauth_state']) && $_GET['state'] !== $_SESSION['blackwall_oauth_state']) {
|
||||
http_response_code(400);
|
||||
echo 'Invalid state';
|
||||
if (isset($_GET['error'])) {
|
||||
$error = (string) $_GET['error'];
|
||||
$description = isset($_GET['error_description']) ? (string) $_GET['error_description'] : 'Authorisation was denied.';
|
||||
$status = $error === 'access_denied' ? 403 : 400;
|
||||
http_response_code($status);
|
||||
echo 'Authorisation failed: ' . htmlspecialchars($description, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$tokens = $auth->exchangeCodeForTokens($_GET['code']);
|
||||
$_SESSION['access_token'] = $tokens['access_token'] ?? null;
|
||||
$_SESSION['refresh_token'] = $tokens['refresh_token'] ?? null;
|
||||
|
||||
if (isset($_SESSION['access_token']) && is_string($_SESSION['access_token'])) {
|
||||
$_SESSION['user'] = $auth->getUserInfo($_SESSION['access_token']);
|
||||
}
|
||||
|
||||
unset($_SESSION['blackwall_oauth_state'], $_SESSION['blackwall_oauth_code_verifier']);
|
||||
$result = $auth->handleCallback($_GET, true, [
|
||||
'expected_nonce' => $_SESSION[AuthClient::NONCE_SESSION_KEY] ?? null,
|
||||
]);
|
||||
$_SESSION['access_token'] = $result['tokens']['access_token'] ?? null;
|
||||
$_SESSION['refresh_token'] = $result['tokens']['refresh_token'] ?? null;
|
||||
$_SESSION['user'] = $result['raw_user'] ?? null;
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
} catch (NonceMismatchException $e) {
|
||||
http_response_code(403);
|
||||
echo 'Sign-in failed: the OpenID Connect nonce check did not pass. Please try signing in again.';
|
||||
} catch (UserInfoException $e) {
|
||||
http_response_code(403);
|
||||
echo 'User info access failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo 'Auth failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
@@ -16,6 +16,7 @@ foreach ($autoloadCandidates as $file) {
|
||||
}
|
||||
|
||||
use BlackWallSDK\BlackWallAuth;
|
||||
use BlackWall\Auth\AuthClient;
|
||||
|
||||
$auth = new BlackWallAuth([
|
||||
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
|
||||
@@ -31,8 +32,9 @@ if (isset($_GET['logout'])) {
|
||||
$_SESSION['access_token'],
|
||||
$_SESSION['refresh_token'],
|
||||
$_SESSION['user'],
|
||||
$_SESSION['blackwall_oauth_state'],
|
||||
$_SESSION['blackwall_oauth_code_verifier']
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY],
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY],
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY]
|
||||
);
|
||||
header('Location: /');
|
||||
exit;
|
||||
|
||||
+198
-15
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth;
|
||||
|
||||
use BlackWall\Auth\Exception\NonceMismatchException;
|
||||
use BlackWall\Auth\Exception\AuthException;
|
||||
use BlackWall\Auth\Exception\StateMismatchException;
|
||||
use BlackWall\Auth\Exception\TokenExchangeException;
|
||||
use BlackWall\Auth\Exception\UserInfoException;
|
||||
@@ -12,6 +14,10 @@ use BlackWall\Auth\Http\HttpClientInterface;
|
||||
|
||||
final class AuthClient
|
||||
{
|
||||
public const STATE_SESSION_KEY = 'blackwall_oauth_state';
|
||||
public const CODE_VERIFIER_SESSION_KEY = 'blackwall_oauth_code_verifier';
|
||||
public const NONCE_SESSION_KEY = 'blackwall_oidc_nonce';
|
||||
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly HttpClientInterface $httpClient = new CurlHttpClient()
|
||||
@@ -20,7 +26,7 @@ final class AuthClient
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{url:string,state:string,code_verifier:string,code_challenge:string}
|
||||
* @return array{url:string,state:string,code_verifier:string,code_challenge:string,nonce:?string}
|
||||
*/
|
||||
public function buildAuthorisationUrl(array $options = []): array
|
||||
{
|
||||
@@ -30,10 +36,16 @@ final class AuthClient
|
||||
$scope = isset($options['scope']) ? (string) $options['scope'] : $this->config->defaultScope;
|
||||
$extra = isset($options['extra']) && is_array($options['extra']) ? $options['extra'] : [];
|
||||
$persist = !isset($options['persist']) || (bool) $options['persist'];
|
||||
$nonce = $this->resolveNonce($scope, $options, $extra);
|
||||
|
||||
if ($persist && session_status() === PHP_SESSION_ACTIVE) {
|
||||
$_SESSION['blackwall_oauth_state'] = $state;
|
||||
$_SESSION['blackwall_oauth_code_verifier'] = $codeVerifier;
|
||||
$_SESSION[self::STATE_SESSION_KEY] = $state;
|
||||
$_SESSION[self::CODE_VERIFIER_SESSION_KEY] = $codeVerifier;
|
||||
if ($nonce !== null) {
|
||||
$_SESSION[self::NONCE_SESSION_KEY] = $nonce;
|
||||
} else {
|
||||
unset($_SESSION[self::NONCE_SESSION_KEY]);
|
||||
}
|
||||
}
|
||||
|
||||
$query = array_merge([
|
||||
@@ -45,6 +57,9 @@ final class AuthClient
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
], $extra);
|
||||
if ($nonce !== null) {
|
||||
$query['nonce'] = $nonce;
|
||||
}
|
||||
|
||||
$url = $this->config->authorizeUrl . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
@@ -53,22 +68,31 @@ final class AuthClient
|
||||
'state' => $state,
|
||||
'code_verifier' => $codeVerifier,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'nonce' => $nonce,
|
||||
];
|
||||
}
|
||||
|
||||
public function assertStateMatches(string $state): void
|
||||
{
|
||||
$sessionState = $_SESSION['blackwall_oauth_state'] ?? null;
|
||||
$sessionState = $_SESSION[self::STATE_SESSION_KEY] ?? 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');
|
||||
}
|
||||
}
|
||||
|
||||
public function assertNonceMatches(string $nonce): void
|
||||
{
|
||||
$sessionNonce = $_SESSION[self::NONCE_SESSION_KEY] ?? null;
|
||||
if (!is_string($sessionNonce) || $nonce !== $sessionNonce) {
|
||||
throw new NonceMismatchException('The OIDC nonce did not match the session value', 'nonce_mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
public function exchangeCodeForTokens(string $code, ?string $codeVerifier = null): TokenSet
|
||||
{
|
||||
$verifier = $codeVerifier ?? ($_SESSION['blackwall_oauth_code_verifier'] ?? null);
|
||||
$verifier = $codeVerifier ?? ($_SESSION[self::CODE_VERIFIER_SESSION_KEY] ?? 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 +112,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 +139,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 +155,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 +165,108 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes JWT payload claims only. This does not verify signature, issuer, audience, expiry, or token use.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function decodeJwtPayloadClaims(string $jwt): array
|
||||
{
|
||||
$parts = explode('.', $jwt);
|
||||
if (count($parts) !== 3) {
|
||||
throw new AuthException('JWT must contain exactly three segments', 'invalid_jwt');
|
||||
}
|
||||
|
||||
$payload = $this->base64UrlDecode($parts[1]);
|
||||
if ($payload === false) {
|
||||
throw new AuthException('JWT payload was not valid base64url', 'invalid_jwt');
|
||||
}
|
||||
|
||||
$claims = json_decode($payload, true);
|
||||
if (!is_array($claims)) {
|
||||
throw new AuthException('JWT payload was not valid JSON', 'invalid_jwt');
|
||||
}
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function assertIdTokenNonceMatches(string $idToken, ?string $expectedNonce = null): array
|
||||
{
|
||||
$claims = $this->decodeJwtPayloadClaims($idToken);
|
||||
$nonce = $claims['nonce'] ?? null;
|
||||
if (!is_string($nonce) || $nonce === '') {
|
||||
throw new NonceMismatchException('The ID token did not contain a nonce claim', 'nonce_missing');
|
||||
}
|
||||
|
||||
$expected = $expectedNonce ?? ($_SESSION[self::NONCE_SESSION_KEY] ?? null);
|
||||
if (!is_string($expected) || $expected === '') {
|
||||
throw new NonceMismatchException('Missing expected nonce; pass one explicitly or persist it in session.', 'missing_expected_nonce');
|
||||
}
|
||||
|
||||
if (!hash_equals($expected, $nonce)) {
|
||||
throw new NonceMismatchException('The ID token nonce did not match the expected value', 'nonce_mismatch');
|
||||
}
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options
|
||||
*/
|
||||
public function handleCallback(array $query, bool $clearPkce = true, array $options = []): 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']);
|
||||
$this->maybeValidateNonce($tokens, $options);
|
||||
$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);
|
||||
@@ -161,7 +277,7 @@ final class AuthClient
|
||||
|
||||
public function clearPkceSessionState(): void
|
||||
{
|
||||
unset($_SESSION['blackwall_oauth_state'], $_SESSION['blackwall_oauth_code_verifier']);
|
||||
unset($_SESSION[self::STATE_SESSION_KEY], $_SESSION[self::CODE_VERIFIER_SESSION_KEY], $_SESSION[self::NONCE_SESSION_KEY]);
|
||||
}
|
||||
|
||||
private function base64Url(string $binary): string
|
||||
@@ -169,6 +285,73 @@ final class AuthClient
|
||||
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $value): string|false
|
||||
{
|
||||
$padding = strlen($value) % 4;
|
||||
if ($padding > 0) {
|
||||
$value .= str_repeat('=', 4 - $padding);
|
||||
}
|
||||
|
||||
return base64_decode(strtr($value, '-_', '+/'), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private function resolveNonce(string $scope, array $options, array $extra): ?string
|
||||
{
|
||||
if (array_key_exists('nonce', $options)) {
|
||||
$nonce = $options['nonce'];
|
||||
return is_string($nonce) && $nonce !== '' ? $nonce : null;
|
||||
}
|
||||
|
||||
if (isset($extra['nonce']) && is_string($extra['nonce']) && $extra['nonce'] !== '') {
|
||||
return $extra['nonce'];
|
||||
}
|
||||
|
||||
return $this->scopeIncludesOpenId($scope) ? $this->randomUrlSafe(32) : null;
|
||||
}
|
||||
|
||||
private function scopeIncludesOpenId(string $scope): bool
|
||||
{
|
||||
$scopes = preg_split('/\s+/', trim($scope)) ?: [];
|
||||
return in_array('openid', $scopes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options
|
||||
*/
|
||||
private function maybeValidateNonce(TokenSet $tokens, array $options): void
|
||||
{
|
||||
$validateNonce = !isset($options['validate_nonce']) || (bool) $options['validate_nonce'];
|
||||
if (!$validateNonce || $tokens->idToken === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expectedNonce = $options['expected_nonce'] ?? ($_SESSION[self::NONCE_SESSION_KEY] ?? null);
|
||||
if (!is_string($expectedNonce) || $expectedNonce === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowMissingNonce = !isset($options['allow_missing_nonce']) || (bool) $options['allow_missing_nonce'];
|
||||
if ($allowMissingNonce) {
|
||||
$claims = $this->decodeJwtPayloadClaims($tokens->idToken);
|
||||
$nonce = $claims['nonce'] ?? null;
|
||||
if (!is_string($nonce) || $nonce === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hash_equals($expectedNonce, $nonce)) {
|
||||
throw new NonceMismatchException('The ID token nonce did not match the expected value', 'nonce_mismatch');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertIdTokenNonceMatches($tokens->idToken, $expectedNonce);
|
||||
}
|
||||
|
||||
private function randomUrlSafe(int $length): string
|
||||
{
|
||||
return $this->base64Url(random_bytes($length));
|
||||
|
||||
@@ -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 $userInfoUrl = 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(
|
||||
clientId: $config['clientId'],
|
||||
authorizeUrl: rtrim($config['authorizeUrl'], '/'),
|
||||
tokenUrl: rtrim($config['tokenUrl'], '/'),
|
||||
redirectUri: $config['redirectUri'],
|
||||
userInfoUrl: isset($config['userInfoUrl']) ? rtrim((string) $config['userInfoUrl'], '/') : null,
|
||||
authorizeUrl: $authorizeUrl,
|
||||
tokenUrl: $tokenUrl,
|
||||
redirectUri: $redirectUri,
|
||||
userInfoUrl: $userInfoUrl,
|
||||
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
|
||||
{
|
||||
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,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Exception;
|
||||
|
||||
class NonceMismatchException extends AuthException
|
||||
{
|
||||
}
|
||||
@@ -8,15 +8,19 @@ use BlackWall\Auth\Exception\TransportException;
|
||||
|
||||
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
|
||||
{
|
||||
$this->assertValidUrl($url);
|
||||
$ch = curl_init($url);
|
||||
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([
|
||||
@@ -29,16 +33,31 @@ final class CurlHttpClient implements HttpClientInterface
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields, '', '&', PHP_QUERY_RFC3986),
|
||||
CURLOPT_HTTPHEADER => $headerLines,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeoutSeconds,
|
||||
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);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
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];
|
||||
@@ -46,9 +65,10 @@ final class CurlHttpClient implements HttpClientInterface
|
||||
|
||||
public function get(string $url, array $headers = []): array
|
||||
{
|
||||
$this->assertValidUrl($url);
|
||||
$ch = curl_init($url);
|
||||
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([
|
||||
@@ -58,16 +78,31 @@ final class CurlHttpClient implements HttpClientInterface
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => $headerLines,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => $this->connectTimeoutSeconds,
|
||||
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);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
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];
|
||||
@@ -81,9 +116,30 @@ final class CurlHttpClient implements HttpClientInterface
|
||||
{
|
||||
$result = [];
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+62
-1
@@ -26,13 +26,36 @@ class BlackWallAuth
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $opts
|
||||
* @return array{url:string,state:string,code_verifier:string,code_challenge:string}
|
||||
* @return array{url:string,state:string,code_verifier:string,code_challenge:string,nonce:?string}
|
||||
*/
|
||||
public function getAuthorizationUrl(array $opts = []): array
|
||||
{
|
||||
return $this->client->buildAuthorisationUrl($opts);
|
||||
}
|
||||
|
||||
public function assertNonceMatches(string $nonce): void
|
||||
{
|
||||
$this->client->assertNonceMatches($nonce);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function assertIdTokenNonceMatches(string $idToken, ?string $expectedNonce = null): array
|
||||
{
|
||||
return $this->client->assertIdTokenNonceMatches($idToken, $expectedNonce);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes JWT payload claims only. This does not perform cryptographic verification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function decodeJwtPayloadClaims(string $jwt): array
|
||||
{
|
||||
return $this->client->decodeJwtPayloadClaims($jwt);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -56,4 +79,42 @@ 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
|
||||
* @param array{expected_nonce?:?string,validate_nonce?:bool,allow_missing_nonce?:bool} $options
|
||||
* @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 $options = []): array
|
||||
{
|
||||
$result = $this->client->handleCallback($query, $clearPkce, $options);
|
||||
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';
|
||||
|
||||
+273
-5
@@ -6,6 +6,7 @@ namespace BlackWall\Auth\Tests;
|
||||
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Config;
|
||||
use BlackWall\Auth\Exception\NonceMismatchException;
|
||||
use BlackWall\Auth\Exception\StateMismatchException;
|
||||
use BlackWall\Auth\Exception\TokenExchangeException;
|
||||
use BlackWall\Auth\Exception\UserInfoException;
|
||||
@@ -47,14 +48,59 @@ final class AuthClientTest extends TestCase
|
||||
self::assertArrayHasKey('state', $result);
|
||||
self::assertArrayHasKey('code_verifier', $result);
|
||||
self::assertArrayHasKey('code_challenge', $result);
|
||||
self::assertSame($result['state'], $_SESSION['blackwall_oauth_state']);
|
||||
self::assertSame($result['code_verifier'], $_SESSION['blackwall_oauth_code_verifier']);
|
||||
self::assertArrayHasKey('nonce', $result);
|
||||
self::assertIsString($result['nonce']);
|
||||
self::assertNotSame('', $result['nonce']);
|
||||
self::assertSame($result['state'], $_SESSION[AuthClient::STATE_SESSION_KEY]);
|
||||
self::assertSame($result['code_verifier'], $_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY]);
|
||||
self::assertSame($result['nonce'], $_SESSION[AuthClient::NONCE_SESSION_KEY]);
|
||||
self::assertStringContainsString('code_challenge_method=S256', $result['url']);
|
||||
self::assertStringContainsString('nonce=', $result['url']);
|
||||
}
|
||||
|
||||
public function testBuildAuthorisationUrlDoesNotGenerateNonceWithoutOpenIdScope(): void
|
||||
{
|
||||
$result = $this->client->buildAuthorisationUrl([
|
||||
'scope' => 'profile email',
|
||||
]);
|
||||
|
||||
self::assertNull($result['nonce']);
|
||||
self::assertArrayNotHasKey(AuthClient::NONCE_SESSION_KEY, $_SESSION);
|
||||
self::assertStringNotContainsString('nonce=', $result['url']);
|
||||
}
|
||||
|
||||
public function testBuildAuthorisationUrlPersistsProvidedNonce(): void
|
||||
{
|
||||
$result = $this->client->buildAuthorisationUrl([
|
||||
'nonce' => 'nonce-123',
|
||||
]);
|
||||
|
||||
self::assertSame('nonce-123', $result['nonce']);
|
||||
self::assertSame('nonce-123', $_SESSION[AuthClient::NONCE_SESSION_KEY]);
|
||||
}
|
||||
|
||||
public function testAssertNonceMatchesThrowsOnMismatch(): void
|
||||
{
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce';
|
||||
|
||||
$this->expectException(NonceMismatchException::class);
|
||||
$this->client->assertNonceMatches('actual-nonce');
|
||||
}
|
||||
|
||||
public function testDecodeJwtPayloadClaimsReturnsClaimsWithoutVerification(): void
|
||||
{
|
||||
$claims = $this->client->decodeJwtPayloadClaims($this->makeJwt([
|
||||
'sub' => 'user-1',
|
||||
'nonce' => 'nonce-1',
|
||||
]));
|
||||
|
||||
self::assertSame('user-1', $claims['sub']);
|
||||
self::assertSame('nonce-1', $claims['nonce']);
|
||||
}
|
||||
|
||||
public function testAssertStateMatchesThrowsOnMismatch(): void
|
||||
{
|
||||
$_SESSION['blackwall_oauth_state'] = 'expected';
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'expected';
|
||||
|
||||
$this->expectException(StateMismatchException::class);
|
||||
$this->client->assertStateMatches('actual');
|
||||
@@ -62,7 +108,7 @@ final class AuthClientTest extends TestCase
|
||||
|
||||
public function testExchangeCodeForTokensReturnsTypedTokenSet(): void
|
||||
{
|
||||
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
@@ -95,7 +141,7 @@ final class AuthClientTest extends TestCase
|
||||
|
||||
public function testExchangeCodeForTokensThrowsOnEndpointError(): void
|
||||
{
|
||||
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 400,
|
||||
@@ -147,4 +193,226 @@ final class AuthClientTest extends TestCase
|
||||
$this->expectExceptionMessage('UserInfo endpoint error (401)');
|
||||
$this->client->getUserInfo('expired-token');
|
||||
}
|
||||
|
||||
public function testGetNormalizedUserInfoReturnsCanonicalFields(): void
|
||||
{
|
||||
$this->http->queueGet([
|
||||
'status' => 200,
|
||||
'body' => '{"email":"Admin@Example.com","claims":{"role":"admin"}}',
|
||||
]);
|
||||
|
||||
$user = $this->client->getNormalizedUserInfo('access-4');
|
||||
|
||||
self::assertSame('admin@example.com', $user->email);
|
||||
self::assertSame(1, $user->privilegeLevel);
|
||||
self::assertSame('admin', $user->role);
|
||||
}
|
||||
|
||||
public function testResolvePrivilegeLevelSupportsAliases(): void
|
||||
{
|
||||
self::assertSame(1, AuthClient::resolvePrivilegeLevel(['privilege_level' => 1]));
|
||||
self::assertSame(2, AuthClient::resolvePrivilegeLevel(['claims' => ['role_level' => '2']]));
|
||||
self::assertSame(1, AuthClient::resolvePrivilegeLevel(['role' => 'superadmin']));
|
||||
self::assertSame(2, AuthClient::resolvePrivilegeLevel(['role' => 'tutor']));
|
||||
self::assertNull(AuthClient::resolvePrivilegeLevel(['foo' => 'bar']));
|
||||
}
|
||||
|
||||
public function testHandleCallbackReturnsCallbackResult(): void
|
||||
{
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'nonce-1';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
'body' => json_encode([
|
||||
'access_token' => 'access-1',
|
||||
'refresh_token' => 'refresh-1',
|
||||
'id_token' => $this->makeJwt(['nonce' => 'nonce-1', 'sub' => 'user-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(AuthClient::STATE_SESSION_KEY, $_SESSION);
|
||||
self::assertArrayNotHasKey(AuthClient::CODE_VERIFIER_SESSION_KEY, $_SESSION);
|
||||
self::assertArrayNotHasKey(AuthClient::NONCE_SESSION_KEY, $_SESSION);
|
||||
}
|
||||
|
||||
public function testHandleCallbackThrowsWhenIdTokenNonceDoesNotMatch(): void
|
||||
{
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
'body' => json_encode([
|
||||
'access_token' => 'access-1',
|
||||
'refresh_token' => 'refresh-1',
|
||||
'id_token' => $this->makeJwt(['nonce' => 'actual-nonce', 'sub' => 'user-1']),
|
||||
'token_type' => 'Bearer',
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$this->expectException(NonceMismatchException::class);
|
||||
$this->client->handleCallback([
|
||||
'code' => 'code-1',
|
||||
'state' => 'state-1',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testHandleCallbackSucceedsWhenNoIdTokenReturned(): void
|
||||
{
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce';
|
||||
|
||||
$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::assertNull($result->tokens->idToken);
|
||||
}
|
||||
|
||||
public function testHandleCallbackAllowsMissingNonceClaimWhenEnabled(): void
|
||||
{
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
'body' => json_encode([
|
||||
'access_token' => 'access-1',
|
||||
'refresh_token' => 'refresh-1',
|
||||
'id_token' => $this->makeJwt(['sub' => 'user-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',
|
||||
], true, [
|
||||
'allow_missing_nonce' => true,
|
||||
]);
|
||||
|
||||
self::assertSame('access-1', $result->tokens->accessToken);
|
||||
}
|
||||
|
||||
public function testHandleCallbackAllowsMissingNonceClaimByDefault(): void
|
||||
{
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
'body' => json_encode([
|
||||
'access_token' => 'access-1',
|
||||
'refresh_token' => 'refresh-1',
|
||||
'id_token' => $this->makeJwt(['sub' => 'user-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);
|
||||
}
|
||||
|
||||
public function testHandleCallbackCanDisableNonceValidationTemporarily(): void
|
||||
{
|
||||
$_SESSION[AuthClient::STATE_SESSION_KEY] = 'state-1';
|
||||
$_SESSION[AuthClient::CODE_VERIFIER_SESSION_KEY] = 'verifier-123';
|
||||
$_SESSION[AuthClient::NONCE_SESSION_KEY] = 'expected-nonce';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
'body' => json_encode([
|
||||
'access_token' => 'access-1',
|
||||
'refresh_token' => 'refresh-1',
|
||||
'id_token' => $this->makeJwt(['nonce' => 'different-nonce', 'sub' => 'user-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',
|
||||
], true, [
|
||||
'validate_nonce' => false,
|
||||
]);
|
||||
|
||||
self::assertSame('access-1', $result->tokens->accessToken);
|
||||
}
|
||||
|
||||
public function testHandleCallbackThrowsOnMissingParams(): void
|
||||
{
|
||||
$this->expectException(UserInfoException::class);
|
||||
$this->client->handleCallback(['state' => 'only-state']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function makeJwt(array $claims): string
|
||||
{
|
||||
$header = $this->base64Url(json_encode(['alg' => 'none', 'typ' => 'JWT'], JSON_THROW_ON_ERROR));
|
||||
$payload = $this->base64Url(json_encode($claims, JSON_THROW_ON_ERROR));
|
||||
|
||||
return $header . '.' . $payload . '.signature';
|
||||
}
|
||||
|
||||
private function base64Url(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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