41 Commits
Author SHA1 Message Date
Andy DixonandCopilot d287e01b2a Git Ignoreness
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:54:30 +01:00
Andy Dixon b2163ad996 Default allow_missing_nonce behavior 2026-04-27 10:50:50 +01:00
Andy Dixon 1d8578c1da Additional Improvements 2026-03-11 07:41:21 +00:00
Andy Dixon 986dc95646 Updated to enforce nonce in example and documentaion 2026-03-10 09:50:34 +00:00
Andy Dixon 6b67bf574d typo 2026-02-26 13:40:48 +00:00
Andy Dixon fc80ee1659 docs: add provider security model for sdk integrators
Introduces security-model guidance covering provider invariants, expected auth failure modes, and integration-safe handling.
2026-02-26 13:38:45 +00:00
Andy Dixon 1d62f0affd docs: document refresh invalid_grant on scope tightening
Adds integration guidance that refresh-token exchange may fail after provider-side client scope policy is narrowed.
2026-02-26 13:34:42 +00:00
Andy Dixon ddc08270a7 docs: document invalid_scope handling and scope allowlist alignment
Clarifies that requested SDK scopes must be enabled on the provider OAuth client and adds callback-handling guidance for invalid_scope failures.
2026-02-26 13:30:07 +00:00
Andy Dixon 32208c7701 docs: note cryptbin credential-binding requirements
Document that new cryptbin items can enforce creating-credential binding for unwrap/update/delete operations in addition to key proof.
2026-02-26 13:25:20 +00:00
Andy Dixon 62544d86b4 docs: add CIDR trusted-proxy guidance
Clarify trusted-proxy configuration can use explicit IP/CIDR entries and should remain narrowly scoped for abuse-control integrity.
2026-02-26 13:23:29 +00:00
Andy Dixon 35d58ff07b docs: add CSV import limit guidance for bulk admin flows
Document operational expectations for CSV file-size and row-count limits during provider bulk-user import automation.
2026-02-26 13:17:28 +00:00
Andy Dixon 464524da27 docs: note serialized decision behavior for approval actions
Clarify that approve/reject/cancel decisions for a single approval request should be treated as serialized and not submitted in parallel.
2026-02-26 13:16:32 +00:00
Andy Dixon 8bb3c1ce12 docs: note JWT revocation-aware userinfo behavior
Update integration guidance that revoked JWT access tokens are now rejected on provider userinfo before natural expiry.
2026-02-26 13:00:51 +00:00
Andy Dixon bbcada1317 docs: clarify idempotent retry expectations for approval flows
Document serialized approval execution expectations and recommend bounded idempotent client retries for admin mutation workflows.
2026-02-26 12:58:50 +00:00
Andy Dixon 878e2b98e6 docs: add reverse-proxy IP header hardening guidance
Document strict trusted-proxy configuration requirements to avoid spoofed client IP forwarding affecting abuse controls.
2026-02-26 12:42:10 +00:00
Andy Dixon 6cc2ddabd0 docs: require non-URL handling for enrollment links
Clarify that enrollment URLs are sensitive bearer material and must not be transported in redirect query strings or logs.
2026-02-26 12:40:33 +00:00
Andy Dixon fb60a1eccb docs: document introspection active=false on disabled subjects
Clarify SDK integration guidance that provider introspection can return active=false when user/project/client is disabled after token issuance.
2026-02-26 12:38:45 +00:00
Andy Dixon ebea82f63d docs: add CSV export formula-injection handling guidance
Document that provider CSV exports should be handled with formula-injection safety when opened in spreadsheet tooling.
2026-02-26 12:35:02 +00:00
Andy Dixon 3d3493e546 docs: require POST+CSRF for admin enrollment export calls
Document provider hardening change: enrollment URL export is state-changing and must be invoked with POST plus CSRF token, not GET links.
2026-02-26 12:33:51 +00:00
Andy Dixon 822b9b93c5 docs: document cryptbin unwrap key proof requirement
Update SDK security guidance/examples to note that provider cryptbin unwrap calls must include key_b64url and now fail with 403 Forbidden on key mismatch.
2026-02-26 12:31:11 +00:00
Andy Dixon ba8868cc12 docs: note stricter limits on high-cost report/export endpoints
Extend integration guidance to account for stricter abuse controls on provider report/export style endpoints.
2026-02-26 12:28:11 +00:00
Andy Dixon 78606bf751 docs: note disabled projects may be omitted from assignment views
Clarify integration expectation that provider-side project visibility can be restricted to active projects, even when historical assignments remain.
2026-02-26 12:27:25 +00:00
Andy Dixon d97638201e docs: include consent-time access_denied for inactive accounts
Clarify that provider-side active-account enforcement can reject OAuth consent submission when account status changes during the authorization session.
2026-02-26 12:26:45 +00:00
Andy Dixon df7ca1329e docs: note session invalidation behavior for disabled accounts
Document that provider-side account disablement can invalidate existing authenticated sessions, not just new login attempts.
2026-02-26 12:22:20 +00:00
Andy Dixon 8b7f11fc6a docs: note disabled-account failures during WebAuthn login verification
Clarify that provider WebAuthn login verification may reject disabled accounts with credential-style failures despite valid authenticator assertions.
2026-02-26 12:21:30 +00:00
Andy Dixon cdef7da4b9 docs: align state/nonce/PKCE format expectations with provider validation
Document provider-side strict validation for OAuth authorization request parameters so integrations generate URL-safe, spec-conformant state/nonce/PKCE values.
2026-02-26 12:07:18 +00:00
Andy Dixon f51997016d docs: note disabled-account access_denied outcomes during auth flow
Clarify that account status changes (disabled) may surface as access_denied during provider login/authorization, and integrations should handle these outcomes explicitly.
2026-02-26 12:06:19 +00:00
Andy Dixon 49265eee0f docs: note invalid_grant on inactive subject state during token exchange
Extend SDK integration guidance to cover provider-side active-state enforcement at token issuance, where invalid_grant may be returned if client/user/project is inactive.
2026-02-26 12:05:06 +00:00
Andy Dixon 4d612b57f4 docs: require single OAuth client auth method per request
Document that integrations must not mix HTTP Basic and client_secret form authentication on the same token/control endpoint request, aligning with provider-side hardening.
2026-02-26 12:03:33 +00:00
Andy Dixon f9323110ce docs: prohibit URL query transport of secrets and tokens
Update SDK security guidance to explicitly forbid placing client secrets or bearer tokens in URL query strings, matching provider-side hardening against secret leakage.
2026-02-26 12:02:21 +00:00
Andy Dixon 12cefe68e8 docs: clarify provider rate limits may be source-identity scoped
Add integration guidance that provider abuse controls can apply across related auth endpoints based on source identity, reinforcing backoff and retry discipline.
2026-02-26 11:53:25 +00:00
Andy Dixon cb0bd43d04 docs: clarify end-user integrations should use OAuth/portal endpoints
Add guidance that admin authentication endpoints are scope-restricted and not intended for end-user SDK integrations.
2026-02-26 11:52:41 +00:00
Andy Dixon 78a18ccfab docs: handle userinfo invalid_token outcomes in callback example
Update SDK example callback and integration guidance to treat UserInfoException as an authorization-state outcome (e.g. subject disabled/inactive scope) and return a clear 403 path.
2026-02-26 11:51:29 +00:00
Andy Dixon 7bb919a0a0 docs: recommend bounded retry/backoff for token exchange failures
Update integration guidance to avoid tight retry loops during transient token endpoint failures by using bounded backoff strategies.
2026-02-26 11:48:40 +00:00
Andy Dixon 60f6989595 docs: add userinfo 429 backoff guidance
Extend integration guidance to cover provider-side abuse controls on userinfo lookups, advising retry/backoff handling for 429 responses.
2026-02-26 11:47:15 +00:00
Andy Dixon 066a0b8fb7 docs: require HTTPS redirect URIs except localhost loopback dev
Align SDK integration guidance with provider redirect URI hardening: use HTTPS callback URLs in production and limit HTTP callbacks to localhost loopback during development/testing.
2026-02-26 11:46:31 +00:00
Andy Dixon d4a6418e5d docs: specify POST-only usage for provider WebAuthn challenge/verify endpoints
Clarify integration expectations that provider-side WebAuthn login challenge and verify endpoints should be invoked via POST methods only.
2026-02-26 11:40:31 +00:00
Andy Dixon 7d0319332f docs: clarify callback access_denied causes include privilege changes
Update integration guidance so consuming apps treat access_denied as an expected provider authorization result when membership or privilege state changes during the OAuth consent flow.
2026-02-26 11:39:50 +00:00
Andy Dixon 44aa9e6f80 docs: note 429 backoff expectations for provider control endpoints
Document that integrations should expect provider-side abuse controls on OAuth control endpoints and handle 429 responses with retry/backoff semantics.
2026-02-26 11:38:56 +00:00
Andy Dixon d8a28dea12 docs: document consent-time access_denied from provider revalidation
Clarify that providers may return access_denied at callback time even after consent UI is shown, when membership/tenant scope changes before consent submission. This aligns integration guidance with current provider-side security enforcement.
2026-02-26 11:38:13 +00:00
Andy Dixon f529714f45 docs: handle OAuth callback access_denied outcomes from stricter provider authz
Update SDK docs and example callback handling to process provider callback errors explicitly. access_denied is now documented and handled as an expected authorization outcome (HTTP 403), including cases where provider membership/scope checks fail at consent submission.
2026-02-26 11:36:17 +00:00
11 changed files with 640 additions and 45 deletions
+2
View File
@@ -0,0 +1,2 @@
vendor
.phpunit.cache
+74 -10
View File
@@ -9,7 +9,8 @@ This package provides:
- UserInfo retrieval
- UserInfo normalisation (`email`, `privilege_level`, `role`)
- Unified callback handling helper (`handleCallback`)
- Session helpers for `state` and `code_verifier`
- 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
@@ -40,7 +41,7 @@ In the consuming application's `composer.json`:
}
],
"require": {
"dixon/blackwall-auth-sdk": "*"
"andydixon/blackwall-auth-sdk": "*"
}
}
```
@@ -77,22 +78,22 @@ $config = Config::fromArray([
$client = new AuthClient($config);
// Step 1: redirect user to provider
$auth = $client->buildAuthorisationUrl([
'extra' => [
// Some providers require nonce for OIDC requests.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
$auth = $client->buildAuthorisationUrl();
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);
$result = $client->handleCallback($_GET, true, [
'expected_nonce' => $_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null,
]);
$_SESSION['user'] = [
'email' => $result->user->email,
@@ -102,6 +103,49 @@ $_SESSION['user'] = [
$_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
A wrapper class is retained for older integrations:
@@ -128,7 +172,27 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
## Security notes
- Always validate OAuth `state` in callback handlers.
- For OIDC providers, include a per-request `nonce` in authorisation requests.
- 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.
+106 -10
View File
@@ -25,12 +25,7 @@ $client = new AuthClient(Config::fromArray([
]));
if (!isset($_SESSION['user'])) {
$auth = $client->buildAuthorisationUrl([
'extra' => [
// Some providers require nonce for OIDC requests.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
$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');
@@ -41,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
@@ -63,12 +60,18 @@ $client = new AuthClient(Config::fromArray([
]));
if (isset($_GET['error'])) {
$error = (string) $_GET['error'];
$description = isset($_GET['error_description']) ? (string) $_GET['error_description'] : 'Unknown provider error';
http_response_code(400);
exit('Authorization failed: ' . $description);
// 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);
}
$result = $client->handleCallback($_GET);
$result = $client->handleCallback($_GET, true, [
'expected_nonce' => $_SESSION[\BlackWall\Auth\AuthClient::NONCE_SESSION_KEY] ?? null,
]);
$_SESSION['user'] = [
'email' => $result->user->email,
@@ -82,6 +85,66 @@ header('Location: /');
exit;
```
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.
@@ -115,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`
@@ -131,13 +195,45 @@ try {
}
```
`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.
- Add a unique `nonce` to each OIDC authorisation request.
- 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.
+2
View File
@@ -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
+27
View File
@@ -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 -1
View File
@@ -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,14 +28,31 @@ $auth = new BlackWallAuth([
'redirectUri' => 'https://test.dixon.cx/callback.php',
]);
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 {
$result = $auth->handleCallback($_GET);
$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');
+4 -2
View File
@@ -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;
+149 -8
View File
@@ -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,20 +68,29 @@ 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', '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.', 'missing_code_verifier');
}
@@ -157,9 +181,58 @@ final class AuthClient
}
/**
* @param array<string, mixed> $query
* Decodes JWT payload claims only. This does not verify signature, issuer, audience, expiry, or token use.
*
* @return array<string, mixed>
*/
public function handleCallback(array $query, bool $clearPkce = true): CallbackResult
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');
@@ -167,6 +240,7 @@ final class AuthClient
$this->assertStateMatches((string) $query['state']);
$tokens = $this->exchangeCodeForTokens((string) $query['code']);
$this->maybeValidateNonce($tokens, $options);
$rawUser = $this->getUserInfo($tokens->accessToken);
$user = UserInfoNormalizer::normalize($rawUser);
@@ -203,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
@@ -211,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,9 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Exception;
class NonceMismatchException extends AuthException
{
}
+27 -3
View File
@@ -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>
*/
@@ -73,15 +96,16 @@ class BlackWallAuth
/**
* @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
public function handleCallback(array $query, bool $clearPkce = true, array $options = []): array
{
$result = $this->client->handleCallback($query, $clearPkce);
$result = $this->client->handleCallback($query, $clearPkce, $options);
return [
'tokens' => $result->tokens->raw,
'user' => [
+219 -11
View File
@@ -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,
@@ -173,8 +219,66 @@ final class AuthClientTest extends TestCase
public function testHandleCallbackReturnsCallbackResult(): void
{
$_SESSION['blackwall_oauth_state'] = 'state-1';
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
$_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,
@@ -196,10 +300,98 @@ final class AuthClientTest extends TestCase
]);
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);
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
@@ -207,4 +399,20 @@ final class AuthClientTest extends TestCase
$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), '+/', '-_'), '=');
}
}