Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ token validator. Never expose a client secret or token in browser code.

The administrative API is intentionally excluded from this package.

## Transport and correlation

All discovery, token, and JWKS requests enforce certificate and hostname
verification, reject HTTP redirects, and use bounded connect/read timeouts.
Callers may pass a safe correlation identifier as the optional final argument
to `discover`, `exchange`, and ID-token validation methods; the SDK propagates
it as `X-Correlation-ID`. The SDK rejects malformed values and never logs
authorization codes, client secrets, or tokens.

## Security

Only explicit HTTPS endpoints are accepted. JWT signing is pinned to RS256, keys
Expand Down
11 changes: 6 additions & 5 deletions src/Oidc/AuthorizationCodeClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public function __construct(private readonly ClientInterface $http)
{
}

public function exchange(OidcClientConfiguration $configuration, string $code, string $codeVerifier): OidcTokenSet
public function exchange(OidcClientConfiguration $configuration, string $code, string $codeVerifier, ?string $correlationId = null): OidcTokenSet
{
if ($code === '' || $codeVerifier === '') {
throw new OidcException('Authorization code and PKCE verifier are required.');
Expand All @@ -29,10 +29,11 @@ public function exchange(OidcClientConfiguration $configuration, string $code, s

try {
$response = $this->http->request('POST', $configuration->tokenEndpoint, [
'timeout' => $configuration->httpTimeoutSeconds,
'connect_timeout' => $configuration->httpTimeoutSeconds,
'http_errors' => false,
'headers' => ['Accept' => 'application/json'],
...OidcHttpRequestOptions::strict(
$configuration->httpTimeoutSeconds,
['Accept' => 'application/json'],
$correlationId,
),
'form_params' => $form,
]);
} catch (\Throwable $exception) {
Expand Down
18 changes: 9 additions & 9 deletions src/Oidc/IdTokenValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ public function __construct(private readonly ClientInterface $http)

/** @return array<string, mixed> */
/** @return array<string, mixed> */
public function validate(OidcClientConfiguration $configuration, string $idToken, ?string $expectedNonce = null): array
public function validate(OidcClientConfiguration $configuration, string $idToken, ?string $expectedNonce = null, ?string $correlationId = null): array
{
$payload = $this->verifiedClaims($configuration, $idToken);
$payload = $this->verifiedClaims($configuration, $idToken, $correlationId);
$this->assertIssuerAndAudience($configuration, $payload);
if (trim((string) ($payload['sub'] ?? '')) === '') {
throw new OidcException('ID token subject is missing.');
Expand All @@ -37,9 +37,9 @@ public function validate(OidcClientConfiguration $configuration, string $idToken
}

/** @return array<string, mixed> */
public function validateLogoutToken(OidcClientConfiguration $configuration, string $logoutToken): array
public function validateLogoutToken(OidcClientConfiguration $configuration, string $logoutToken, ?string $correlationId = null): array
{
$payload = $this->verifiedClaims($configuration, $logoutToken);
$payload = $this->verifiedClaims($configuration, $logoutToken, $correlationId);
$this->assertIssuerAndAudience($configuration, $payload);
if (! is_numeric($payload['iat'] ?? null) || (int) $payload['iat'] > time() + 30 || trim((string) ($payload['jti'] ?? '')) === '') {
throw new OidcException('Back-channel logout token time or identifier claims are invalid.');
Expand All @@ -49,18 +49,18 @@ public function validateLogoutToken(OidcClientConfiguration $configuration, stri
}

/** @return array<string, mixed> */
private function verifiedClaims(OidcClientConfiguration $configuration, string $token): array
private function verifiedClaims(OidcClientConfiguration $configuration, string $token, ?string $correlationId): array
{
$header = $this->decodeHeader($token);
if (($header['alg'] ?? null) !== 'RS256' || ! is_string($header['kid'] ?? null) || $header['kid'] === '') {
throw new OidcException('JWT header does not use an allowed signing algorithm.');
}

$keys = $this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, false);
$keys = $this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, false, $correlationId);
$keySet = JWK::parseKeySet($keys);
$kid = $header['kid'];
if (! isset($keySet[$kid])) {
$keySet = JWK::parseKeySet($this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, true));
$keySet = JWK::parseKeySet($this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, true, $correlationId));
}
$key = $keySet[$kid] ?? null;
if (! $key instanceof Key) {
Expand Down Expand Up @@ -93,13 +93,13 @@ private function assertIssuerAndAudience(OidcClientConfiguration $configuration,

/** @return array<string, mixed> */
/** @return array<string, mixed> */
private function jwks(string $uri, int $timeoutSeconds, bool $refresh): array
private function jwks(string $uri, int $timeoutSeconds, bool $refresh, ?string $correlationId): array
{
if (! $refresh && isset($this->jwksByUri[$uri])) {
return $this->jwksByUri[$uri];
}
try {
$response = $this->http->request('GET', $uri, ['timeout' => $timeoutSeconds, 'connect_timeout' => $timeoutSeconds, 'http_errors' => false]);
$response = $this->http->request('GET', $uri, OidcHttpRequestOptions::strict($timeoutSeconds, [], $correlationId));
} catch (\Throwable $exception) {
throw new OidcException('JWKS request failed.', 0, $exception);
}
Expand Down
26 changes: 18 additions & 8 deletions src/Oidc/OidcDiscoveryClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,19 @@ public function __construct(private readonly ClientInterface $http)
{
}

public function discover(string $issuer, int $timeoutSeconds = 5): OidcDiscoveryDocument
public function discover(string $issuer, int $timeoutSeconds = 5, ?string $correlationId = null): OidcDiscoveryDocument
{
$issuer = rtrim($issuer, '/');
if (filter_var($issuer, FILTER_VALIDATE_URL) === false || parse_url($issuer, PHP_URL_SCHEME) !== 'https') {
throw new OidcException('OIDC issuer must be an explicit HTTPS URL.');
}

try {
$response = $this->http->request('GET', $issuer.'/.well-known/openid-configuration', [
'timeout' => $timeoutSeconds,
'connect_timeout' => $timeoutSeconds,
'http_errors' => false,
'headers' => ['Accept' => 'application/json'],
]);
$response = $this->http->request('GET', $issuer.'/.well-known/openid-configuration', OidcHttpRequestOptions::strict(
$timeoutSeconds,
['Accept' => 'application/json'],
$correlationId,
));
} catch (\Throwable $exception) {
throw new OidcException('OIDC discovery request failed.', 0, $exception);
}
Expand All @@ -34,11 +33,17 @@ public function discover(string $issuer, int $timeoutSeconds = 5): OidcDiscovery
}

foreach (['authorization_endpoint', 'token_endpoint', 'jwks_uri'] as $field) {
if (! is_string($document[$field] ?? null) || $document[$field] === '') {
if (! is_string($document[$field] ?? null) || ! self::isHttpsUrl($document[$field])) {
throw new OidcException('OIDC discovery document is missing '.$field.'.');
}
}

foreach (['userinfo_endpoint', 'end_session_endpoint'] as $field) {
if (isset($document[$field]) && (! is_string($document[$field]) || ! self::isHttpsUrl($document[$field]))) {
throw new OidcException('OIDC discovery document contains an invalid '.$field.'.');
}
}

return new OidcDiscoveryDocument(
issuer: $issuer,
authorizationEndpoint: (string) $document['authorization_endpoint'],
Expand All @@ -48,4 +53,9 @@ public function discover(string $issuer, int $timeoutSeconds = 5): OidcDiscovery
endSessionEndpoint: is_string($document['end_session_endpoint'] ?? null) ? $document['end_session_endpoint'] : null,
);
}

private static function isHttpsUrl(string $url): bool
{
return filter_var($url, FILTER_VALIDATE_URL) !== false && parse_url($url, PHP_URL_SCHEME) === 'https';
}
}
41 changes: 41 additions & 0 deletions src/Oidc/OidcHttpRequestOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Novvor\IdentitySdk\Oidc;

/**
* Builds the non-negotiable transport options for OIDC protocol calls.
*
* Consumers may choose a custom CA bundle on their HTTP client, but protocol
* calls can never opt out of certificate or hostname verification.
*/
final class OidcHttpRequestOptions
{
/**
* @param array<string, string> $headers
* @return array<string, mixed>
*/
public static function strict(int $timeoutSeconds, array $headers = [], ?string $correlationId = null): array
{
if ($timeoutSeconds < 1 || $timeoutSeconds > 30) {
throw new OidcException('OIDC HTTP timeout must be between one and thirty seconds.');
}

if ($correlationId !== null) {
$correlationId = trim($correlationId);
if ($correlationId === '' || strlen($correlationId) > 128 || preg_match('/^[A-Za-z0-9._:-]+$/', $correlationId) !== 1) {
throw new OidcException('OIDC correlation ID is invalid.');
}

$headers['X-Correlation-ID'] = $correlationId;
}

return [
'timeout' => $timeoutSeconds,
'connect_timeout' => $timeoutSeconds,
'http_errors' => false,
'allow_redirects' => false,
'verify' => true,
'headers' => $headers,
];
}
}
80 changes: 80 additions & 0 deletions tests/OidcTransportSecurityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Novvor\IdentitySdk\Tests;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Response;
use Novvor\IdentitySdk\Oidc\AuthorizationCodeClient;
use Novvor\IdentitySdk\Oidc\OidcClientConfiguration;
use Novvor\IdentitySdk\Oidc\OidcDiscoveryClient;
use Novvor\IdentitySdk\Oidc\OidcException;
use Novvor\IdentitySdk\Oidc\OidcHttpRequestOptions;
use PHPUnit\Framework\TestCase;

final class OidcTransportSecurityTest extends TestCase
{
public function test_discovery_requires_https_endpoints(): void
{
$client = $this->client([
new Response(200, ['Content-Type' => 'application/json'], json_encode([
'issuer' => 'https://identity.example.com',
'authorization_endpoint' => 'https://identity.example.com/oauth/authorize',
'token_endpoint' => 'https://identity.example.com/oauth/token',
'jwks_uri' => 'https://identity.example.com/.well-known/jwks.json',
], JSON_THROW_ON_ERROR)),
]);

$document = (new OidcDiscoveryClient($client))->discover('https://identity.example.com');

self::assertSame('https://identity.example.com/oauth/token', $document->tokenEndpoint);
}

public function test_token_exchange_and_strict_transport_options_reject_invalid_correlation_id(): void
{
$client = $this->client([
new Response(200, ['Content-Type' => 'application/json'], json_encode([
'access_token' => 'access-token',
'id_token' => 'id-token',
'token_type' => 'Bearer',
'expires_in' => 300,
], JSON_THROW_ON_ERROR)),
]);

$tokens = (new AuthorizationCodeClient($client))->exchange($this->configuration(), 'code', str_repeat('a', 43));

self::assertSame('access-token', $tokens->accessToken);
self::assertSame([
'timeout' => 5,
'connect_timeout' => 5,
'http_errors' => false,
'allow_redirects' => false,
'verify' => true,
'headers' => ['Accept' => 'application/json', 'X-Correlation-ID' => 'corr-456'],
], OidcHttpRequestOptions::strict(5, ['Accept' => 'application/json'], 'corr-456'));

$this->expectException(OidcException::class);
OidcHttpRequestOptions::strict(5, [], "invalid\nheader");
}

/** @param list<Response> $responses */
private function client(array $responses): Client
{
return new Client(['handler' => \GuzzleHttp\HandlerStack::create(new MockHandler($responses))]);
}

private function configuration(): OidcClientConfiguration
{
return new OidcClientConfiguration(
'https://identity.example.com',
'client',
'https://app.example.com/auth/oidc/callback',
'https://identity.example.com/oauth/authorize',
'https://identity.example.com/oauth/token',
'https://identity.example.com/.well-known/jwks.json',
'client-secret',
);
}
}
Loading