MAJOR: Replace keycloak-js with nuxt-oidc-auth for seamless SSO integration

##  **SOLUTION: Migrate to Server-Side OIDC Authentication**

This completely replaces the problematic keycloak-js client-side implementation
with nuxt-oidc-auth, eliminating all CORS and iframe issues.

###  **Benefits:**
- **No more CORS errors** - Server-side OAuth flow
- **No iframe dependencies** - Eliminates cross-domain issues
- **Works with nginx proxy** - No proxy configuration conflicts
- **Better security** - Tokens handled server-side
- **Cleaner integration** - Native Nuxt patterns
- **Maintains Directus compatibility** - Dual auth support

###  **Installation & Configuration:**
- Added
uxt-oidc-auth module to nuxt.config.ts
- Configured Keycloak provider with proper OIDC settings
- Updated environment variables for security keys

###  **Code Changes:**

#### **Authentication Flow:**
- **middleware/authentication.ts** - Updated to check both Directus + OIDC auth
- **composables/useUnifiedAuth.ts** - Migrated to use useOidcAuth()
- **pages/login.vue** - Updated SSO button to use oidcLogin('keycloak')

#### **Configuration:**
- **nuxt.config.ts** - Added OIDC provider configuration
- **.env.example** - Updated with nuxt-oidc-auth environment variables
- Removed old Keycloak runtime config

#### **Cleanup:**
- Removed keycloak-js dependency from package.json
- Deleted obsolete files:
  - composables/useKeycloak.ts
  - pages/auth/callback.vue
  - server/utils/keycloak-oauth.ts
  - server/api/debug/ directory

###  **Authentication Routes (Auto-Generated):**
- /auth/keycloak/login - SSO login endpoint
- /auth/keycloak/logout - SSO logout endpoint
- /auth/keycloak/callback - OAuth callback (handled automatically)

###  **Security Setup Required:**
Environment variables needed for production:
- NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_SECRET
- NUXT_OIDC_TOKEN_KEY (base64 encoded 32-byte key)
- NUXT_OIDC_SESSION_SECRET (48-character random string)
- NUXT_OIDC_AUTH_SESSION_SECRET (48-character random string)

###  **Expected Results:**
 SSO login should work without CORS errors
 Compatible with nginx proxy setup
 Maintains existing Directus authentication
 Server-side session management
 Automatic token refresh

Ready for container rebuild and production testing!
This commit is contained in:
Matt 2025-06-14 15:58:03 +02:00
parent c29f64b50b
commit 0ae190b255
12 changed files with 1693 additions and 779 deletions

View File

@ -21,14 +21,8 @@ NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
# Webhook Configuration for Embedded Signing
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
# Keycloak Configuration
KEYCLOAK_ISSUER=https://auth.portnimara.dev/realms/client-portal
KEYCLOAK_CLIENT_ID=client-portal
KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
KEYCLOAK_CALLBACK_URL=https://client.portnimara.dev/auth/callback
# For local development, use: http://localhost:3000/auth/callback
# OIDC Session Configuration
OIDC_SESSION_SECRET=your-32-character-session-secret
OIDC_ENCRYPT_KEY=your-32-character-encryption-key
OIDC_ENCRYPT_IV=16-char-enc-iv!
# nuxt-oidc-auth Configuration
NUXT_OIDC_PROVIDERS_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
NUXT_OIDC_TOKEN_KEY=base64_encoded_32_byte_key
NUXT_OIDC_SESSION_SECRET=48_character_random_string_for_session_security
NUXT_OIDC_AUTH_SESSION_SECRET=48_character_random_string_for_auth_session

View File

