diff --git a/src/index.ts b/src/index.ts index 31685f3..c8fad63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,14 +22,14 @@ export { swissoidMergeAppConfigMap } from './utils/swissoidMergeAppConfigMap'; export const swissoidAppConfigMapKey = 'swissoidAppConfigMapNamespace'; // Classes and functions for direct use -export { createOIDCRoutes } from './oidc/OIDCRoutes'; +export { createOidcStandardRoutes } from './oidc/OIDCStandardRoutes'; +export { buildOidcConfig } from './oidc/oidcConfigBuilder'; export { SwissOIDSessionService } from './session/SwissOIDSessionService'; export { CookieManager } from './cookies/CookieManager'; export { SwissOIDJWTVerifier } from './jwt/SwissOIDJWTVerifier'; // Types export type { - OIDCConfig, CookieConfig, SessionData, DATClaims, @@ -38,4 +38,4 @@ export type { // Re-export useful types from dependencies export type { Request, Response, Router } from 'express'; -export type { LoadDictElement } from 'di-why/build/src/DiContainer'; \ No newline at end of file +export type { LoadDictElement } from 'di-why/build/src/DiContainer'; diff --git a/src/loaders/oidcStandardRoutesMiddleware.ts b/src/loaders/oidcStandardRoutesMiddleware.ts index a72d9fa..dcae128 100644 --- a/src/loaders/oidcStandardRoutesMiddleware.ts +++ b/src/loaders/oidcStandardRoutesMiddleware.ts @@ -1,7 +1,7 @@ import { LoadDictElement } from 'di-why'; import { Express } from 'express'; import { createOidcStandardRoutes } from '../oidc/OIDCStandardRoutes'; -import { buildOidcConfig } from './oidcConfigBuilder'; +import { buildOidcConfig } from '../oidc/oidcConfigBuilder'; /** * OIDC Standard Routes as a Middleware Attacher diff --git a/src/oidc/OIDCRoutes.ts b/src/oidc/OIDCRoutes.ts deleted file mode 100644 index 10a78b9..0000000 --- a/src/oidc/OIDCRoutes.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { Router, Request, Response } from 'express'; -import express from 'express'; -import cookieParser from 'cookie-parser'; -import * as crypto from 'crypto'; -import { jwtVerify, createRemoteJWKSet, SignJWT } from 'jose'; -import { OIDCConfig, SessionData } from '../types/auth.types'; - -export interface OIDCRoutesConfig extends OIDCConfig { - logger: any; - sessionService: any; - redisClient: any; -} - -export function createOIDCRoutes(config: OIDCRoutesConfig): Router { - const router = Router(); - - // Add cookie parser middleware - router.use(cookieParser()); - - const { - logger, - sessionService, - redisClient, - issuer, - clientId, - clientSecret, - tokenEndpoint, - jwksUri, - authorizeEndpoint, - callbackUrl, - cookieDomain, - frontendUrl, - sessionCookieName, - stateSigningSecret - } = config; - - // Create the secret key for signing - const secretKey = new TextEncoder().encode(stateSigningSecret); - - // Cookie options - const getCookieOptions = () => ({ - domain: cookieDomain, - httpOnly: true, - secure: true, - sameSite: 'lax' as const, - path: '/' - }); - - /** - * GET /auth/ping - Health check endpoint - */ - router.get('/auth/ping', (req: Request, res: Response) => { - res.json({ - status: 'ok', - service: 'swissoid-back', - timestamp: new Date().toISOString() - }); - }); - - /** - * GET /login - Initiates OIDC flow - */ - router.get('/login', async (req: Request, res: Response) => { - try { - const jti = crypto.randomBytes(16).toString('base64url'); - const nonce = crypto.randomBytes(16).toString('base64url'); - - // Create signed state token - const state = await new SignJWT({ - jti, - nonce, - client_id: clientId, - redirect_uri: callbackUrl - }) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime('10m') - .sign(secretKey); - - // Store JTI for replay prevention - await redisClient.set( - `oidc_jti:${jti}`, - '1', - 'NX', - 'EX', - 600 - ); - - // Set nonce cookie for backward compatibility - res.cookie('rp_nonce', nonce, { - ...getCookieOptions(), - maxAge: 10 * 60 * 1000 - }); - - logger.log('Initiating OIDC flow', { - client_id: clientId, - jti_hash: crypto.createHash('sha256').update(jti).digest('hex').substring(0, 8) - }); - - // Build authorization URL - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: callbackUrl, - response_type: 'code', - response_mode: 'form_post', - scope: 'openid', - state: state, - nonce: nonce - }); - - const authorizationUrl = `${authorizeEndpoint}?${params.toString()}`; - return res.redirect(302, authorizationUrl); - - } catch (error) { - logger.error('Error initiating login', error); - return res.status(500).send('Internal server error'); - } - }); - - /** - * POST /oidc/callback - Handles callback from SwissOID - */ - router.post('/oidc/callback', - express.urlencoded({ extended: false }), - async (req: Request, res: Response) => { - try { - const { code, state } = req.body; - - if (!code || !state) { - logger.error('Missing code or state in callback'); - return res.status(400).send('Missing required parameters'); - } - - // Verify signed state token - let statePayload: any; - try { - const { payload } = await jwtVerify(state, secretKey, { - algorithms: ['HS256'], - clockTolerance: 5 - }); - statePayload = payload; - } catch (verifyError) { - logger.error('State token verification failed', verifyError); - return res.status(400).send('Invalid state parameter'); - } - - // Enforce single-use via JTI - const jti = statePayload.jti; - const jtiKey = `oidc_jti:${jti}`; - const deleted = await redisClient.del(jtiKey); - if (deleted === 0) { - logger.error('JTI already used or expired', { jti }); - return res.status(400).send('State already used or expired'); - } - - // Validate embedded parameters - if (statePayload.client_id !== clientId || statePayload.redirect_uri !== callbackUrl) { - logger.error('State parameter mismatch'); - return res.status(400).send('Invalid state parameter'); - } - - // Exchange code for tokens - const tokenParams = new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - redirect_uri: callbackUrl - }); - - const authHeader = clientSecret - ? 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64') - : undefined; - - const tokenResponse = await fetch(tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - ...(authHeader && { 'Authorization': authHeader }) - }, - body: tokenParams.toString() - }); - - if (!tokenResponse.ok) { - logger.error('Token exchange failed', { status: tokenResponse.status }); - return res.status(401).send('Token exchange failed'); - } - - const tokenData = await tokenResponse.json() as any; - const { id_token } = tokenData; - - if (!id_token) { - logger.error('No id_token in response'); - return res.status(401).send('No id_token received'); - } - - // Verify id_token - const JWKS = createRemoteJWKSet(new URL(jwksUri)); - let payload: any; - try { - const result = await jwtVerify(id_token, JWKS, { - issuer: issuer, - audience: clientId, - algorithms: ['RS256'], - clockTolerance: 5 - }); - payload = result.payload; - } catch (verifyError) { - logger.error('ID token verification failed', verifyError); - return res.status(401).send('Invalid id_token'); - } - - // Verify nonce - if (!payload.nonce || payload.nonce !== statePayload.nonce) { - logger.error('Nonce mismatch'); - return res.status(401).send('Nonce mismatch'); - } - - // Clear nonce cookie - res.clearCookie('rp_nonce', getCookieOptions()); - - // Create session - const sessionId = crypto.randomBytes(24).toString('base64url'); - await sessionService.createSession(sessionId, { - sub: payload.sub, - iat: payload.iat, - exp: payload.exp, - email: payload.email, - name: payload.name - }); - - // Create transit token for finalize endpoint - const transitToken = crypto.randomBytes(32).toString('base64url'); - await redisClient.set( - `login_tx:${transitToken}`, - sessionId, - 'EX', - 60 - ); - - // Redirect to finalize endpoint - const finalizeUrl = `${callbackUrl.replace('/oidc/callback', '/oidc/finalize')}?tx=${transitToken}`; - return res.redirect(303, finalizeUrl); - - } catch (error) { - logger.error('Error processing OIDC callback', error); - return res.status(500).send('Internal server error'); - } - }); - - /** - * GET /oidc/finalize - Sets session cookie in first-party context - */ - router.get('/oidc/finalize', async (req: Request, res: Response) => { - try { - const { tx } = req.query; - - if (!tx || typeof tx !== 'string') { - logger.error('Missing transit token'); - return res.status(400).send('Missing transit token'); - } - - // Retrieve and delete session ID from Redis - const transitKey = `login_tx:${tx}`; - const sessionId = await redisClient.get(transitKey); - - if (!sessionId) { - logger.error('Transit token not found or expired'); - return res.status(400).send('Transit token expired'); - } - - await redisClient.del(transitKey); - - // Verify session exists - const sessionData = await sessionService.getSession(sessionId); - if (!sessionData) { - logger.error('Session not found'); - return res.status(400).send('Session not found'); - } - - // Set session cookie - res.cookie(sessionCookieName, sessionId, { - ...getCookieOptions(), - maxAge: 7 * 24 * 60 * 60 * 1000 - }); - - // Redirect to app - const redirectUrl = `${frontendUrl}/workspace`; - return res.redirect(302, redirectUrl); - - } catch (error) { - logger.error('Error in finalize endpoint', error); - return res.status(500).send('Internal server error'); - } - }); - - /** - * GET /auth/status - Returns authentication status - */ - router.get('/auth/status', async (req: Request, res: Response) => { - try { - const sessionId = req.cookies?.[sessionCookieName]; - - if (!sessionId) { - return res.json({ authenticated: false }); - } - - const session = await sessionService.getSession(sessionId); - if (!session) { - return res.json({ authenticated: false }); - } - - return res.json({ - authenticated: true, - user: { - id: session.sub, - UUID: session.sub, - email: session.email, - username: session.email, - name: session.name, - ...session - } - }); - - } catch (error) { - logger.error('Error checking auth status', error); - return res.json({ authenticated: false }); - } - }); - - /** - * GET/POST /auth/logout - Destroys session and clears cookies - */ - const logoutHandler = async (req: Request, res: Response) => { - try { - const sessionId = req.cookies?.[sessionCookieName]; - - if (sessionId) { - await sessionService.destroySession(sessionId); - } - - res.clearCookie(sessionCookieName, getCookieOptions()); - - if (req.method === 'GET') { - return res.redirect('/login'); - } else { - return res.json({ success: true }); - } - - } catch (error) { - logger.error('Error during logout', error); - if (req.method === 'GET') { - return res.redirect('/login'); - } else { - return res.status(500).json({ error: 'Logout failed' }); - } - } - }; - - router.get('/auth/logout', logoutHandler); - router.post('/auth/logout', logoutHandler); - - /** - * GET /auth/userinfo - Returns user information (if authenticated) - */ - router.get('/auth/userinfo', async (req: Request, res: Response) => { - try { - const sessionId = req.cookies?.[sessionCookieName]; - - if (!sessionId) { - return res.status(401).json({ error: 'Not authenticated' }); - } - - const session = await sessionService.getSession(sessionId); - if (!session) { - return res.status(401).json({ error: 'Invalid session' }); - } - - return res.json({ - sub: session.sub, - email: session.email, - name: session.name, - ...session - }); - - } catch (error) { - logger.error('Error fetching user info', error); - return res.status(500).json({ error: 'Internal server error' }); - } - }); - - return router; -} \ No newline at end of file diff --git a/src/oidc/oidcRoutesLDEGen.ts b/src/oidc/oidcRoutesLDEGen.ts deleted file mode 100644 index 7d787ef..0000000 --- a/src/oidc/oidcRoutesLDEGen.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { LoadDictElement } from 'di-why/build/src/DiContainer'; -import { Router } from 'express'; -import { createOIDCRoutes } from './OIDCRoutes'; -import { OIDCConfig } from '../types/auth.types'; - -export const oidcRoutesLDEGen = (config?: Partial): LoadDictElement => ({ - factory: ({ logger, sessionService, redisClient, appConfig }) => { - const finalConfig = { - logger, - sessionService, - redisClient, - issuer: config?.issuer || appConfig.swissoidIssuer, - clientId: config?.clientId || appConfig.swissoidClientId, - clientSecret: config?.clientSecret || appConfig.swissoidClientSecret, - tokenEndpoint: config?.tokenEndpoint || appConfig.swissoidTokenEndpoint, - jwksUri: config?.jwksUri || appConfig.swissoidJwksUri, - authorizeEndpoint: config?.authorizeEndpoint || appConfig.swissoidAuthorizeEndpoint, - callbackUrl: config?.callbackUrl || appConfig.rpCallbackUrl, - cookieDomain: config?.cookieDomain || appConfig.rpCookieDomain, - frontendUrl: config?.frontendUrl || appConfig.rpFrontendUrl, - sessionCookieName: config?.sessionCookieName || appConfig.sessionCookieName, - sessionSecret: config?.sessionSecret || appConfig.sessionSecret, - stateSigningSecret: config?.stateSigningSecret || appConfig.stateSigningSecret - }; - - return createOIDCRoutes(finalConfig); - }, - locateDeps: { - logger: 'logger', - sessionService: 'sessionService', - redisClient: 'redisClient', - appConfig: 'appConfig' - } -}); \ No newline at end of file diff --git a/src/oidc/oidcRoutesMounterLDEGen.ts b/src/oidc/oidcRoutesMounterLDEGen.ts deleted file mode 100644 index 040e01a..0000000 --- a/src/oidc/oidcRoutesMounterLDEGen.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { LoadDictElement } from 'di-why/build/src/DiContainer'; -import { Express } from 'express'; -import { createOIDCRoutes } from './OIDCRoutes'; -import { OIDCConfig } from '../types/auth.types'; - -/** - * Loader that creates OIDC routes and mounts them on Express app - * Returns a string to indicate completion (cronide-user pattern) - */ -export const oidcRoutesMounterLDEGen = (config?: Partial): LoadDictElement => ({ - factory: ({ app, logger, sessionService, redisClient, appConfig }) => { - const finalConfig = { - logger, - sessionService, - redisClient, - issuer: config?.issuer || appConfig.swissoidIssuer, - clientId: config?.clientId || appConfig.swissoidClientId, - clientSecret: config?.clientSecret || appConfig.swissoidClientSecret, - tokenEndpoint: config?.tokenEndpoint || appConfig.swissoidTokenEndpoint, - jwksUri: config?.jwksUri || appConfig.swissoidJwksUri, - authorizeEndpoint: config?.authorizeEndpoint || appConfig.swissoidAuthorizeEndpoint, - callbackUrl: config?.callbackUrl || appConfig.rpCallbackUrl, - cookieDomain: config?.cookieDomain || appConfig.rpCookieDomain || appConfig.cookieDomain, - frontendUrl: config?.frontendUrl || appConfig.rpFrontendUrl, - sessionCookieName: config?.sessionCookieName || appConfig.sessionCookieName, - sessionSecret: config?.sessionSecret || appConfig.sessionSecret, - stateSigningSecret: config?.stateSigningSecret || appConfig.stateSigningSecret - }; - - const router = createOIDCRoutes(finalConfig); - - // Mount the routes on the Express app - (app as Express).use(router); - - logger.log('Standard OIDC routes mounted - /login, POST /oidc/callback, /auth/status, /auth/userinfo, /auth/logout'); - - return 'oidcStandardRoutes-loaded'; - }, - locateDeps: { - app: 'app', - logger: 'logger', - sessionService: 'sessionService', - appConfig: 'appConfig', - redisClient: 'redisClient' - } -}); \ No newline at end of file diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index 4270db3..cf02773 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -1,23 +1,3 @@ -export interface OIDCConfig { - // SwissOID settings - issuer: string; // https://api.swissoid.com - clientId: string; // Application client ID - clientSecret?: string; // For confidential clients - tokenEndpoint: string; // https://api.swissoid.com/token - jwksUri: string; // https://api.swissoid.com/.well-known/jwks.json - authorizeEndpoint: string; // https://api.swissoid.com/authorize - - // RP settings - callbackUrl: string; // Where SwissOID redirects back to - cookieDomain: string; // Domain for cookies (e.g., .example.com) - frontendUrl: string; // Frontend application URL - - // Session settings - sessionCookieName: string; // Name for session cookie - sessionSecret: string; // Secret for session encryption - stateSigningSecret: string; // Secret for signing state parameter -} - export interface CookieConfig { domain: string; sessionName: string; @@ -57,4 +37,4 @@ export interface SwissOIDSessionConfig { sameSite?: 'strict' | 'lax' | 'none'; sessionTTL?: number; // In seconds refreshTTL?: number; // In seconds -} \ No newline at end of file +}