40 Commits
Author SHA1 Message Date
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
Andy Dixon 6ddbcaec70 docs: add OIDC nonce guidance and callback error handling 2026-02-26 11:11:47 +00:00
Andy Dixon 3ff5b7aa26 Security hardening: enforce secure URLs and transport safeguards 2026-02-26 09:10:09 +00:00
10 changed files with 316 additions and 15 deletions
+33 -2
View File
@@ -40,7 +40,7 @@ In the consuming application's `composer.json`:
} }
], ],
"require": { "require": {
"dixon/blackwall-auth-sdk": "*" "andydixon/blackwall-auth-sdk": "*"
} }
} }
``` ```
@@ -77,12 +77,20 @@ $config = Config::fromArray([
$client = new AuthClient($config); $client = new AuthClient($config);
// Step 1: redirect user to provider // Step 1: redirect user to provider
$auth = $client->buildAuthorisationUrl(); $auth = $client->buildAuthorisationUrl([
'extra' => [
// Include nonce in the authorisation request for OIDC.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
header('Location: ' . $auth['url']); header('Location: ' . $auth['url']);
exit; exit;
``` ```
The generated authorisation request URL will include `nonce=...` alongside `state`, `scope`, and the PKCE parameters.
For callback handling, see `docs/INTEGRATION_GUIDE.md`. For callback handling, see `docs/INTEGRATION_GUIDE.md`.
For provider-side threat/invariant assumptions, see `docs/SECURITY_MODEL.md`.
Minimal callback example: Minimal callback example:
@@ -123,10 +131,33 @@ This wrapper now delegates to `BlackWall\Auth\AuthClient`.
## Security notes ## Security notes
- Always validate OAuth `state` in callback handlers. - Always validate OAuth `state` in callback handlers.
- Handle provider callback errors (`error`, `error_description`) explicitly; treat `access_denied` as an expected user/authorisation outcome rather than a transport failure.
- Treat `invalid_scope` as a client-configuration mismatch: requested scopes must be a subset of scopes allowed on the provider client.
- Expect `access_denied` even after the consent page is displayed if provider-side project membership or tenant scope changes before consent submission.
- Expect provider token introspection to return `active=false` when user/project/client state is no longer active.
- Expect revoked JWT access tokens to be rejected by provider `userinfo` even before token expiry.
- Register only HTTPS redirect URIs in provider client settings (HTTP should be used only for localhost loopback during development).
- Use OAuth/portal authentication endpoints for end users; provider admin login endpoints enforce separate admin scope checks.
- For provider admin enrolment URL export operations, use `POST` with CSRF protection; do not automate them via unauthenticated `GET` links.
- Handle provider CSV exports with formula-injection safety in mind if opening them in spreadsheet applications.
- Treat generated enrolment URLs as secrets and keep them out of URL query strings and request logs.
- For reverse-proxy deployments, harden trusted proxy/IP forwarding configuration to prevent spoofed client IP headers.
- Prefer narrowly scoped trusted-proxy entries (explicit IPs/CIDR ranges) instead of broad network trust.
- Keep client-side admin mutation retries idempotent; approval workflows can be lock-serialised and should not be assumed to execute twice.
- Do not submit parallel approve/reject/cancel decisions for the same approval request ID.
- Apply bounded CSV import sizes and row counts for admin bulk-user operations.
- Never place client secrets, refresh tokens, or access tokens in URL query strings.
- For OIDC providers, include a per-request `nonce` in authorisation requests.
- Ensure requested `scope` values in SDK config/examples (for example `offline_access`) are enabled on the provider-side client before rollout.
- Expect refresh-token exchange to fail (`invalid_grant`) if provider-side client scopes were tightened and the refresh token now carries disallowed scopes.
- For direct Cryptbin API usage, send `key_b64url` on unwrap calls; provider rejects key-mismatch unwrap attempts with `403 Forbidden`.
- For newly created Cryptbin items, continue using the creating WebAuthn credential for unwrap/update/delete flows.
- Always use HTTPS in production. - Always use HTTPS in production.
- Store refresh tokens securely. - Store refresh tokens securely.
- Keep access tokens out of logs and browser-visible output. - Keep access tokens out of logs and browser-visible output.
- Use short session lifetimes where possible. - Use short session lifetimes where possible.
- `Config::fromArray()` enforces HTTPS URLs by default.
- For localhost-only development over HTTP, set `allowInsecureHttp => true`.
## Repository layout ## Repository layout
+56 -1
View File
@@ -25,7 +25,15 @@ $client = new AuthClient(Config::fromArray([
])); ]));
if (!isset($_SESSION['user'])) { if (!isset($_SESSION['user'])) {
$auth = $client->buildAuthorisationUrl(); $auth = $client->buildAuthorisationUrl([
'extra' => [
// Include nonce in the authorisation request for OIDC.
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
// Prevent stale redirects from being reused from cache.
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Location: ' . $auth['url']); header('Location: ' . $auth['url']);
exit; exit;
} }
@@ -33,6 +41,8 @@ if (!isset($_SESSION['user'])) {
echo 'Already signed in.'; echo 'Already signed in.';
``` ```
The resulting redirect request includes `nonce=...` in the provider authorisation URL, together with `state`, `code_challenge`, and the rest of the OAuth/OIDC query parameters.
## 2. Handle callback ## 2. Handle callback
```php ```php
@@ -54,6 +64,16 @@ $client = new AuthClient(Config::fromArray([
'redirectUri' => 'https://your-app.example/callback.php', 'redirectUri' => 'https://your-app.example/callback.php',
])); ]));
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);
}
$result = $client->handleCallback($_GET); $result = $client->handleCallback($_GET);
$_SESSION['user'] = [ $_SESSION['user'] = [
@@ -117,10 +137,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 ## Operational guidance
- Keep provider URLs in environment variables, not hard-coded. - Keep provider URLs in environment variables, not hard-coded.
- Avoid printing tokens in production pages. - Avoid printing tokens in production pages.
- Expect `429 Too Many Requests` from provider control endpoints under abuse protection; implement backoff/retry instead of tight loops.
- Apply the same backoff strategy for provider `userinfo` calls that return `429 Too Many Requests`.
- For token exchange and refresh, apply bounded retry/backoff on transient provider failures instead of immediate repeated retries.
- Use exactly one client authentication method per token/control request (do not send both HTTP Basic and `client_secret` form fields together).
- If you call provider WebAuthn login challenge/verify endpoints directly, use `POST` only.
- Treat admin enrolment URL export as a state-changing operation: call it with `POST` and include a valid CSRF token.
- Treat provider CSV exports as untrusted input in spreadsheet tools; prefer importing into systems that neutralise formula cells.
- Treat enrolment URLs as sensitive bearer material: do not place them in redirect query parameters, logs, or analytics tags; keep them in one-time server-side flash/session state.
- If your deployment runs behind reverse proxies, ensure trusted-proxy configuration is strict so spoofed forwarding headers cannot bypass IP-based abuse controls.
- Trusted proxy allowlists may be expressed as explicit IPs or CIDR ranges; avoid broad ranges and keep proxy chains minimal.
- For approval-driven admin mutations, assume provider-side execution is serialised per approval item; client retries should still be idempotent and bounded.
- This serialisation applies across approval actions (approve/reject/cancel), so clients should avoid parallel decision submissions for the same request ID.
- For provider CSV-based user import operations, enforce strict file-size and row-count limits in automation tooling and surface clear operator errors on limit breaches.
- Configure OAuth client redirect URIs as HTTPS in production; reserve HTTP for localhost loopback testing only.
- Assume provider rate limits are enforced per source identity (for example IP) and shared across related auth endpoints.
- Treat report/export-style provider endpoints as high-cost and expect stricter rate limits.
- Rotate client secrets for confidential clients. - Rotate client secrets for confidential clients.
- Do not transport secrets or tokens in URL query parameters; keep them in server-side session or secure storage only.
- Add a unique `nonce` to each OIDC authorisation request.
- Keep `state`, `nonce`, and PKCE values within standard URL-safe formats/lengths; malformed values can be rejected by provider validation.
- Validate `state` on every callback request. - Validate `state` on every callback request.
- Use secure session cookies (`Secure`, `HttpOnly`, `SameSite=Lax` or stricter). - Use secure session cookies (`Secure`, `HttpOnly`, `SameSite=Lax` or stricter).
- HTTPS URLs are enforced by default in `Config::fromArray()`.
- For localhost-only HTTP testing, explicitly set `allowInsecureHttp => true`.
- If you call Cryptbin unwrap APIs directly, include the original share key as `key_b64url`; unwrap now returns `403 Forbidden` when key proof does not match metadata verifier.
- New Cryptbin items may also bind unwrap/update/delete operations to the creating WebAuthn credential; use the same authenticator for follow-up operations.
+1
View File
@@ -26,6 +26,7 @@ $user = $client->getUserInfo($tokens->accessToken);
## Key differences ## Key differences
- Method spelling uses British English in the new API: `buildAuthorisationUrl()`. - Method spelling uses British English in the new API: `buildAuthorisationUrl()`.
- The legacy method name `getAuthorizationUrl()` is unchanged for backwards compatibility.
- Token responses are strongly typed via `TokenSet`. - Token responses are strongly typed via `TokenSet`.
- Errors are represented by specific exception classes. - Errors are represented by specific exception classes.
- HTTP transport is swappable via `HttpClientInterface`. - HTTP transport is swappable via `HttpClientInterface`.
+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.
+13
View File
@@ -16,6 +16,7 @@ foreach ($autoloadCandidates as $file) {
} }
use BlackWallSDK\BlackWallAuth; use BlackWallSDK\BlackWallAuth;
use BlackWall\Auth\Exception\UserInfoException;
$auth = new BlackWallAuth([ $auth = new BlackWallAuth([
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2', 'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
@@ -25,6 +26,15 @@ $auth = new BlackWallAuth([
'redirectUri' => 'https://test.dixon.cx/callback.php', '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 { try {
$result = $auth->handleCallback($_GET); $result = $auth->handleCallback($_GET);
$_SESSION['access_token'] = $result['tokens']['access_token'] ?? null; $_SESSION['access_token'] = $result['tokens']['access_token'] ?? null;
@@ -33,6 +43,9 @@ try {
header('Location: /'); header('Location: /');
exit; exit;
} catch (UserInfoException $e) {
http_response_code(403);
echo 'User info access failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); http_response_code(500);
echo 'Auth failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); echo 'Auth failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+5 -1
View File
@@ -39,7 +39,11 @@ if (isset($_GET['logout'])) {
} }
if (!isset($_SESSION['user'])) { if (!isset($_SESSION['user'])) {
$authData = $auth->getAuthorizationUrl(); $authData = $auth->getAuthorizationUrl([
'extra' => [
'nonce' => rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='),
],
]);
header('Location: ' . $authData['url']); header('Location: ' . $authData['url']);
exit; exit;
} }
+50 -6
View File
@@ -13,7 +13,8 @@ final class Config
public readonly string $redirectUri, public readonly string $redirectUri,
public readonly ?string $userInfoUrl = null, public readonly ?string $userInfoUrl = null,
public readonly ?string $clientSecret = null, public readonly ?string $clientSecret = null,
public readonly string $defaultScope = 'openid profile email' public readonly string $defaultScope = 'openid profile email',
public readonly bool $allowInsecureHttp = false
) { ) {
} }
@@ -28,14 +29,57 @@ final class Config
} }
} }
$allowInsecureHttp = isset($config['allowInsecureHttp']) && (bool) $config['allowInsecureHttp'];
$authorizeUrl = rtrim(trim((string) $config['authorizeUrl']), '/');
$tokenUrl = rtrim(trim((string) $config['tokenUrl']), '/');
$redirectUri = trim((string) $config['redirectUri']);
$userInfoUrl = isset($config['userInfoUrl']) ? rtrim(trim((string) $config['userInfoUrl']), '/') : null;
self::assertSecureUrl($authorizeUrl, 'authorizeUrl', $allowInsecureHttp);
self::assertSecureUrl($tokenUrl, 'tokenUrl', $allowInsecureHttp);
self::assertSecureUrl($redirectUri, 'redirectUri', $allowInsecureHttp);
if ($userInfoUrl !== null && $userInfoUrl !== '') {
self::assertSecureUrl($userInfoUrl, 'userInfoUrl', $allowInsecureHttp);
}
return new self( return new self(
clientId: $config['clientId'], clientId: $config['clientId'],
authorizeUrl: rtrim($config['authorizeUrl'], '/'), authorizeUrl: $authorizeUrl,
tokenUrl: rtrim($config['tokenUrl'], '/'), tokenUrl: $tokenUrl,
redirectUri: $config['redirectUri'], redirectUri: $redirectUri,
userInfoUrl: isset($config['userInfoUrl']) ? rtrim((string) $config['userInfoUrl'], '/') : null, userInfoUrl: $userInfoUrl,
clientSecret: isset($config['clientSecret']) ? (string) $config['clientSecret'] : null, clientSecret: isset($config['clientSecret']) ? (string) $config['clientSecret'] : null,
defaultScope: isset($config['scope']) ? (string) $config['scope'] : 'openid profile email' defaultScope: isset($config['scope']) ? (string) $config['scope'] : 'openid profile email',
allowInsecureHttp: $allowInsecureHttp
); );
} }
private static function assertSecureUrl(string $url, string $field, bool $allowInsecureHttp): void
{
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme'])) {
throw new \InvalidArgumentException("{$field} must be a valid absolute URL");
}
$scheme = strtolower((string) $parts['scheme']);
if (!in_array($scheme, ['https', 'http'], true)) {
throw new \InvalidArgumentException("{$field} must use http or https");
}
if ($scheme === 'https') {
return;
}
if ($allowInsecureHttp && self::isLocalHost((string) ($parts['host'] ?? ''))) {
return;
}
throw new \InvalidArgumentException("{$field} must use https (set allowInsecureHttp=true for localhost development)");
}
private static function isLocalHost(string $host): bool
{
$normalized = strtolower(trim($host));
return in_array($normalized, ['localhost', '127.0.0.1', '::1'], true);
}
} }
+61 -5
View File
@@ -8,15 +8,19 @@ use BlackWall\Auth\Exception\TransportException;
final class CurlHttpClient implements HttpClientInterface final class CurlHttpClient implements HttpClientInterface
{ {
public function __construct(private readonly int $timeoutSeconds = 20) public function __construct(
private readonly int $timeoutSeconds = 20,
private readonly int $connectTimeoutSeconds = 10
)
{ {
} }
public function postForm(string $url, array $fields, array $headers = []): array public function postForm(string $url, array $fields, array $headers = []): array
{ {
$this->assertValidUrl($url);
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
throw new TransportException('Unable to initialise cURL for POST request'); throw new TransportException('Unable to initialise cURL for POST request', 'curl_init_failed');
} }
$headerLines = $this->normaliseHeaders(array_merge([ $headerLines = $this->normaliseHeaders(array_merge([
@@ -29,16 +33,31 @@ final class CurlHttpClient implements HttpClientInterface
CURLOPT_POSTFIELDS => http_build_query($fields, '', '&', PHP_QUERY_RFC3986), CURLOPT_POSTFIELDS => http_build_query($fields, '', '&', PHP_QUERY_RFC3986),
CURLOPT_HTTPHEADER => $headerLines, CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeoutSeconds,
CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
]); ]);
if (defined('CURLOPT_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
if (defined('CURLOPT_REDIR_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
$body = curl_exec($ch); $body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
if ($body === false) { if ($body === false) {
throw new TransportException($error !== '' ? $error : 'Unknown cURL POST error'); throw new TransportException(
$error !== '' ? $error : 'Unknown cURL POST error',
$errno > 0 ? ('curl_errno_' . $errno) : 'curl_post_failed'
);
} }
return ['status' => $status, 'body' => $body]; return ['status' => $status, 'body' => $body];
@@ -46,9 +65,10 @@ final class CurlHttpClient implements HttpClientInterface
public function get(string $url, array $headers = []): array public function get(string $url, array $headers = []): array
{ {
$this->assertValidUrl($url);
$ch = curl_init($url); $ch = curl_init($url);
if ($ch === false) { if ($ch === false) {
throw new TransportException('Unable to initialise cURL for GET request'); throw new TransportException('Unable to initialise cURL for GET request', 'curl_init_failed');
} }
$headerLines = $this->normaliseHeaders(array_merge([ $headerLines = $this->normaliseHeaders(array_merge([
@@ -58,16 +78,31 @@ final class CurlHttpClient implements HttpClientInterface
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headerLines, CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeoutSeconds,
CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_MAXREDIRS => 0,
]); ]);
if (defined('CURLOPT_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
if (defined('CURLOPT_REDIR_PROTOCOLS')) {
curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
$body = curl_exec($ch); $body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
$error = curl_error($ch); $error = curl_error($ch);
curl_close($ch); curl_close($ch);
if ($body === false) { if ($body === false) {
throw new TransportException($error !== '' ? $error : 'Unknown cURL GET error'); throw new TransportException(
$error !== '' ? $error : 'Unknown cURL GET error',
$errno > 0 ? ('curl_errno_' . $errno) : 'curl_get_failed'
);
} }
return ['status' => $status, 'body' => $body]; return ['status' => $status, 'body' => $body];
@@ -81,9 +116,30 @@ final class CurlHttpClient implements HttpClientInterface
{ {
$result = []; $result = [];
foreach ($headers as $name => $value) { foreach ($headers as $name => $value) {
if (
str_contains((string) $name, "\r")
|| str_contains((string) $name, "\n")
|| str_contains((string) $value, "\r")
|| str_contains((string) $value, "\n")
) {
throw new TransportException('Header names/values must not contain CR/LF characters', 'invalid_header');
}
$result[] = sprintf('%s: %s', $name, $value); $result[] = sprintf('%s: %s', $name, $value);
} }
return $result; return $result;
} }
private function assertValidUrl(string $url): void
{
$parts = parse_url($url);
if ($parts === false || !isset($parts['scheme'])) {
throw new TransportException('Request URL must be an absolute URL', 'invalid_url');
}
$scheme = strtolower((string) $parts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
throw new TransportException('Only http/https URLs are supported', 'invalid_url_scheme');
}
}
} }
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Tests;
use BlackWall\Auth\Config;
use PHPUnit\Framework\TestCase;
final class ConfigSecurityTest extends TestCase
{
public function testRejectsInsecureHttpForNonLocalhostByDefault(): void
{
$this->expectException(\InvalidArgumentException::class);
Config::fromArray([
'clientId' => 'client-123',
'authorizeUrl' => 'http://provider.example/oauth/authorize',
'tokenUrl' => 'https://provider.example/oauth/token',
'userInfoUrl' => 'https://provider.example/oauth/userinfo',
'redirectUri' => 'https://app.example/callback.php',
]);
}
public function testAllowsInsecureHttpForLocalhostWhenExplicitlyEnabled(): void
{
$config = Config::fromArray([
'clientId' => 'client-123',
'authorizeUrl' => 'http://localhost/oauth/authorize',
'tokenUrl' => 'http://127.0.0.1/oauth/token',
'userInfoUrl' => 'http://localhost/oauth/userinfo',
'redirectUri' => 'http://localhost/callback.php',
'allowInsecureHttp' => true,
]);
self::assertTrue($config->allowInsecureHttp);
self::assertSame('http://localhost/oauth/authorize', $config->authorizeUrl);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Tests;
use BlackWall\Auth\Exception\TransportException;
use BlackWall\Auth\Http\CurlHttpClient;
use PHPUnit\Framework\TestCase;
final class CurlHttpClientSecurityTest extends TestCase
{
public function testRejectsUnsupportedUrlScheme(): void
{
$client = new CurlHttpClient();
$this->expectException(TransportException::class);
$client->get('file:///etc/passwd');
}
public function testRejectsHeaderInjectionAttempt(): void
{
$client = new CurlHttpClient();
$this->expectException(TransportException::class);
$client->get('https://example.com', [
'X-Test' => "ok\r\nInjected: yes",
]);
}
}