fix: dropping the legacy surface
parent
9f2176cd4b
commit
d7b3f2a4df
@ -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;
|
||||
}
|
||||
@ -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<OIDCConfig>): LoadDictElement<Router> => ({
|
||||
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'
|
||||
}
|
||||
});
|
||||
@ -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<OIDCConfig>): LoadDictElement<string> => ({
|
||||
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'
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue