Initial Version
This commit is contained in:
88
lib/Module.php
Normal file
88
lib/Module.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\AuthenticationProviderOidc;
|
||||
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use DI\Attribute\Inject;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
/**
|
||||
* OpenID Connect Identity Provider Module
|
||||
* Provides SSO authentication via OIDC protocol
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProviderManager $providerManager,
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
) {}
|
||||
public function handle(): string
|
||||
{
|
||||
return 'authentication_provider_oidc';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'OpenID Connect Authentication Provider';
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return 'Ktrix';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'OpenID Connect (OIDC) authentication provider - enables SSO authentication with Google, Azure AD, Okta, Keycloak, and other OIDC-compliant identity providers';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Register the provider with the provider manager
|
||||
$this->providerManager->register('authentication', 'oidc', Provider::class);
|
||||
}
|
||||
|
||||
public function install(): void
|
||||
{
|
||||
// Create cache directory for OIDC state
|
||||
$cacheDir = $this->rootDir . '/var/cache/oidc_state';
|
||||
if (!is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function uninstall(): void
|
||||
{
|
||||
// Optionally clean up cache
|
||||
}
|
||||
|
||||
public function enable(): void
|
||||
{
|
||||
// Provider is registered on boot
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
{
|
||||
// Nothing to do - provider not registered when module is disabled
|
||||
}
|
||||
|
||||
public function bootUi(): array
|
||||
{
|
||||
return [
|
||||
'handle' => $this->handle(),
|
||||
'namespace' => 'AuthenticationProviderOidc',
|
||||
'version' => $this->version(),
|
||||
'label' => $this->label(),
|
||||
'author' => $this->author(),
|
||||
'description' => $this->description(),
|
||||
'boot' => 'static/module.mjs',
|
||||
];
|
||||
}
|
||||
}
|
||||
287
lib/OidcClient.php
Normal file
287
lib/OidcClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
157
lib/Provider.php
Normal file
157
lib/Provider.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\AuthenticationProviderOidc;
|
||||
|
||||
use KTXF\Security\Authentication\AuthenticationProviderAbstract;
|
||||
use KTXF\Security\Authentication\ProviderContext;
|
||||
use KTXF\Security\Authentication\ProviderResult;
|
||||
|
||||
/**
|
||||
* OpenID Connect Identity Provider
|
||||
*
|
||||
* Implements OIDC authentication flow for SSO.
|
||||
* Can be used as primary (redirect to IdP) or secondary (step-up authentication).
|
||||
*/
|
||||
class Provider extends AuthenticationProviderAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OidcClient $client,
|
||||
) { }
|
||||
|
||||
// =========================================================================
|
||||
// Provider Implementation
|
||||
// =========================================================================
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return 'authentication';
|
||||
}
|
||||
|
||||
public function identifier(): string
|
||||
{
|
||||
return 'oidc';
|
||||
}
|
||||
|
||||
public function method(): string
|
||||
{
|
||||
return self::METHOD_REDIRECT;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'OpenID Connect';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Authenticate using an OpenID Connect identity provider.';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-circle-o';
|
||||
}
|
||||
|
||||
public function beginRedirect(ProviderContext $context, string $callbackUrl, ?string $returnUrl = null): ProviderResult
|
||||
{
|
||||
$config = $context->config;
|
||||
|
||||
if (empty($config)) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_PROVIDER,
|
||||
'OIDC provider configuration is missing'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$authData = $this->client->beginAuth($config, $callbackUrl, $returnUrl);
|
||||
|
||||
return ProviderResult::redirect(
|
||||
$authData['redirect_url'],
|
||||
[
|
||||
'state' => $authData['state'],
|
||||
'nonce' => $authData['nonce'] ?? null,
|
||||
'return_url' => $returnUrl,
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INTERNAL,
|
||||
'Failed to initiate OIDC authentication: ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
||||
{
|
||||
$config = $context->config;
|
||||
$expectedState = $context->getMeta('state');
|
||||
$expectedNonce = $context->getMeta('nonce');
|
||||
|
||||
if (empty($config)) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_PROVIDER,
|
||||
'OIDC provider configuration is missing'
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($expectedState)) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_CREDENTIALS,
|
||||
'Missing expected state for OIDC verification'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->client->completeAuth($params, $config, $expectedState, $expectedNonce);
|
||||
|
||||
if (!$result || !isset($result['success']) || !$result['success']) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INVALID_CREDENTIALS,
|
||||
$result['error'] ?? 'OIDC authentication failed'
|
||||
);
|
||||
}
|
||||
|
||||
return ProviderResult::success(
|
||||
[
|
||||
'provider' => $this->identifier(),
|
||||
'subject' => $result['sub'] ?? null,
|
||||
'email' => $result['email'] ?? null,
|
||||
'name' => $result['name'] ?? null,
|
||||
'attributes' => $result['attributes'] ?? [],
|
||||
],
|
||||
[
|
||||
'return_url' => $context->getMeta('return_url'),
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return ProviderResult::failed(
|
||||
ProviderResult::ERROR_INTERNAL,
|
||||
'Failed to complete OIDC authentication: ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Attribute Helpers
|
||||
// =========================================================================
|
||||
|
||||
public function getAttributes(array $identity): array
|
||||
{
|
||||
return $identity['attributes'] ?? [];
|
||||
}
|
||||
|
||||
public function mapAttributes(array $attributes, array $mapping): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach ($mapping as $sourceKey => $targetKey) {
|
||||
if (isset($attributes[$sourceKey])) {
|
||||
$mapped[$targetKey] = $attributes[$sourceKey];
|
||||
}
|
||||
}
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user