diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts new file mode 100644 index 000000000..1ee4bd690 --- /dev/null +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -0,0 +1,111 @@ +/** + * Custom state store for OIDC authentication that doesn't rely on express-session. + * This store manages OAuth2 state parameters in memory with automatic cleanup. + */ +export class OidcStateStore { + private stateMap = new Map< + string, + { + ctx: { maxAge?: number; nonce?: string; issued?: Date }; + appState?: unknown; + meta?: unknown; + timestamp: number; + } + >(); + private readonly STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes + + /** + * Store request state. + * Signature matches passport-openidconnect SessionStore + */ + public store( + _req: unknown, + ctx: { maxAge?: number; nonce?: string; issued?: Date }, + appState: unknown, + _meta: unknown, + callback: (err: Error | null, handle?: string) => void + ): void { + try { + // Generate a unique handle for this state + const handle = this.generateHandle(); + + this.stateMap.set(handle, { + appState, + ctx, + meta: _meta, + timestamp: Date.now() + }); + + // Clean up expired states + this.cleanup(); + + callback(null, handle); + } catch (error) { + callback(error as Error); + } + } + + /** + * Verify request state. + * Signature matches passport-openidconnect SessionStore + */ + public verify( + _req: unknown, + handle: string, + callback: ( + err: Error | null, + ctx?: { maxAge?: number; nonce?: string; issued?: Date }, + appState?: unknown + ) => void + ): void { + try { + const data = this.stateMap.get(handle); + + if (!data) { + return callback(null, undefined, undefined); + } + + // Check if state has expired + if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { + this.stateMap.delete(handle); + return callback(null, undefined, undefined); + } + + // Remove state after verification (one-time use) + this.stateMap.delete(handle); + + callback(null, data.ctx, data.appState); + } catch (error) { + callback(error as Error); + } + } + + /** + * Generate a cryptographically secure random handle + */ + private generateHandle(): string { + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + + Date.now().toString(36) + ); + } + + /** + * Clean up expired states + */ + private cleanup(): void { + const now = Date.now(); + const expiredKeys: string[] = []; + + for (const [key, value] of this.stateMap.entries()) { + if (now - value.timestamp > this.STATE_EXPIRY_MS) { + expiredKeys.push(key); + } + } + + for (const key of expiredKeys) { + this.stateMap.delete(key); + } + } +} diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index f3996ed67..663592b06 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -1,13 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; -import { DoneCallback } from 'passport'; import { Strategy } from 'passport-openidconnect'; import { AuthService } from './auth.service'; +import { OidcStateStore } from './oidc-state.store'; @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { + private static readonly stateStore = new OidcStateStore(); + public constructor( private readonly authService: AuthService, options: any @@ -15,7 +17,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { super({ ...options, passReqToCallback: true, - scope: ['openid', 'profile', 'email'] + scope: ['openid', 'profile', 'email'], + store: OidcStrategy.stateStore }); } @@ -23,18 +26,29 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { _request: any, _issuer: string, profile: any, - done: DoneCallback + context: any, + idToken: any, + _accessToken: any, + _refreshToken: any, + params: any ) { try { + const thirdPartyId = + params?.sub || idToken?.sub || context?.claims?.sub || profile?.id; + + if (!thirdPartyId) { + throw new Error('Missing subject identifier in OIDC response'); + } + const jwt = await this.authService.validateOAuthLogin({ provider: Provider.OIDC, - thirdPartyId: profile.id + thirdPartyId }); - done(null, { jwt }); + return { jwt }; } catch (error) { Logger.error(error, 'OidcStrategy'); - done(error, false); + throw error; } } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 8e1fe787f..a8de3dc5e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,7 +15,6 @@ import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { NextFunction, Request, Response } from 'express'; -import * as session from 'express-session'; import helmet from 'helmet'; import { AppModule } from './app/app.module'; @@ -62,14 +61,6 @@ async function bootstrap() { }) ); - app.use( - session({ - resave: false, - saveUninitialized: false, - secret: configService.get('JWT_SECRET_KEY') - }) - ); - // Support 10mb csv/json files for importing activities app.useBodyParser('json', { limit: '10mb' }); diff --git a/package-lock.json b/package-lock.json index 34e0268a6..5dde2a62b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "dotenv": "17.2.3", "dotenv-expand": "12.0.3", "envalid": "8.1.0", - "express-session": "^1.18.2", "fuse.js": "7.1.0", "google-spreadsheet": "3.2.0", "helmet": "7.0.0", @@ -127,7 +126,6 @@ "@storybook/angular": "9.1.5", "@trivago/prettier-plugin-sort-imports": "5.2.2", "@types/big.js": "6.2.2", - "@types/express-session": "^1.18.2", "@types/google-spreadsheet": "3.1.5", "@types/jest": "29.5.13", "@types/lodash": "4.17.20", @@ -14204,16 +14202,6 @@ "@types/send": "*" } }, - "node_modules/@types/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, "node_modules/@types/express/node_modules/@types/express-serve-static-core": { "version": "4.19.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", @@ -22427,46 +22415,6 @@ "express": ">= 4.11" } }, - "node_modules/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.1.0", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/express-session/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express-session/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/express/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -33795,6 +33743,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -35944,15 +35893,6 @@ "dev": true, "license": "MIT" }, - "node_modules/random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -40311,18 +40251,6 @@ "node": ">=8" } }, - "node_modules/uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "license": "MIT", - "dependencies": { - "random-bytes": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uid2": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", diff --git a/package.json b/package.json index 2a9bfee9b..091571f2d 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "dotenv": "17.2.3", "dotenv-expand": "12.0.3", "envalid": "8.1.0", - "express-session": "^1.18.2", "fuse.js": "7.1.0", "google-spreadsheet": "3.2.0", "helmet": "7.0.0", @@ -173,7 +172,6 @@ "@storybook/angular": "9.1.5", "@trivago/prettier-plugin-sort-imports": "5.2.2", "@types/big.js": "6.2.2", - "@types/express-session": "^1.18.2", "@types/google-spreadsheet": "3.1.5", "@types/jest": "29.5.13", "@types/lodash": "4.17.20",