From 52ff8a831434b186acbdaa8c3aa9ccc7812d7918 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Dec 2025 10:00:27 -0500 Subject: [PATCH] Initial Version --- .gitignore | 29 +++ composer.json | 13 + lib/Module.php | 88 +++++++ lib/OidcClient.php | 287 ++++++++++++++++++++++ lib/Provider.php | 157 ++++++++++++ package.json | 20 ++ src/components/ConfigPanel.vue | 427 +++++++++++++++++++++++++++++++++ src/main.ts | 12 + tsconfig.app.json | 17 ++ tsconfig.json | 7 + tsconfig.node.json | 21 ++ vite.config.ts | 40 +++ 12 files changed, 1118 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 lib/Module.php create mode 100644 lib/OidcClient.php create mode 100644 lib/Provider.php create mode 100644 package.json create mode 100644 src/components/ConfigPanel.vue create mode 100644 src/main.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812e4cd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..598bbb5 --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..fd9d029 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,88 @@ +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', + ]; + } +} diff --git a/lib/OidcClient.php b/lib/OidcClient.php new file mode 100644 index 0000000..c4793da --- /dev/null +++ b/lib/OidcClient.php @@ -0,0 +1,287 @@ +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); + } +} diff --git a/lib/Provider.php b/lib/Provider.php new file mode 100644 index 0000000..56b7476 --- /dev/null +++ b/lib/Provider.php @@ -0,0 +1,157 @@ +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; + } + +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..13bd62c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/components/ConfigPanel.vue b/src/components/ConfigPanel.vue new file mode 100644 index 0000000..68d0ab1 --- /dev/null +++ b/src/components/ConfigPanel.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..dbdc1b3 --- /dev/null +++ b/src/main.ts @@ -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 +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..5e2a957 --- /dev/null +++ b/tsconfig.app.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..953116c --- /dev/null +++ b/tsconfig.node.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e96dce3 --- /dev/null +++ b/vite.config.ts @@ -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]' + } + } + }, + }, +})