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); } }