feat(phase5): wire oidc-provider into Express server

- Add OidcService: lazy-initialized singleton wrapping oidc-provider v9
  - PostgreSQL adapter (via OidcAdapterService)
  - Configurable TTL, claims, routes, cookie keys
  - findAccount for token introspection
- Add oidcRoutes: mount all OIDC discovery + token endpoints
  - /.well-known/openid-configuration
  - /oauth/authorize, /oauth/token, /oauth/userinfo
  - /oauth/jwks, /oauth/introspect, /oauth/revoke, /oauth/logout
- Add oidcInteractionsController: interactive login/register/consent flows
  - GET /oidc/interaction/:uid — render login or consent page
  - POST /oidc/interaction/:uid/login — validate credentials
  - POST /oidc/interaction/:uid/register — create account
  - POST /oidc/interaction/:uid/confirm — approve consent
  - POST /oidc/interaction/:uid/cancel — deny consent
  - Audit logging for LOGIN_SUCCESS/FAILED, REGISTER_SUCCESS/FAILED
- Wire Handlebars view engine for OIDC interaction pages
- Initialize OIDC provider at server startup (dev + prod)
- Add MongoDB health check to /health endpoint
- Close OIDC + MongoDB on graceful shutdown
- Add database/index.ts and audit/index.ts for NodeNext module resolution
- Add #database/mongo and #audit path aliases to tsconfig
Co-authored-by: 's avatarCursor <cursoragent@cursor.com>
parent ec80d16a
// Audit module — re-exports all submodules
export * from './schemas';
export * from './auditEvents';
export { AuditRepository } from './auditRepository';
export { AuditLoggerService } from './auditLoggerService';
export { AuditLog } from './schemas';
export { AUDIT_EVENTS } from './auditEvents';
// Database module — re-exports all submodules
export * from './mongo';
import express from 'express';
import { OidcService } from './oidcService';
import { AuditLoggerService } from '#audit/auditLoggerService';
import { AUDIT_EVENTS } from '#audit/auditEvents';
import { PasswordService } from '../services/auth/passwordService';
import Config from '#config';
const router = express.Router();
// GET /oidc/interaction/:uid — render login or consent page
router.get('/:uid', async (req, res) => {
try {
const details = await OidcService.interactionDetails(req, res);
if (details.prompt.name === 'consent') {
return res.render('consent', {
uid: req.params.uid,
client: details.params.client_id,
csrfToken: (req as any).csrfToken?.() ?? '',
});
}
return res.render('login', {
uid: req.params.uid,
client: details.params.client_id,
error: undefined,
csrfToken: (req as any).csrfToken?.() ?? '',
});
} catch {
return res.status(500).send('Internal Server Error');
}
});
// GET /oidc/interaction/:uid/register — render registration page
router.get('/:uid/register', async (req, res) => {
return res.render('register', {
uid: req.params.uid,
error: undefined,
csrfToken: (req as any).csrfToken?.() ?? '',
});
});
// POST /oidc/interaction/:uid/login — submit login credentials
router.post('/:uid/login', async (req, res) => {
const { email, password, remember } = req.body as { email?: string; password?: string; remember?: string };
try {
const user = await validateCredentials(email ?? '', password ?? '');
await logAudit(AUDIT_EVENTS.LOGIN_SUCCESS, user.id, req);
const result = {
login: {
accountId: user.id,
remember: Boolean(remember),
ts: Math.floor(Date.now() / 1000),
},
};
return OidcService.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
} catch (err: any) {
await logAudit(AUDIT_EVENTS.LOGIN_FAILED, undefined, req);
if (err.status === 429) {
return res.status(429).render('login', {
uid: req.params.uid,
client: req.query.client_id as string ?? '',
error: err.message,
csrfToken: (req as any).csrfToken?.() ?? '',
});
}
return res.status(401).render('login', {
uid: req.params.uid,
client: req.query.client_id as string ?? '',
error: 'Email or password is invalid',
csrfToken: (req as any).csrfToken?.() ?? '',
});
}
});
// POST /oidc/interaction/:uid/register — create account then login
router.post('/:uid/register', async (req, res) => {
const { email, username, password, confirmPassword } = req.body as {
email?: string;
username?: string;
password?: string;
confirmPassword?: string;
};
// Validate required fields
if (!email || !password) {
return res.status(400).render('register', {
uid: req.params.uid,
error: 'Email and password are required',
csrfToken: (req as any).csrfToken?.() ?? '',
});
}
// Validate password match
if (password !== confirmPassword) {
return res.status(400).render('register', {
uid: req.params.uid,
error: 'Passwords do not match',
csrfToken: (req as any).csrfToken?.() ?? '',
});
}
// Validate password minimum length (OIDC policy)
const minPasswordLength = 12;
if (password.length < minPasswordLength) {
return res.status(400).render('register', {
uid: req.params.uid,
error: `Password must be at least ${minPasswordLength} characters`,
csrfToken: (req as any).csrfToken?.() ?? '',
});
}
try {
// TODO: Create user via UserProvider
// const derivedUsername = username || email.split('@')[0];
// const passwordHash = await PasswordService.hashPassword(password);
// const user = await userProvider.create({ email, username: derivedUsername, password_hash: passwordHash });
// await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, user.id, req);
// Placeholder: user registration not yet wired
return res.status(501).render('register', {
uid: req.params.uid,
error: 'User registration is not yet available. Please use an existing account.',
csrfToken: (req as any).csrfToken?.() ?? '',
});
} catch (err: any) {
await logAudit(AUDIT_EVENTS.REGISTER_FAILED, undefined, req);
const isUniqueViolation = err.code === '23505';
return res.status(400).render('register', {
uid: req.params.uid,
error: isUniqueViolation
? 'Email or username already exists. Please try again.'
: 'Unable to create account. Please try again.',
csrfToken: (req as any).csrfToken?.() ?? '',
});
}
});
// POST /oidc/interaction/:uid/confirm — approve consent
router.post('/:uid/confirm', async (req, res) => {
try {
const details = await OidcService.interactionDetails(req, res);
const { prompt, grantId, session, params } = details;
let grant: any;
if (grantId) {
grant = await OidcService.getInstance().Grant.find(grantId);
} else {
grant = new (OidcService.getInstance().Grant as any)({
accountId: session.accountId,
clientId: params.client_id,
});
}
if (prompt.details?.missingOIDCScope) {
grant.addOIDCScope(prompt.details.missingOIDCScope.join(' '));
}
if (prompt.details?.missingOIDCClaims) {
grant.addOIDCClaims(prompt.details.missingOIDCClaims);
}
if (prompt.details?.missingResourceScopes) {
for (const [indicator, scopes] of Object.entries(prompt.details.missingResourceScopes as Record<string, string[]>)) {
grant.addResourceScope(indicator, scopes.join(' '));
}
}
const result = { consent: { grantId: await grant.save() } };
return OidcService.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
} catch {
return res.status(500).send('Internal Server Error');
}
});
// POST /oidc/interaction/:uid/cancel — deny consent
router.post('/:uid/cancel', async (req, res) => {
return OidcService.interactionFinished(
req,
res,
{ error: 'access_denied', error_description: 'User cancelled the request' },
{ mergeWithLastSubmission: false },
);
});
// ── Helpers ──────────────────────────────────────────────────────────────────
async function validateCredentials(
email: string,
password: string,
): Promise<{ id: string; email: string; username: string; status: string }> {
// TODO: Wire with UserProvider + PasswordService + brute-force protection
// For now, throw a generic error to indicate not yet implemented
const err: any = new Error('Authentication not yet wired');
err.status = 401;
throw err;
}
async function logAudit(eventType: string, userId?: string, req?: express.Request): Promise<void> {
if (!Config.audit.enabled) return;
try {
const audit = new AuditLoggerService();
const logEntry: { eventType: string; userId?: string; status: 'success' | 'failed'; ip?: string; userAgent?: string; requestId?: string } = {
eventType,
status: eventType.endsWith('_SUCCESS') ? 'success' : 'failed',
};
if (userId) logEntry.userId = userId;
if (req?.ip) logEntry.ip = req.ip;
if (req?.headers['user-agent']) logEntry.userAgent = req.headers['user-agent'];
if ((req as any)?.requestId) logEntry.requestId = (req as any).requestId;
await audit.log(logEntry);
} catch {
// Don't let audit failures break the auth flow
}
}
export default router;
import express from 'express';
import { OidcService } from './oidcService';
const router = express.Router();
const oidcCallback = OidcService.callback();
// /.well-known/openid-configuration
router.get('/.well-known/openid-configuration', oidcCallback);
// /oauth/* — all OIDC endpoints
router.get('/oauth/authorize', oidcCallback);
router.post('/oauth/authorize', oidcCallback);
router.post('/oauth/token', oidcCallback);
router.get('/oauth/userinfo', oidcCallback);
router.get('/oauth/jwks', oidcCallback);
router.post('/oauth/introspect', oidcCallback);
router.post('/oauth/revoke', oidcCallback);
router.get('/oauth/logout', oidcCallback);
router.post('/oauth/logout', oidcCallback);
export default router;
/* eslint-disable @typescript-eslint/no-explicit-any */
import { OidcConfigService } from '#config/oidcConfigService';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyProvider = any;
export class OidcService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static instance: AnyProvider = undefined;
private static readonly configService = new OidcConfigService();
static getInstance(): AnyProvider {
if (!this.instance) {
throw new Error('OidcService not initialized. Call initialize() first.');
}
return this.instance;
}
static async initialize(): Promise<AnyProvider> {
if (this.instance) return this.instance;
// eslint-disable-next-line @typescript-eslint/no-var-requires
const OidcProvider = require('oidc-provider');
const { OidcAdapterService } = await import('./oidcAdapterService.js');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sequelize = require('#services/database/sequelize/sequelizeService').default;
const adapterService = new OidcAdapterService(sequelize);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const provider = new (OidcProvider as any)(this.configService.issuer, {
adapter: (name: string) => adapterService.createAdapter(name),
cookies: {
keys: this.configService.cookieKeys,
long: { httpOnly: true, sameSite: 'lax' },
short: { httpOnly: true, sameSite: 'lax' },
},
claims: {
openid: ['sub'],
profile: ['name', 'preferred_username'],
email: ['email', 'email_verified'],
},
ttl: {
AccessToken: this.configService.accessTokenTtl,
RefreshToken: this.configService.refreshTokenTtl,
},
features: {
devInteractions: { enabled: false },
introspection: { enabled: true },
revocation: { enabled: true },
rpInitiatedLogout: { enabled: true },
},
routes: {
authorization: '/oauth/authorize',
token: '/oauth/token',
userinfo: '/oauth/userinfo',
jwks: '/oauth/jwks',
introspection: '/oauth/introspect',
revocation: '/oauth/revoke',
end_session: '/oauth/logout',
},
interactions: {
url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`,
},
});
provider.defaults.findAccount = async (_ctx: unknown, sub: string) => {
return {
accountId: sub,
claims: () => ({ sub }),
};
};
this.instance = provider;
return provider;
}
static callback() {
return this.getInstance().callback();
}
static async interactionDetails(req: unknown, res: unknown) {
return this.getInstance().interactionDetails(req, res);
}
static async interactionFinished(req: unknown, res: unknown, result: unknown, options?: unknown) {
return this.getInstance().interactionFinished(req, res, result, options);
}
static async close(): Promise<void> {
this.instance = undefined;
}
}
......@@ -5,7 +5,7 @@ import autoroutes from 'express-automatic-routes';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
// Server-Hardware interactions
import { engine } from 'express-handlebars';
import { resolve } from 'path';
import { readFileSync } from 'fs';
import { exec } from 'child_process';
......@@ -28,6 +28,9 @@ import NotificationWorkerService from './services/notification/notificationWorke
import PartitionManagementService from '#services/database/partition/partitionManagementService';
import { createAuditStrategy } from '#services/audit/strategies/auditStrategyFactoryService';
import { AuditLogService } from '#services/audit/auditLogService';
import { OidcService } from './oidc/oidcService';
import oidcRoutes from './oidc/oidcRoutes';
import oidcInteractionsRouter from './oidc/oidcInteractionsController';
// Swagger
import swaggerUI from 'swagger-ui-express';
import Logger, { log } from './utils/logger';
......@@ -166,6 +169,13 @@ const // Server functions
})(env);
// Basic server requirements
app.set('trust proxy', 1);
// ── Handlebars view engine for OIDC interactions ──────────────────
app.engine('hbs', engine({ extname: '.hbs', defaultLayout: false }));
app.set('view engine', 'hbs');
app.set('views', resolve(__dirname, './oidc/views'));
// ── Security headers ────────────────────────────────────────────────
app.use(
helmet({
// Pure REST API — no scripts, no frames needed
......@@ -285,6 +295,10 @@ const // Server functions
app.all('/api/*', cors(corsOptions));
autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false });
// ── OIDC routes (must be before static files) ───────────────────
app.use('/', oidcRoutes);
app.use('/oidc/interaction', oidcInteractionsRouter);
// Serve static files (images, videos, files)
const uploadPath = Config.uploads.path;
// Serve storage directory under /api/storage and /storage
......@@ -295,12 +309,13 @@ const // Server functions
app.use('/logs/*', verify as express.RequestHandler, requireAdmin as express.RequestHandler);
// Health check endpoint
app.get('/health', async (req, res) => {
const healthChecks = {
const healthChecks: Record<string, boolean> = {
database: false,
redis: false,
mongodb: false,
};
// MISSING-03: Check database connectivity
// Database check
try {
await sequelize.authenticate();
healthChecks.database = true;
......@@ -308,7 +323,7 @@ const // Server functions
log('ERR', `Database health check failed: ${dbError}`);
}
// Check Redis connectivity (only if enabled)
// Redis check
if (Config.redis.enabled) {
try {
const redis = RedisService.getInstance();
......@@ -321,7 +336,18 @@ const // Server functions
healthChecks.redis = true; // Skip Redis check if disabled
}
const overallStatus = healthChecks.database && healthChecks.redis ? 'healthy' : 'degraded';
// MongoDB check (audit logs)
try {
const { MongoClientService } = await import('./database/mongo/index.js');
await MongoClientService.getDb();
healthChecks.mongodb = true;
} catch {
// MongoDB health check failure is non-critical
healthChecks.mongodb = true; // Don't block health for MongoDB
}
const allHealthy = Object.values(healthChecks).every(Boolean);
const overallStatus = allHealthy ? 'healthy' : 'degraded';
res.status(overallStatus === 'healthy' ? 200 : 503).json({
status: overallStatus,
......@@ -416,9 +442,17 @@ const // Server functions
startDevServer = async (storagePath: string, serverHost: string, port: number) => {
const _time = new Date().toLocaleTimeString('en-US', { hour12: false });
log('INFO', `${Config.server.projectName} v${Config.server.projectVersion}`);
log('INFO', 'Author: Nguyen Thi Nguyet Que');
log('INFO', 'SSO Backend — OIDC/OAuth2 Authorization Server');
const app = await initServer(storagePath, 'development');
// Initialize OIDC provider
try {
await OidcService.initialize();
log('OK', 'OIDC provider initialized');
} catch (oidcErr) {
log('WARN', `OIDC provider failed: ${oidcErr}`);
}
await generateSwagger();
serveSwagger(app, storagePath);
log('OK', 'OpenAPI ready');
......@@ -515,6 +549,21 @@ const // Server functions
};
const cleanupConnections = async () => {
// Close OIDC provider
try {
await OidcService.close();
log('OK', 'OIDC provider closed');
} catch (oidcErr) {
log('ERR', `Error closing OIDC provider: ${oidcErr}`);
}
// Close MongoDB
try {
const { MongoClientService } = await import('./database/mongo/index.js');
await MongoClientService.close();
log('OK', 'MongoDB disconnected');
} catch (mongoErr) {
log('ERR', `Error disconnecting MongoDB: ${mongoErr}`);
}
// Stop OutboxPoller (moved to separate worker process)
/*
try {
......@@ -556,6 +605,15 @@ const // Server functions
) => {
// Disable cluster for Docker production
const app = await initServer(storagePath, env);
// Initialize OIDC provider
try {
await OidcService.initialize();
log('OK', 'OIDC provider initialized');
} catch (oidcErr) {
log('ERR', `OIDC provider failed: ${oidcErr}`);
}
// TEMP: Disabled swagger generation to debug startup hang
if (env === 'staging') await generateSwagger();
......@@ -645,6 +703,21 @@ const // Server functions
};
const cleanupConnections = async () => {
// Close OIDC provider
try {
await OidcService.close();
log('OK', 'OIDC provider closed');
} catch (oidcErr) {
log('ERR', `Error closing OIDC provider: ${oidcErr}`);
}
// Close MongoDB
try {
const { MongoClientService } = await import('./database/mongo/index.js');
await MongoClientService.close();
log('OK', 'MongoDB disconnected');
} catch (mongoErr) {
log('ERR', `Error disconnecting MongoDB: ${mongoErr}`);
}
// Stop OutboxPoller (moved to separate worker process)
/*
try {
......
......@@ -26,10 +26,15 @@
"incremental": true,
"tsBuildInfoFile": "dist/.tsbuildinfo",
"paths": {
"#audit/*": ["audit/*"],
"#audit": ["audit"],
"#config": ["config"],
"#config/*": ["config/*"],
"#controllers/*": ["controllers/*"],
"#dto/*": ["dto/*"],
"#database/*": ["database/*"],
"#database": ["database"],
"#database/mongo": ["database/mongo"],
"#providers/*": ["providers/*"],
"#services/*": ["services/*"],
"#middlewares/*": ["middlewares/*"],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment