Files
zyt/server/tests/IamOidcClientTest.php
T

97 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\iam\IamOidcClient;
use Firebase\JWT\JWT;
function iamExpect(bool $ok, string $label): void
{
if (!$ok) { throw new RuntimeException('FAIL: ' . $label); }
}
function iamReject(callable $call, string $label): void
{
try { $call(); } catch (Throwable $error) { return; }
throw new RuntimeException('FAIL: accepted ' . $label);
}
function iamB64(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); }
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$other = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$details = openssl_pkey_get_details($key);
$jwks = ['keys' => [['kty' => 'RSA', 'kid' => 'test', 'alg' => 'RS256', 'use' => 'sig',
'n' => iamB64($details['rsa']['n']), 'e' => iamB64($details['rsa']['e'])]]];
$config = ['issuer' => 'https://iam.example.test', 'client_id' => 'zyt', 'client_secret' => 'secret',
'redirect_uri' => 'https://zyt.example.test/admin/iam/callback', 'api_url' => 'https://iam.example.test',
'application_id' => 'zyt app', 'application_token' => 'application-secret'];
$nonce = str_repeat('n', 32);
$verifier = str_repeat('v', 64);
$metadata = ['issuer' => $config['issuer'], 'authorization_endpoint' => $config['issuer'] . '/oauth/authorize',
'token_endpoint' => $config['issuer'] . '/oauth/token', 'jwks_uri' => $config['issuer'] . '/oauth/jwks'];
$claims = ['iss' => $config['issuer'], 'aud' => 'zyt', 'sub' => 'employee-7', 'nonce' => $nonce,
'iat' => time() - 1, 'exp' => time() + 300, 'nbf' => time() - 1];
$token = JWT::encode($claims, $key, 'RS256', 'test');
$calls = [];
$transport = function (string $method, string $url, array $headers, string $body) use (&$metadata, &$token, &$jwks, &$calls, $config, $verifier): array {
$calls[] = [$method, $url, $headers, $body];
if (str_ends_with($url, '/.well-known/openid-configuration')) { $data = $metadata; }
elseif (str_ends_with($url, '/oauth/token')) {
parse_str($body, $params);
iamExpect($method === 'POST' && $params['code_verifier'] === $verifier && $params['redirect_uri'] === $config['redirect_uri'], 'token form');
iamExpect(in_array('Authorization: Basic ' . base64_encode('zyt:secret'), $headers, true), 'client authentication');
$data = ['id_token' => $token];
} elseif (str_ends_with($url, '/oauth/jwks')) { $data = $jwks; }
elseif (str_ends_with($url, '/provisioning-context')) {
iamExpect(in_array('Authorization: Bearer application-secret', $headers, true), 'application bearer');
iamExpect(str_contains($url, '/zyt%20app/employees/employee-7/'), 'encoded provisioning path');
$data = ['externalAccountId' => '9'];
} else { throw new RuntimeException('Unexpected request'); }
return ['status' => 200, 'body' => json_encode($data, JSON_THROW_ON_ERROR)];
};
$client = new IamOidcClient($config, $transport);
parse_str(parse_url($client->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), PHP_URL_QUERY), $query);
iamExpect($query['code_challenge_method'] === 'S256' && $query['code_challenge'] === iamB64(hash('sha256', $verifier, true)), 'PKCE S256');
iamExpect($client->exchange('code', $verifier, $nonce)['sub'] === 'employee-7', 'valid token');
iamExpect($client->provisioning('employee-7')['externalAccountId'] === '9', 'provisioning');
foreach (['iss' => 'https://other.example.test', 'aud' => 'other', 'nonce' => 'wrong', 'exp' => time() - 1,
'nbf' => time() + 100, 'iat' => time() + 100, 'azp' => 'other', 'sub' => ''] as $field => $value) {
$changed = $claims;
$changed[$field] = $value;
$token = JWT::encode($changed, $key, 'RS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), $field);
}
$changed = $claims;
$changed['aud'] = ['zyt', 'other'];
$token = JWT::encode($changed, $key, 'RS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'multiple audiences without azp');
$changed['azp'] = 'zyt';
$token = JWT::encode($changed, $key, 'RS256', 'test');
iamExpect($client->exchange('code', $verifier, $nonce)['azp'] === 'zyt', 'multiple audience azp');
$token = JWT::encode($claims, $other, 'RS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'wrong signature');
$token = JWT::encode($claims, str_repeat('s', 64), 'HS256', 'test');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'algorithm confusion');
$token = JWT::encode($claims, $key, 'RS256', 'unknown');
iamReject(fn() => $client->exchange('code', $verifier, $nonce), 'unknown kid');
iamReject(fn() => $client->authorizationUrl(str_repeat('s', 32), $nonce, 'short'), 'short verifier');
$metadata['issuer'] = 'https://other.example.test';
iamReject(fn() => (new IamOidcClient($config, $transport))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'discovery issuer');
$metadata['issuer'] = $config['issuer'];
$metadata['jwks_uri'] = 'https://other.example.test/jwks';
iamReject(fn() => (new IamOidcClient($config, $transport))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'cross-origin JWKS');
foreach (['http://iam.example.test', 'https://user@iam.example.test', 'https://iam.example.test/#fragment'] as $invalid) {
$bad = $config;
$bad['issuer'] = $invalid;
iamReject(fn() => new IamOidcClient($bad, $transport), 'invalid HTTPS configuration');
}
$bad = $config;
$bad['api_url'] = 'https://other.example.test';
iamReject(fn() => new IamOidcClient($bad, $transport), 'cross-origin provisioning');
iamReject(fn() => (new IamOidcClient($config, fn() => ['status' => 302, 'body' => '{}']))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'HTTP redirect');
iamReject(fn() => (new IamOidcClient($config, fn() => ['status' => 200, 'body' => 'invalid']))->authorizationUrl(str_repeat('s', 32), $nonce, $verifier), 'invalid JSON');
echo "IamOidcClientTest passed (offline RSA, claims, discovery, PKCE, provisioning, HTTPS)\n";