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 { // 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 { 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(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 { 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(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, '') }