Initial Version

This commit is contained in:
root
2025-12-21 10:00:27 -05:00
commit 52ff8a8314
12 changed files with 1118 additions and 0 deletions

287
lib/OidcClient.php Normal file
View File

@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace KTXM\AuthenticationProviderOidc;
/**
* OIDC Client
* Handles OpenID Connect protocol operations
*/
class OidcClient
{
private array $discoveryCache = [];
/**
* Begin the OIDC authorization flow
*/
public function beginAuth(array $config, string $callbackUrl, ?string $returnUrl = null): array
{
$issuer = rtrim($config['issuer'] ?? '', '/');
$clientId = $config['client_id'] ?? '';
$scopes = $config['scopes'] ?? ['openid', 'email', 'profile'];
if (empty($issuer) || empty($clientId)) {
throw new \InvalidArgumentException('OIDC issuer and client_id are required');
}
// Discover OIDC endpoints
$discovery = $this->discover($issuer);
$authEndpoint = $discovery['authorization_endpoint'] ?? null;
if (!$authEndpoint) {
throw new \RuntimeException('Could not find authorization_endpoint in OIDC discovery');
}
// Generate state and nonce for security
$state = bin2hex(random_bytes(32));
$nonce = bin2hex(random_bytes(32));
// Build authorization URL
$params = [
'client_id' => $clientId,
'redirect_uri' => $callbackUrl,
'response_type' => 'code',
'scope' => implode(' ', $scopes),
'state' => $state,
'nonce' => $nonce,
];
// Add PKCE if supported (recommended)
$codeVerifier = bin2hex(random_bytes(32));
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$params['code_challenge'] = $codeChallenge;
$params['code_challenge_method'] = 'S256';
$redirectUrl = $authEndpoint . '?' . http_build_query($params);
return [
'redirect_url' => $redirectUrl,
'state' => $state,
'nonce' => $nonce,
'code_verifier' => $codeVerifier,
];
}
/**
* Complete the OIDC authorization flow
*/
public function completeAuth(array $params, array $config, string $expectedState, ?string $expectedNonce = null): ?array
{
// Check for error response
if (isset($params['error'])) {
return [
'success' => false,
'error' => $params['error_description'] ?? $params['error'],
];
}
// Validate state
$state = $params['state'] ?? null;
if ($state !== $expectedState) {
return [
'success' => false,
'error' => 'Invalid state parameter',
];
}
// Get authorization code
$code = $params['code'] ?? null;
if (!$code) {
return [
'success' => false,
'error' => 'Missing authorization code',
];
}
$issuer = rtrim($config['issuer'] ?? '', '/');
$clientId = $config['client_id'] ?? '';
$clientSecret = $config['client_secret'] ?? '';
$callbackUrl = $params['redirect_uri'] ?? $config['callback_url'] ?? '';
$codeVerifier = $params['code_verifier'] ?? null;
// Discover OIDC endpoints
$discovery = $this->discover($issuer);
$tokenEndpoint = $discovery['token_endpoint'] ?? null;
if (!$tokenEndpoint) {
return [
'success' => false,
'error' => 'Could not find token_endpoint in OIDC discovery',
];
}
// Exchange code for tokens
$tokenParams = [
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'redirect_uri' => $callbackUrl,
];
if ($codeVerifier) {
$tokenParams['code_verifier'] = $codeVerifier;
}
$tokenResponse = $this->httpPost($tokenEndpoint, $tokenParams);
if (!$tokenResponse || isset($tokenResponse['error'])) {
return [
'success' => false,
'error' => $tokenResponse['error_description'] ?? $tokenResponse['error'] ?? 'Token exchange failed',
];
}
// Parse ID token
$idToken = $tokenResponse['id_token'] ?? null;
if (!$idToken) {
return [
'success' => false,
'error' => 'No ID token in response',
];
}
$claims = $this->parseIdToken($idToken);
if (!$claims) {
return [
'success' => false,
'error' => 'Failed to parse ID token',
];
}
// Validate nonce if provided
if ($expectedNonce && ($claims['nonce'] ?? null) !== $expectedNonce) {
return [
'success' => false,
'error' => 'Invalid nonce in ID token',
];
}
// Extract user attributes
$externalSubject = $claims['sub'] ?? null;
$email = $claims['email'] ?? null;
$name = $claims['name'] ?? null;
if (!$externalSubject) {
return [
'success' => false,
'error' => 'Missing subject claim in ID token',
];
}
return [
'success' => true,
'identity' => $email,
'external_subject' => $externalSubject,
'attributes' => [
'sub' => $externalSubject,
'email' => $email,
'email_verified' => $claims['email_verified'] ?? false,
'name' => $name,
'given_name' => $claims['given_name'] ?? null,
'family_name' => $claims['family_name'] ?? null,
'picture' => $claims['picture'] ?? null,
'locale' => $claims['locale'] ?? null,
],
'raw' => $claims,
'access_token' => $tokenResponse['access_token'] ?? null,
'refresh_token' => $tokenResponse['refresh_token'] ?? null,
];
}
/**
* Discover OIDC configuration from well-known endpoint
*/
public function discover(string $issuer): array
{
if (isset($this->discoveryCache[$issuer])) {
return $this->discoveryCache[$issuer];
}
$discoveryUrl = $issuer . '/.well-known/openid-configuration';
$response = $this->httpGet($discoveryUrl);
if (!$response) {
throw new \RuntimeException("Failed to fetch OIDC discovery document from {$discoveryUrl}");
}
$this->discoveryCache[$issuer] = $response;
return $response;
}
/**
* Parse and decode ID token (JWT)
* Note: In production, you should also verify the signature
*/
private function parseIdToken(string $idToken): ?array
{
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
return null;
}
$payload = $parts[1];
$payload = strtr($payload, '-_', '+/');
$payload = base64_decode($payload, true);
if (!$payload) {
return null;
}
$claims = json_decode($payload, true);
if (!is_array($claims)) {
return null;
}
return $claims;
}
/**
* Make HTTP GET request
*/
private function httpGet(string $url): ?array
{
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => 'Accept: application/json',
'timeout' => 10,
],
]);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
return null;
}
return json_decode($response, true);
}
/**
* Make HTTP POST request
*/
private function httpPost(string $url, array $data): ?array
{
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\nAccept: application/json",
'content' => http_build_query($data),
'timeout' => 10,
],
]);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
return null;
}
return json_decode($response, true);
}
}