Initial Version
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/lib/vendor/
|
||||||
|
coverage/
|
||||||
|
phpunit.xml.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
13
composer.json
Normal file
13
composer.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "ktrix/authentication-provider-oidc",
|
||||||
|
"description": "Ktrix OpenID Connect authentication provider for SSO authentication",
|
||||||
|
"type": "project",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXM\\AuthenticationProviderOidc\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@ktrix/authentication-provider-oidc",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.1",
|
||||||
|
"vue-tsc": "^2.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
427
src/components/ConfigPanel.vue
Normal file
427
src/components/ConfigPanel.vue
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
<template>
|
||||||
|
<div class="config-panel">
|
||||||
|
<h3>OpenID Connect Configuration</h3>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<p class="description">
|
||||||
|
Configure your OIDC identity provider settings. This provider enables single sign-on (SSO)
|
||||||
|
with services like Google, Azure AD, Okta, Keycloak, and other OIDC-compliant identity providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>Provider Settings</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="enabled">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
v-model="config.enabled"
|
||||||
|
@change="emitUpdate"
|
||||||
|
/>
|
||||||
|
Enable Provider
|
||||||
|
</label>
|
||||||
|
<p class="help-text">Allow users to authenticate with this OIDC provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="priority">Display Priority</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="priority"
|
||||||
|
v-model.number="config.priority"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
@change="emitUpdate"
|
||||||
|
/>
|
||||||
|
<p class="help-text">Lower numbers appear first on the login page</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="label">Button Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="label"
|
||||||
|
v-model="config.label"
|
||||||
|
placeholder="Sign in with SSO"
|
||||||
|
@input="emitUpdate"
|
||||||
|
/>
|
||||||
|
<p class="help-text">Text displayed on the SSO login button</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>OIDC Configuration</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="issuer">Issuer URL *</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="issuer"
|
||||||
|
v-model="config.config.issuer"
|
||||||
|
placeholder="https://accounts.google.com"
|
||||||
|
required
|
||||||
|
@input="emitUpdate"
|
||||||
|
/>
|
||||||
|
<p class="help-text">
|
||||||
|
The OIDC issuer URL. Common values:
|
||||||
|
<br>• Google: https://accounts.google.com
|
||||||
|
<br>• Azure AD: https://login.microsoftonline.com/{tenant}/v2.0
|
||||||
|
<br>• Okta: https://{domain}.okta.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client_id">Client ID *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="client_id"
|
||||||
|
v-model="config.config.client_id"
|
||||||
|
placeholder="your-client-id"
|
||||||
|
required
|
||||||
|
@input="emitUpdate"
|
||||||
|
/>
|
||||||
|
<p class="help-text">OAuth2 client ID from your identity provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client_secret">Client Secret *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="client_secret"
|
||||||
|
v-model="config.config.client_secret"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
@input="emitUpdate"
|
||||||
|
/>
|
||||||
|
<p class="help-text">OAuth2 client secret from your identity provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scopes">Scopes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="scopes"
|
||||||
|
v-model="scopesString"
|
||||||
|
placeholder="openid email profile"
|
||||||
|
@input="updateScopes"
|
||||||
|
/>
|
||||||
|
<p class="help-text">Space-separated list of OIDC scopes to request</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>Provisioning</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="provisioning">Provisioning Mode</label>
|
||||||
|
<select id="provisioning" v-model="config.provisioning" @change="emitUpdate">
|
||||||
|
<option value="manual">Manual - Admin creates users before first login</option>
|
||||||
|
<option value="auto">Automatic - Create users on first login</option>
|
||||||
|
</select>
|
||||||
|
<p class="help-text">
|
||||||
|
Automatic provisioning creates local user accounts when users authenticate for the first time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" v-if="config.provisioning === 'auto'">
|
||||||
|
<label for="default_roles">Default Roles</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="default_roles"
|
||||||
|
v-model="defaultRolesString"
|
||||||
|
placeholder="role-id-1, role-id-2"
|
||||||
|
@input="updateDefaultRoles"
|
||||||
|
/>
|
||||||
|
<p class="help-text">Comma-separated role IDs to assign to auto-provisioned users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<h4>Attribute Mapping</h4>
|
||||||
|
<p class="help-text">Map OIDC claims to user profile fields</p>
|
||||||
|
|
||||||
|
<div class="attribute-map">
|
||||||
|
<div class="attribute-row" v-for="(target, source) in config.config.attribute_map" :key="source">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="source"
|
||||||
|
placeholder="OIDC claim"
|
||||||
|
@input="updateAttributeSource($event, source)"
|
||||||
|
/>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
:value="target"
|
||||||
|
placeholder="User field"
|
||||||
|
@input="updateAttributeTarget($event, source)"
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn-remove" @click="removeAttribute(source)">×</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-add" @click="addAttribute">+ Add Mapping</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section callback-info">
|
||||||
|
<h4>Callback URL</h4>
|
||||||
|
<p class="help-text">
|
||||||
|
Configure this URL as the redirect/callback URI in your identity provider:
|
||||||
|
</p>
|
||||||
|
<code class="callback-url">{{ callbackUrl }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
interface OidcConfig {
|
||||||
|
issuer: string
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
scopes: string[]
|
||||||
|
attribute_map: Record<string, string>
|
||||||
|
default_roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderConfig {
|
||||||
|
enabled: boolean
|
||||||
|
priority: number
|
||||||
|
label: string
|
||||||
|
provisioning: 'manual' | 'auto'
|
||||||
|
config: OidcConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: Partial<ProviderConfig>
|
||||||
|
tenantDomain?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: ProviderConfig): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const defaultAttributeMap: Record<string, string> = {
|
||||||
|
'email': 'identity',
|
||||||
|
'name': 'label',
|
||||||
|
'given_name': 'profile.name_first',
|
||||||
|
'family_name': 'profile.name_last'
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = reactive<ProviderConfig>({
|
||||||
|
enabled: props.modelValue.enabled ?? false,
|
||||||
|
priority: props.modelValue.priority ?? 2,
|
||||||
|
label: props.modelValue.label ?? 'Sign in with SSO',
|
||||||
|
provisioning: props.modelValue.provisioning ?? 'manual',
|
||||||
|
config: {
|
||||||
|
issuer: props.modelValue.config?.issuer ?? '',
|
||||||
|
client_id: props.modelValue.config?.client_id ?? '',
|
||||||
|
client_secret: props.modelValue.config?.client_secret ?? '',
|
||||||
|
scopes: props.modelValue.config?.scopes ?? ['openid', 'email', 'profile'],
|
||||||
|
attribute_map: props.modelValue.config?.attribute_map ?? { ...defaultAttributeMap },
|
||||||
|
default_roles: props.modelValue.config?.default_roles ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scopesString = computed({
|
||||||
|
get: () => config.config.scopes.join(' '),
|
||||||
|
set: () => {} // Handled by updateScopes
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultRolesString = computed({
|
||||||
|
get: () => config.config.default_roles.join(', '),
|
||||||
|
set: () => {} // Handled by updateDefaultRoles
|
||||||
|
})
|
||||||
|
|
||||||
|
const callbackUrl = computed(() => {
|
||||||
|
const domain = props.tenantDomain || window.location.origin
|
||||||
|
return `${domain}/security/provider/oidc/callback`
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
config.enabled = newValue.enabled ?? false
|
||||||
|
config.priority = newValue.priority ?? 2
|
||||||
|
config.label = newValue.label ?? 'Sign in with SSO'
|
||||||
|
config.provisioning = newValue.provisioning ?? 'manual'
|
||||||
|
if (newValue.config) {
|
||||||
|
config.config.issuer = newValue.config.issuer ?? ''
|
||||||
|
config.config.client_id = newValue.config.client_id ?? ''
|
||||||
|
config.config.client_secret = newValue.config.client_secret ?? ''
|
||||||
|
config.config.scopes = newValue.config.scopes ?? ['openid', 'email', 'profile']
|
||||||
|
config.config.attribute_map = newValue.config.attribute_map ?? { ...defaultAttributeMap }
|
||||||
|
config.config.default_roles = newValue.config.default_roles ?? []
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
function emitUpdate() {
|
||||||
|
emit('update:modelValue', JSON.parse(JSON.stringify(config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScopes(event: Event) {
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
config.config.scopes = value.split(/\s+/).filter(s => s.length > 0)
|
||||||
|
emitUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDefaultRoles(event: Event) {
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
config.config.default_roles = value.split(',').map(s => s.trim()).filter(s => s.length > 0)
|
||||||
|
emitUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAttributeSource(event: Event, oldSource: string) {
|
||||||
|
const newSource = (event.target as HTMLInputElement).value
|
||||||
|
if (newSource !== oldSource) {
|
||||||
|
const target = config.config.attribute_map[oldSource]
|
||||||
|
delete config.config.attribute_map[oldSource]
|
||||||
|
if (newSource) {
|
||||||
|
config.config.attribute_map[newSource] = target
|
||||||
|
}
|
||||||
|
emitUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAttributeTarget(event: Event, source: string) {
|
||||||
|
const newTarget = (event.target as HTMLInputElement).value
|
||||||
|
config.config.attribute_map[source] = newTarget
|
||||||
|
emitUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAttribute() {
|
||||||
|
config.config.attribute_map[''] = ''
|
||||||
|
emitUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttribute(source: string) {
|
||||||
|
delete config.config.attribute_map[source]
|
||||||
|
emitUpdate()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel h4 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group > label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="url"],
|
||||||
|
.form-group input[type="password"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="checkbox"] {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute-map {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attribute-row .arrow {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
background: var(--color-background-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callback-info {
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callback-url {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
src/main.ts
Normal file
12
src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Identity Provider OIDC Module Entry
|
||||||
|
// Exports components for admin configuration UI
|
||||||
|
|
||||||
|
import ConfigPanel from './components/ConfigPanel.vue'
|
||||||
|
|
||||||
|
export {
|
||||||
|
ConfigPanel
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
ConfigPanel
|
||||||
|
}
|
||||||
17
tsconfig.app.json
Normal file
17
tsconfig.app.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@KTXC/*": ["../../core/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
21
tsconfig.node.json
Normal file
21
tsconfig.node.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
40
vite.config.ts
Normal file
40
vite.config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../core/src')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env': {},
|
||||||
|
'process': undefined,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: true,
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'src/main.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'module.mjs',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['vue', 'vue-router', 'pinia'],
|
||||||
|
output: {
|
||||||
|
assetFileNames: (assetInfo) => {
|
||||||
|
if (assetInfo.name?.endsWith('.css')) {
|
||||||
|
return 'identity-provider-oidc-[hash].css'
|
||||||
|
}
|
||||||
|
return '[name]-[hash][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user