@ -1,211 +0,0 @@
import type Keycloak from 'keycloak-js'
export const useKeycloak = () => {
const config = useRuntimeConfig()
const keycloak = ref<Keycloak | null>(null)
const isAuthenticated = ref(false)
const user = ref<any>(null)
const token = ref<string | null>(null)
const isInitialized = ref(false)
// Get the correct base URL considering proxy setup
const getBaseUrl = () => {
if (process.server) {
// In production, always use the configured HTTPS base URL
return config.public.baseUrl
}
// Client-side: detect if we're behind a proxy
const currentOrigin = window.location.origin
const isProduction = !currentOrigin.includes('localhost') && !currentOrigin.includes('127.0.0.1')
if (isProduction) {
// Force HTTPS in production environments
return config.public.baseUrl
}
// Development: use current origin
return currentOrigin
}
const logDebug = (message: string, data?: any) => {
if (config.public.keycloakDebug) {
console.log(`[KEYCLOAK] ${message}`, data)
}
}
const initKeycloak = async () => {
if (process.server) return false
try {
const baseUrl = getBaseUrl()
logDebug('Initializing Keycloak', {
baseUrl,
keycloakUrl: config.public.keycloak.url,
realm: config.public.keycloak.realm,
clientId: config.public.keycloak.clientId
})
// Dynamically import keycloak-js
const KeycloakModule = await import('keycloak-js')
const KeycloakConstructor = KeycloakModule.default
keycloak.value = new KeycloakConstructor({
url: config.public.keycloak.url,
realm: config.public.keycloak.realm,
clientId: config.public.keycloak.clientId,
})
const authenticated = await keycloak.value.init({
onLoad: 'check-sso',
// Use proper HTTPS redirect URI
redirectUri: `${baseUrl}/auth/callback`,
// Disable all iframe-based features that cause CORS issues
checkLoginIframe: false,
silentCheckSsoRedirectUri: undefined, // Disable silent SSO check
enableLogging: false, // Reduce console noise
// Use standard flow compatible with proxy setups
flow: 'standard',
responseMode: 'query', // Use query params instead of fragments
// Disable third-party cookie checks
checkLoginIframeInterval: 0,
// PKCE for security
pkceMethod: 'S256',
// Timeout settings
messageReceiveTimeout: 10000,
// Disable adapter features that can cause issues in proxied environments
adapter: 'default'
})
logDebug('Keycloak initialization result', {
authenticated,
redirectUri: `${baseUrl}/auth/callback`,
checkLoginIframe: false,
silentSso: 'disabled'
})
isAuthenticated.value = authenticated
isInitialized.value = true
if (authenticated && keycloak.value.token) {
token.value = keycloak.value.token || null
user.value = {
id: keycloak.value.subject,
username: keycloak.value.tokenParsed?.preferred_username,
email: keycloak.value.tokenParsed?.email,
firstName: keycloak.value.tokenParsed?.given_name,
lastName: keycloak.value.tokenParsed?.family_name,
fullName: keycloak.value.tokenParsed?.name,
roles: keycloak.value.tokenParsed?.realm_access?.roles || [],
}
// Set up token refresh
keycloak.value.onTokenExpired = () => {
keycloak.value?.updateToken(30).then((refreshed) => {
if (refreshed) {
token.value = keycloak.value?.token || null
console.log('Token refreshed')
} else {
console.log('Token still valid')
}
}).catch(() => {
console.log('Failed to refresh token')
logout()
})
}
}
return authenticated
} catch (error) {
console.error('Failed to initialize Keycloak:', error)
isInitialized.value = true
return false
}
}
const login = async () => {
console.log('[KEYCLOAK] Login function called')
if (!keycloak.value) {
console.log('[KEYCLOAK] Keycloak not initialized, initializing now...')
await initKeycloak()
}
if (keycloak.value) {
try {
const baseUrl = getBaseUrl()
console.log('[KEYCLOAK] Starting login', {
redirectUri: `${baseUrl}/dashboard`,
keycloakInitialized: !!keycloak.value,
keycloakAuthenticated: keycloak.value.authenticated
})
await keycloak.value.login({
redirectUri: `${baseUrl}/dashboard`
})
console.log('[KEYCLOAK] Login method completed successfully')
} catch (error) {
console.error('[KEYCLOAK] Login failed:', error)
if (error instanceof Error) {
console.error('[KEYCLOAK] Error details:', {
message: error.message,
stack: error.stack,
name: error.name
})
} else {
console.error('[KEYCLOAK] Unknown error type:', error)
}
throw error
}
} else {
console.error('[KEYCLOAK] No keycloak instance available for login')
throw new Error('Keycloak not available')
}
}
const logout = async () => {
if (keycloak.value) {
try {
await keycloak.value.logout({
redirectUri: window.location.origin
})
} catch (error) {
console.error('Logout failed:', error)
}
}
// Clear local state
isAuthenticated.value = false
user.value = null
token.value = null
}
const getToken = () => {
return token.value
}
const hasRole = (role: string) => {
return user.value?.roles?.includes(role) || false
}
const hasAnyRole = (roles: string[]) => {
return roles.some(role => hasRole(role))
}
return {
keycloak: readonly(keycloak),
isAuthenticated: readonly(isAuthenticated),
user: readonly(user),
token: readonly(token),
isInitialized: readonly(isInitialized),
initKeycloak,
login,
logout,
getToken,
hasRole,
hasAnyRole,
}
}

