const passport = require('passport'); const OpenIDConnectStrategy = require('passport-openidconnect'); const express = require('express'); const { param } = require('./applications'); const router = express.Router(); import { Role } from '@app/shared/types/roles'; import pool from '../db'; import { requireLogin } from '../middleware/auth'; import { getUserRoles } from '../services/db/rolesService'; import { getUserState, mapDiscordtoID } from '../services/db/memberService'; import { MemberState } from '@app/shared/types/member'; import { toDateTime } from '@app/shared/utils/time'; import { logger } from '../services/logging/logger'; const querystring = require('querystring'); import { performance } from 'perf_hooks'; import { CacheService } from '../services/cache/cache'; import { Strategy as CustomStrategy } from 'passport-custom'; function parseJwt(token) { return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); } const devLogin = (req: any, res: any, next: any) => { // The object here must match what your 'verify' function returns: { memberId } const devUser = { memberId: 1 }; // Hardcoded ID req.logIn(devUser, (err: any) => { if (err) return next(err); const redirectTo = req.session.redirectTo || process.env.CLIENT_URL; delete req.session.redirectTo; return res.redirect(redirectTo); }); }; if (process.env.AUTH_MODE === "mock") { passport.use('mock', new CustomStrategy(async (req, done) => { const mockUser = { memberId: 1 }; return done(null, mockUser); })) } else { passport.use('oidc', new OpenIDConnectStrategy({ issuer: process.env.AUTH_ISSUER, authorizationURL: process.env.AUTH_DOMAIN + '/authorize/', tokenURL: process.env.AUTH_DOMAIN + '/token/', userInfoURL: process.env.AUTH_DOMAIN + '/userinfo/', clientID: process.env.AUTH_CLIENT_ID, clientSecret: process.env.AUTH_CLIENT_SECRET, callbackURL: process.env.AUTH_REDIRECT_URI, scope: ['openid', 'profile', 'discord'] }, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) { // console.log('--- OIDC verify() called ---'); // console.log('issuer:', issuer); // console.log('sub:', sub); // // console.log('discord:', discord); // console.log('profile:', profile); // console.log('jwt: ', parseJwt(jwtClaims)); // console.log('params:', params); let con; try { con = await pool.getConnection(); await con.beginTransaction(); //lookup existing user const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]); let memberId: number | null = null; //if member exists if (existing.length > 0) { //login memberId = existing[0].id; logger.info('auth', `Existing member login`, { memberId, issuer, }); } else { //otherwise: create account mode const jwt = parseJwt(jwtClaims); const discordID = jwt.discord?.id as number; //check if account is available to claim if (discordID) memberId = await mapDiscordtoID(discordID); if (discordID && memberId) { // claim account const result = await con.query( `UPDATE members SET authentik_sub = ?, authentik_issuer = ? WHERE id = ?;`, [sub, issuer, memberId] ) logger.info('auth', `Existing member claimed via Discord`, { memberId, discordID, issuer, }); } else { // new account const username = sub.username; const result = await con.query( `INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`, [username, sub, issuer] ) memberId = Number(result.insertId); logger.info('auth', `New member account created`, { memberId, username, issuer, }); } } await con.query(`UPDATE members SET last_login = ? WHERE id = ?`, [toDateTime(new Date()), memberId]) await con.commit(); return cb(null, { memberId }); } catch (error) { logger.error('auth', `Authentication transaction failed`, { issuer, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); if (con) { try { await con.rollback(); } catch (rollbackError) { logger.error('auth', `Rollback failed`, { error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), }); } } return cb(error); } finally { if (con) con.release(); } })); } router.get('/login', (req, res, next) => { req.session.redirectTo = req.query.redirect as string; const strategy = process.env.AUTH_MODE === 'mock' ? 'mock' : 'oidc'; passport.authenticate(strategy, { successRedirect: (req.session.redirectTo || process.env.CLIENT_URL), failureRedirect: '/login' })(req, res, next); }); router.get('/callback', (req, res, next) => { //escape if mocked if (process.env.AUTH_MODE === 'mock') { return res.redirect(process.env.CLIENT_URL || '/'); } const redirectURI = req.session.redirectTo; passport.authenticate('oidc', (err, user) => { if (err) return next(err); if (!user) return res.redirect(process.env.CLIENT_URL); req.logIn(user, err => { if (err) return next(err); // Use redirect saved from session const redirectTo = redirectURI || process.env.CLIENT_URL; delete req.session.redirectTo; return res.redirect(redirectTo); }); })(req, res, next); }); router.get('/logout', [requireLogin], function (req, res, next) { req.logout(function (err) { if (err) { return next(err); } req.session.destroy((err) => { if (err) { return next(err); } res.clearCookie('connect.sid', { path: '/', domain: process.env.CLIENT_DOMAIN, httpOnly: true, sameSite: 'lax' }); if (process.env.AUTH_MODE === 'mock') { return res.redirect(process.env.CLIENT_URL || '/'); } var params = { client_id: process.env.AUTH_CLIENT_ID, returnTo: process.env.CLIENT_URL }; const endSessionUri = process.env.AUTH_END_SESSION_URI; if (endSessionUri) { return res.redirect(endSessionUri + '?' + querystring.stringify(params)); } else { return res.redirect(process.env.CLIENT_URL || '/'); } }) }); }); passport.serializeUser(function (user, cb) { process.nextTick(function () { cb(null, user); }); }); passport.deserializeUser(function (user, cb) { const start = performance.now(); const timings: Record = {}; process.nextTick(async function () { const memberID = user.memberId as number; let con; try { //cache lookup let t = performance.now(); const cachedData: UserData | undefined = userCache.Get(memberID); timings.cache_lookup = performance.now() - t; if (cachedData) { timings.total = performance.now() - start; logger.info( 'profiling', 'passport.deserializeUser (cache hit)', { memberId: memberID, cache_hit: true, source: 'cache', total_ms: timings.total, breakdown_ms: timings, }, 'profiling' ); return cb(null, cachedData); } //cache miss, db load t = performance.now(); con = await pool.getConnection(); timings.getConnection = performance.now() - t; t = performance.now(); const userResults = await con.query( `SELECT id, name, discord_id FROM members WHERE id = ?;`, [memberID] ); timings.memberQuery = performance.now() - t; const userData: UserData = userResults[0]; t = performance.now(); userData.roles = await getUserRoles(memberID) || []; timings.roles = performance.now() - t; t = performance.now(); userData.state = await getUserState(memberID); timings.state = performance.now() - t; t = performance.now(); userCache.Set(userData.id, userData); timings.cache_set = performance.now() - t; timings.total = performance.now() - start; logger.info( 'profiling', 'passport.deserializeUser (db load)', { memberId: memberID, cache_hit: false, source: 'db', total_ms: timings.total, breakdown_ms: timings, }, 'profiling' ); return cb(null, userData); } catch (error) { logger.error( 'profiling', 'passport.deserializeUser failed', { memberId: memberID, error: error instanceof Error ? error.message : String(error), } ); return cb(error); } finally { if (con) con.release(); } }); }); declare global { namespace Express { interface Request { user: { id: number; name: string; discord_id: string; roles: Role[]; state: MemberState; }; } } } export interface UserData { id: number; name: string; roles: Role[]; state: MemberState; discord_id?: string; } const userCache = new CacheService(); export const authRouter = router; export const memberCache = userCache;