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";