View File

@ -11,29 +11,27 @@ export const useUnifiedAuth = () => {
// Get both auth systems
const directusAuth = useDirectusAuth();
const directusUser = useDirectusUser();
const keycloak = useKeycloak();
const oidc = useOidcAuth();
// Create unified user object
const user = computed<UnifiedUser | null>(() => {
// Check Keycloak user first
if (keycloak.user?.value) {
const keycloakUser = keycloak.user.value;
// Check OIDC (Keycloak) user first
if (oidc.loggedIn?.value && oidc.user?.value) {
const oidcUser = oidc.user.value as any; // Type cast for flexibility
// Construct name from available fields
let name = keycloakUser.fullName;
if (!name && (keycloakUser.firstName || keycloakUser.lastName)) {
name = `${keycloakUser.firstName || ''} ${keycloakUser.lastName || ''}`.trim();
}
if (!name) {
name = keycloakUser.username || keycloakUser.email;
let name = oidcUser.name || oidcUser.preferred_username || oidcUser.email || 'User';
if (!name && (oidcUser.given_name || oidcUser.family_name)) {
name = `${oidcUser.given_name || ''} ${oidcUser.family_name || ''}`.trim();
}
return {
id: keycloakUser.id,
email: keycloakUser.email,
id: oidcUser.sub || oidcUser.id || 'unknown',
email: oidcUser.email || '',
name: name,
tier: 'basic', // Could be enhanced with Keycloak attributes
authSource: 'keycloak',
raw: keycloakUser
raw: oidcUser
};
}
@ -55,8 +53,8 @@ export const useUnifiedAuth = () => {
// Unified logout function
const logout = async () => {
if (user.value?.authSource === 'keycloak') {
// Keycloak logout
await keycloak.logout();
// OIDC logout
await oidc.logout();
} else if (user.value?.authSource === 'directus') {
// Directus logout
await directusAuth.logout();

View File

@ -5,8 +5,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Check if auth is required (default true unless explicitly set to false)
const isAuthRequired = to.meta.auth !== false;
if (!isAuthRequired) {
return;
}
try {
// Check Directus auth first (most reliable)
// Check Directus auth first
const { fetchUser, setUser } = useDirectusAuth();
const directusUser = useDirectusUser();
@ -15,10 +19,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
const user = await fetchUser();
setUser(user.value);
} catch (error) {
// Ignore directus auth errors for public pages
if (!isAuthRequired) {
return;
}
// Directus auth failed, continue to check OIDC
}
}
@ -27,37 +28,19 @@ export default defineNuxtRouteMiddleware(async (to) => {
return;
}
// Check Keycloak auth if authentication is required
if (isAuthRequired) {
const keycloak = useKeycloak();
// Initialize Keycloak if not already done
if (!keycloak.isInitialized.value) {
try {
const authenticated = await keycloak.initKeycloak();
if (authenticated) {
// User authenticated with Keycloak
return;
}
} catch (error) {
console.error('Keycloak initialization failed:', error);
// Continue to login redirect
}
} else if (keycloak.isAuthenticated.value) {
// User already authenticated with Keycloak
return;
}
// Check OIDC auth (Keycloak)
const { user: oidcUser, loggedIn } = useOidcAuth();
if (loggedIn.value && oidcUser.value) {
// User authenticated with Keycloak via OIDC
return;
}
// No authentication found
if (isAuthRequired) {
// Redirect to login page
return navigateTo('/login');
}
// No authentication found, redirect to login
return navigateTo('/login');
} catch (error) {
console.error('Auth middleware error:', error);
if (isAuthRequired) {
return navigateTo('/login');
}
return navigateTo('/login');
}
});

View File

@ -2,7 +2,7 @@ export default defineNuxtConfig({
ssr: false,
compatibilityDate: "2024-11-01",
devtools: { enabled: true },
modules: ["nuxt-directus", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
modules: ["nuxt-directus", "nuxt-oidc-auth", "vuetify-nuxt-module", "@vite-pwa/nuxt"],
app: {
head: {
titleTemplate: "%s • Port Nimara Portal",
@ -111,6 +111,21 @@ export default defineNuxtConfig({
wasm: true
}
},
oidc: {
providers: {
keycloak: {
audience: 'account',
baseUrl: 'https://auth.portnimara.dev/realms/client-portal',
clientId: 'client-portal',
clientSecret: '', // Will be injected via environment variable
redirectUri: 'https://client.portnimara.dev/auth/keycloak/callback',
userNameClaim: 'preferred_username',
logoutRedirectUri: 'https://client.portnimara.dev',
validateAccessToken: false, // Disable for Keycloak compatibility
exposeIdToken: true,
}
}
},
runtimeConfig: {
nocodb: {
url: "",
@ -128,15 +143,6 @@ export default defineNuxtConfig({
directus: {
url: "https://cms.portnimara.dev",
},
keycloak: {
url: "https://auth.portnimara.dev",
realm: "client-portal",
clientId: "client-portal",
},
// Force HTTPS base URL for production
baseUrl: "https://client.portnimara.dev",
// Enable debug mode for troubleshooting (temporarily enabled for production)
keycloakDebug: true,
},
},
vuetify: {

1643
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,6 @@
"@vite-pwa/nuxt": "^0.10.6",
"formidable": "^3.5.4",
"imap": "^0.8.19",
"keycloak-js": "^26.2.0",
"libphonenumber-js": "^1.12.9",
"lodash-es": "^4.17.21",
"mailparser": "^3.7.3",
@ -23,6 +22,7 @@
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"nuxt-oidc-auth": "^1.0.0-beta.5",
"v-phone-input": "^4.4.2",
"vue": "latest",
"vue-router": "latest",

View File

@ -1,79 +0,0 @@
<template>
<v-app>
<v-main>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center" class="fill-height">
<v-col cols="12" class="text-center">
<v-progress-circular
:size="70"
:width="7"
color="primary"
indeterminate
></v-progress-circular>
<h3 class="mt-4">Processing authentication...</h3>
<p class="text-body-2 mt-2">Please wait while we complete your login</p>
</v-col>
</v-row>
</v-container>
</v-main>
</v-app>
</template>
<script setup lang="ts">
// Define page meta for public access (no auth required)
definePageMeta({
auth: false,
layout: false
});
// Handle OAuth callback
onMounted(async () => {
try {
const route = useRoute()
const keycloak = useKeycloak()
// Check if we have an authorization code
const code = route.query.code as string
const state = route.query.state as string
const error = route.query.error as string
if (error) {
console.error('OAuth error:', error, route.query.error_description)
throw new Error(`Authentication failed: ${error}`)
}
if (!code) {
throw new Error('No authorization code received')
}
console.log('[CALLBACK] Processing OAuth callback with code:', code.substring(0, 10) + '...')
// Initialize Keycloak if not already done
if (!keycloak.isInitialized.value) {
await keycloak.initKeycloak()
}
// Check if user is now authenticated
if (keycloak.isAuthenticated.value) {
console.log('[CALLBACK] User authenticated successfully')
// Redirect to dashboard
await navigateTo('/dashboard')
} else {
throw new Error('Authentication process failed')
}
} catch (error) {
console.error('[CALLBACK] Authentication error:', error)
// Show error and redirect to login
const toast = useToast()
toast.error('Authentication failed. Unable to complete login. Please try again.')
await navigateTo('/login')
}
})
useHead({
title: 'Authenticating...'
})
</script>

View File

@ -113,10 +113,10 @@ definePageMeta({
});
// Directus auth
const { login } = useDirectusAuth();
const { login: directusLogin } = useDirectusAuth();
// Keycloak auth
const keycloak = useKeycloak();
// OIDC auth (Keycloak)
const { login: oidcLogin } = useOidcAuth();
const loading = ref(false);
const keycloakLoading = ref(false);
@ -128,24 +128,17 @@ const passwordVisible = ref(false);
const valid = ref(false);
// Keycloak login function with proper error handling
// SSO login function using nuxt-oidc-auth
const loginWithKeycloak = async () => {
try {
keycloakLoading.value = true;
console.log('[LOGIN] Starting SSO authentication via nuxt-oidc-auth...');
console.log('[LOGIN] Starting Keycloak authentication...');
// Initialize Keycloak first if needed
if (!keycloak.isInitialized.value) {
console.log('[LOGIN] Initializing Keycloak...');
await keycloak.initKeycloak();
}
// Perform login
await keycloak.login();
// nuxt-oidc-auth handles everything server-side, no CORS issues!
await oidcLogin('keycloak');
} catch (error) {
console.error('[LOGIN] Keycloak login error:', error);
console.error('[LOGIN] SSO login error:', error);
const toast = useToast();
toast.error('SSO login failed. Please try again or use email/password login.');
@ -159,7 +152,7 @@ const loginWithKeycloak = async () => {
const submit = async () => {
try {
loading.value = true;
await login({ email: emailAddress.value, password: password.value });
await directusLogin({ email: emailAddress.value, password: password.value });
return navigateTo("/dashboard");
} catch (error) {
errorThrown.value = true;

View File

@ -1,30 +0,0 @@
export default defineEventHandler((event) => {
const config = useRuntimeConfig()
// Return the OIDC configuration (without showing the actual secret)
return {
// Runtime config
runtime: {
issuer: config.openidConnect?.op?.issuer || 'NOT_SET',
clientId: config.openidConnect?.op?.clientId || 'NOT_SET',
clientSecret: config.openidConnect?.op?.clientSecret ? '***SET***' : 'NOT_SET',
secretLength: config.openidConnect?.op?.clientSecret?.length || 0,
},
// Build-time config (what the module actually uses)
buildTime: {
issuer: process.env.KEYCLOAK_ISSUER || 'NOT_SET',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'NOT_SET',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ? '***SET***' : 'NOT_SET',
secretLength: process.env.KEYCLOAK_CLIENT_SECRET?.length || 0,
},
environment: process.env.NODE_ENV,
envVars: {
KEYCLOAK_ISSUER: process.env.KEYCLOAK_ISSUER ? '***SET***' : 'NOT_SET',
KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID ? '***SET***' : 'NOT_SET',
KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET ? '***SET***' : 'NOT_SET',
OIDC_SESSION_SECRET: process.env.OIDC_SESSION_SECRET ? '***SET***' : 'NOT_SET',
OIDC_ENCRYPT_KEY: process.env.OIDC_ENCRYPT_KEY ? '***SET***' : 'NOT_SET',
OIDC_ENCRYPT_IV: process.env.OIDC_ENCRYPT_IV ? '***SET***' : 'NOT_SET',
}
}
})

View File

@ -1,37 +0,0 @@
export default defineEventHandler(async (event) => {
// Test the actual token exchange that's failing
const config = useRuntimeConfig()
const testCode = "test-code-123" // We won't use this for real exchange, just test setup
const tokenRequest = {
grant_type: 'authorization_code',
client_id: process.env.KEYCLOAK_CLIENT_ID,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET,
code: testCode,
redirect_uri: 'https://client.portnimara.dev/oidc/cbt',
}
return {
message: "Token exchange test configuration",
issuer: process.env.KEYCLOAK_ISSUER,
tokenEndpoint: `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecretLength: process.env.KEYCLOAK_CLIENT_SECRET?.length || 0,
redirectUri: 'https://client.portnimara.dev/oidc/cbt',
requestPayload: {
grant_type: tokenRequest.grant_type,
client_id: tokenRequest.client_id,
client_secret: tokenRequest.client_secret ? '***MASKED***' : 'NOT_SET',
code: 'test-code-will-be-replaced',
redirect_uri: tokenRequest.redirect_uri,
},
// Test the actual HTTP vs HTTPS issue
environment: {
NODE_ENV: process.env.NODE_ENV,
headers: getHeaders(event),
host: getHeader(event, 'host'),
protocol: getHeader(event, 'x-forwarded-proto') || 'http',
}
}
})

View File

@ -1,326 +0,0 @@
import { H3Event, getHeader, getCookie, setCookie } from 'h3'
import crypto from 'crypto'
interface OAuthConfig {
issuer: string
clientId: string
clientSecret: string
scope: string[]
}
interface PKCEChallenge {
codeVerifier: string
codeChallenge: string
state: string
}
interface TokenResponse {
access_token: string
refresh_token: string
id_token: string
token_type: string
expires_in: number
}
interface KeycloakUser {
sub: string
email: string
preferred_username: string
given_name?: string
family_name?: string
name?: string
realm_access?: {
roles: string[]
}
}
/**
* Smart base URL detection that handles proxy environments
*/
export function getBaseUrl(event: H3Event): string {
// Check for proxy headers first (nginx/reverse proxy)
const forwardedProto = getHeader(event, 'x-forwarded-proto')
const forwardedHost = getHeader(event, 'x-forwarded-host')
if (forwardedProto && forwardedHost) {
return `${forwardedProto}://${forwardedHost}`
}
// Fallback to host header
const host = getHeader(event, 'host')
// Force HTTPS in production
const proto = process.env.NODE_ENV === 'production' ? 'https' : 'http'
return `${proto}://${host}`
}
/**
* Get OAuth configuration from environment
*/
export function getOAuthConfig(): OAuthConfig {
const config = useRuntimeConfig()
return {
issuer: config.public.keycloak.url + '/realms/' + config.public.keycloak.realm,
clientId: config.public.keycloak.clientId,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
scope: ['openid', 'email', 'profile']
}
}
/**
* Generate PKCE challenge for enhanced security
*/
export async function generatePKCEChallenge(): Promise<PKCEChallenge> {
// Generate code verifier (random string)
const codeVerifier = generateRandomString(128)
// Create code challenge (SHA256 hash of verifier, base64url encoded)
const encoder = new TextEncoder()
const data = encoder.encode(codeVerifier)
const digest = await crypto.subtle.digest('SHA-256', data)
const codeChallenge = base64urlEncode(new Uint8Array(digest))
// Generate state parameter for CSRF protection
const state = generateRandomString(32)
return {
codeVerifier,
codeChallenge,
state
}
}
/**
* Generate Keycloak authorization URL
*/
export async function generateAuthUrl(event: H3Event): Promise<string> {
const config = getOAuthConfig()
const baseUrl = getBaseUrl(event)
const pkce = await generatePKCEChallenge()
// Store PKCE challenge and state in encrypted session
const sessionData = encrypt(JSON.stringify(pkce))
setCookie(event, 'oauth_session', sessionData, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 600 // 10 minutes
})
// Build authorization URL
const authUrl = new URL(`${config.issuer}/protocol/openid-connect/auth`)
authUrl.searchParams.set('client_id', config.clientId)
authUrl.searchParams.set('redirect_uri', `${baseUrl}/api/auth/keycloak/callback`)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', config.scope.join(' '))
authUrl.searchParams.set('state', pkce.state)
authUrl.searchParams.set('code_challenge', pkce.codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
return authUrl.toString()
}
/**
* Exchange authorization code for tokens
*/
export async function exchangeCodeForToken(
event: H3Event,
code: string,
state: string
): Promise<{ tokens: TokenResponse; user: KeycloakUser }> {
const config = getOAuthConfig()
const baseUrl = getBaseUrl(event)
// Retrieve and validate session data
const sessionCookie = getCookie(event, 'oauth_session')
if (!sessionCookie) {
throw createError({
statusCode: 400,
statusMessage: 'Missing OAuth session'
})
}
let sessionData: PKCEChallenge
try {
sessionData = JSON.parse(decrypt(sessionCookie))
} catch (error) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid OAuth session'
})
}
// Validate state parameter (CSRF protection)
if (state !== sessionData.state) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid state parameter'
})
}
// Prepare token request
const tokenUrl = `${config.issuer}/protocol/openid-connect/token`
const tokenData = new URLSearchParams({
grant_type: 'authorization_code',
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: `${baseUrl}/api/auth/keycloak/callback`,
code_verifier: sessionData.codeVerifier
})
// Exchange code for tokens
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: tokenData.toString()
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
console.error('Token exchange failed:', errorText)
throw createError({
statusCode: 400,
statusMessage: 'Token exchange failed'
})
}
const tokens: TokenResponse = await tokenResponse.json()
// Decode user info from ID token
const user = decodeJWTPayload<KeycloakUser>(tokens.id_token)
// Clean up session cookie
setCookie(event, 'oauth_session', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0
})
return { tokens, user }
}
/**
* Refresh access token using refresh token
*/
export async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const config = getOAuthConfig()
const tokenUrl = `${config.issuer}/protocol/openid-connect/token`
const tokenData = new URLSearchParams({
grant_type: 'refresh_token',
client_id: config.clientId,
client_secret: config.clientSecret,
refresh_token: refreshToken
})
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: tokenData.toString()
})
if (!response.ok) {
throw createError({
statusCode: 401,
statusMessage: 'Token refresh failed'
})
}
return await response.json()
}
/**
* Validate JWT token and extract payload
*/
export function decodeJWTPayload<T = any>(token: string): T {
try {
const parts = token.split('.')
if (parts.length !== 3) {
throw new Error('Invalid JWT format')
}
const payload = parts[1]
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
// Check if token is expired
if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired')
}
return decoded
} catch (error) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid token'
})
}
}
/**
* Simple encryption for OAuth session data
*/
function encrypt(data: string): string {
const key = getEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipher('aes-256-cbc', key)
cipher.setAutoPadding(true)
let encrypted = cipher.update(data, 'utf8', 'base64')
encrypted += cipher.final('base64')
return iv.toString('base64') + ':' + encrypted
}
/**
* Simple decryption for OAuth session data
*/
function decrypt(encryptedData: string): string {
const key = getEncryptionKey()
const parts = encryptedData.split(':')
const iv = Buffer.from(parts[0], 'base64')
const encrypted = parts[1]
const decipher = crypto.createDecipher('aes-256-cbc', key)
decipher.setAutoPadding(true)
let decrypted = decipher.update(encrypted, 'base64', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
/**
* Get encryption key from environment
*/
function getEncryptionKey(): string {
const key = process.env.OIDC_ENCRYPT_KEY || 'default-encrypt-key-change-in-prod'
return key.substring(0, 32).padEnd(32, '0')
}
/**
* Generate cryptographically secure random string
*/
function generateRandomString(length: number): string {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
const array = new Uint8Array(length)
crypto.getRandomValues(array)
return Array.from(array, byte => charset[byte % charset.length]).join('')
}
/**
* Base64URL encode (without padding)
*/
function base64urlEncode(buffer: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...buffer))
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}