From dfdc0592ae230fac09596e34141f628648674337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 18:53:53 +0100 Subject: [PATCH 01/36] Feature/add OIDC authentication support --- apps/api/src/app/auth/auth.controller.ts | 28 ++++++ apps/api/src/app/auth/auth.module.ts | 45 ++++++++- apps/api/src/app/auth/oidc.strategy.ts | 40 ++++++++ apps/api/src/app/info/info.service.ts | 4 + apps/api/src/main.ts | 9 ++ .../configuration/configuration.service.ts | 7 ++ .../interfaces/environment.interface.ts | 7 ++ .../app/components/header/header.component.ts | 7 ++ .../interfaces/interfaces.ts | 1 + .../login-with-access-token-dialog.html | 11 +++ libs/common/src/lib/permissions.ts | 2 + package-lock.json | 92 ++++++++++++++++++- package.json | 3 + prisma/schema.prisma | 1 + 14 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/app/auth/oidc.strategy.ts diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index b45e7b97b..5f21ab9fa 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -102,6 +102,34 @@ export class AuthController { } } + @Get('oidc') + @UseGuards(AuthGuard('oidc')) + public oidcLogin() { + // Initiates the OIDC login flow + } + + @Get('oidc/callback') + @UseGuards(AuthGuard('oidc')) + @Version(VERSION_NEUTRAL) + public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { + // Handles the OIDC callback + const jwt: string = (request.user as any).jwt; + + if (jwt) { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` + ); + } else { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth` + ); + } + } + @Get('webauthn/generate-registration-options') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async generateRegistrationOptions() { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 824c432b1..a417563fd 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -4,10 +4,11 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { Module } from '@nestjs/common'; +import { Module, Logger } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ApiKeyStrategy } from './api-key.strategy'; @@ -15,6 +16,7 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './google.strategy'; import { JwtStrategy } from './jwt.strategy'; +import { OidcStrategy } from './oidc.strategy'; @Module({ controllers: [AuthController], @@ -36,6 +38,47 @@ import { JwtStrategy } from './jwt.strategy'; AuthService, GoogleStrategy, JwtStrategy, + { + provide: OidcStrategy, + useFactory: async ( + authService: AuthService, + configurationService: ConfigurationService + ) => { + const issuer = configurationService.get('OIDC_ISSUER'); + const options: any = { + callbackURL: `${configurationService.get( + 'ROOT_URL' + )}/api/auth/oidc/callback`, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET') + }; + + if (issuer) { + try { + const response = await fetch( + `${issuer}/.well-known/openid-configuration` + ); + const config = await response.json(); + + options.authorizationURL = config.authorization_endpoint; + options.issuer = issuer; + options.tokenURL = config.token_endpoint; + options.userInfoURL = config.userinfo_endpoint; + } catch (error) { + Logger.error(error, 'OidcStrategy'); + } + } else { + options.authorizationURL = configurationService.get( + 'OIDC_AUTHORIZATION_URL' + ); + options.tokenURL = configurationService.get('OIDC_TOKEN_URL'); + options.userInfoURL = configurationService.get('OIDC_USER_INFO_URL'); + } + + return new OidcStrategy(authService, options); + }, + inject: [AuthService, ConfigurationService] + }, WebAuthService ] }) diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts new file mode 100644 index 000000000..f3996ed67 --- /dev/null +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -0,0 +1,40 @@ +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'; + +@Injectable() +export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { + public constructor( + private readonly authService: AuthService, + options: any + ) { + super({ + ...options, + passReqToCallback: true, + scope: ['openid', 'profile', 'email'] + }); + } + + public async validate( + _request: any, + _issuer: string, + profile: any, + done: DoneCallback + ) { + try { + const jwt = await this.authService.validateOAuthLogin({ + provider: Provider.OIDC, + thirdPartyId: profile.id + }); + + done(null, { jwt }); + } catch (error) { + Logger.error(error, 'OidcStrategy'); + done(error, false); + } + } +} diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 634fc959c..3802e3ef4 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -55,6 +55,10 @@ export class InfoService { globalPermissions.push(permissions.enableAuthGoogle); } + if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { + globalPermissions.push(permissions.enableAuthOidc); + } + if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) { globalPermissions.push(permissions.enableAuthToken); } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a8de3dc5e..8e1fe787f 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,6 +15,7 @@ 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'; @@ -61,6 +62,14 @@ 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/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index f37189569..7c2b0adb9 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -41,6 +41,7 @@ export class ConfigurationService { default: [] }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), + ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), @@ -57,6 +58,12 @@ export class ConfigurationService { JWT_SECRET_KEY: str({}), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), + OIDC_AUTHORIZATION_URL: str({ default: '' }), + OIDC_CLIENT_ID: str({ default: '' }), + OIDC_CLIENT_SECRET: str({ default: '' }), + OIDC_ISSUER: str({ default: '' }), + OIDC_TOKEN_URL: str({ default: '' }), + OIDC_USER_INFO_URL: str({ default: '' }), PORT: port({ default: DEFAULT_PORT }), PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 3a2ac687c..10988bbcd 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -17,6 +17,7 @@ export interface Environment extends CleanedEnvAccessors { DATA_SOURCES: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; ENABLE_FEATURE_AUTH_GOOGLE: boolean; + ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; @@ -32,6 +33,12 @@ export interface Environment extends CleanedEnvAccessors { JWT_SECRET_KEY: string; MAX_ACTIVITIES_TO_IMPORT: number; MAX_CHART_ITEMS: number; + OIDC_AUTHORIZATION_URL: string; + OIDC_CLIENT_ID: string; + OIDC_CLIENT_SECRET: string; + OIDC_ISSUER: string; + OIDC_TOKEN_URL: string; + OIDC_USER_INFO_URL: string; PORT: number; PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number; PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number; diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index 9fb9a8351..80239d56f 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/apps/client/src/app/components/header/header.component.ts @@ -105,6 +105,7 @@ export class GfHeaderComponent implements OnChanges { public hasFilters: boolean; public hasImpersonationId: boolean; public hasPermissionForAuthGoogle: boolean; + public hasPermissionForAuthOidc: boolean; public hasPermissionForAuthToken: boolean; public hasPermissionForSubscription: boolean; public hasPermissionToAccessAdminControl: boolean; @@ -170,6 +171,11 @@ export class GfHeaderComponent implements OnChanges { permissions.enableAuthGoogle ); + this.hasPermissionForAuthOidc = hasPermission( + this.info?.globalPermissions, + permissions.enableAuthOidc + ); + this.hasPermissionForAuthToken = hasPermission( this.info?.globalPermissions, permissions.enableAuthToken @@ -286,6 +292,7 @@ export class GfHeaderComponent implements OnChanges { data: { accessToken: '', hasPermissionToUseAuthGoogle: this.hasPermissionForAuthGoogle, + hasPermissionToUseAuthOidc: this.hasPermissionForAuthOidc, hasPermissionToUseAuthToken: this.hasPermissionForAuthToken, title: $localize`Sign in` }, diff --git a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts index c7c4ab3fd..e9222e142 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts @@ -1,6 +1,7 @@ export interface LoginWithAccessTokenDialogParams { accessToken: string; hasPermissionToUseAuthGoogle: boolean; + hasPermissionToUseAuthOidc: boolean; hasPermissionToUseAuthToken: boolean; title: string; } diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index bc232cfb7..0623a0f36 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -45,6 +45,17 @@ > } + + @if (data.hasPermissionToUseAuthOidc) { + + } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 597c27690..2e244568c 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -29,6 +29,7 @@ export const permissions = { deleteUser: 'deleteUser', deleteWatchlistItem: 'deleteWatchlistItem', enableAuthGoogle: 'enableAuthGoogle', + enableAuthOidc: 'enableAuthOidc', enableAuthToken: 'enableAuthToken', enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableFearAndGreedIndex: 'enableFearAndGreedIndex', @@ -159,6 +160,7 @@ export function filterGlobalPermissions( return globalPermissions.filter((permission) => { return ( permission !== permissions.enableAuthGoogle && + permission !== permissions.enableAuthOidc && permission !== permissions.enableSubscription ); }); diff --git a/package-lock.json b/package-lock.json index 198da4e48..34e0268a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "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", @@ -83,6 +84,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", + "passport-openidconnect": "^0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -125,6 +127,7 @@ "@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", @@ -14201,6 +14204,16 @@ "@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", @@ -22414,6 +22427,46 @@ "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", @@ -33742,7 +33795,6 @@ "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" @@ -34494,6 +34546,23 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-openidconnect": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz", + "integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==", + "license": "MIT", + "dependencies": { + "oauth": "0.10.x", + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -35875,6 +35944,15 @@ "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", @@ -40233,6 +40311,18 @@ "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 9965e4714..2a9bfee9b 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "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", @@ -129,6 +130,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", + "passport-openidconnect": "^0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -171,6 +173,7 @@ "@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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ec79008..232dde9ca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -335,6 +335,7 @@ enum Provider { ANONYMOUS GOOGLE INTERNET_IDENTITY + OIDC } enum Role { From 06a933b3acf263785829cdba3f07f5af9a41f0e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:08:42 +0100 Subject: [PATCH 02/36] Feature/add custom OIDC state store and remove express-session dependency --- apps/api/src/app/auth/oidc-state.store.ts | 111 ++++++++++++++++++++++ apps/api/src/app/auth/oidc.strategy.ts | 26 +++-- apps/api/src/main.ts | 9 -- package-lock.json | 74 +-------------- package.json | 2 - 5 files changed, 132 insertions(+), 90 deletions(-) create mode 100644 apps/api/src/app/auth/oidc-state.store.ts 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", From d9f5e79a5d84ba9a782a966e97138e3e015c24bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:14:48 +0100 Subject: [PATCH 03/36] Feature/refactor validateAnonymousLogin to simplify promise handling --- apps/api/src/app/auth/auth.service.ts | 36 +++++++++++---------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index a6ee5d260..6fe50dce0 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -17,30 +17,22 @@ export class AuthService { ) {} public async validateAnonymousLogin(accessToken: string): Promise { - return new Promise(async (resolve, reject) => { - try { - const hashedAccessToken = this.userService.createAccessToken({ - password: accessToken, - salt: this.configurationService.get('ACCESS_TOKEN_SALT') - }); + const hashedAccessToken = this.userService.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); - const [user] = await this.userService.users({ - where: { accessToken: hashedAccessToken } - }); + const [user] = await this.userService.users({ + where: { accessToken: hashedAccessToken } + }); - if (user) { - const jwt = this.jwtService.sign({ - id: user.id - }); + if (user) { + return this.jwtService.sign({ + id: user.id + }); + } - resolve(jwt); - } else { - throw new Error(); - } - } catch { - reject(); - } - }); + throw new Error(); } public async validateOAuthLogin({ @@ -75,7 +67,7 @@ export class AuthService { } catch (error) { throw new InternalServerErrorException( 'validateOAuthLogin', - error.message + error instanceof Error ? error.message : 'Unknown error' ); } } From 7aae13da0b3dff3303d5557f963af7c764c442be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:21:11 +0100 Subject: [PATCH 04/36] Feature: reorder fuctions --- apps/api/src/app/auth/auth.controller.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 5f21ab9fa..d5a5e4bea 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -136,6 +136,13 @@ export class AuthController { return this.webAuthService.generateRegistrationOptions(); } + @Post('webauthn/generate-authentication-options') + public async generateAuthenticationOptions( + @Body() body: { deviceId: string } + ) { + return this.webAuthService.generateAuthenticationOptions(body.deviceId); + } + @Post('webauthn/verify-attestation') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async verifyAttestation( @@ -144,13 +151,6 @@ export class AuthController { return this.webAuthService.verifyAttestation(body.credential); } - @Post('webauthn/generate-authentication-options') - public async generateAuthenticationOptions( - @Body() body: { deviceId: string } - ) { - return this.webAuthService.generateAuthenticationOptions(body.deviceId); - } - @Post('webauthn/verify-authentication') public async verifyAuthentication( @Body() body: { deviceId: string; credential: AssertionCredentialJSON } From 9839078b19b38c3a0851f2bcde6fc3aa2f20b172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:30:44 +0100 Subject: [PATCH 05/36] Feature: update comments in OidcStateStore for clarity --- apps/api/src/app/auth/oidc-state.store.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 1ee4bd690..5c9ef2ace 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -14,10 +14,8 @@ export class OidcStateStore { >(); private readonly STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes - /** - * Store request state. - * Signature matches passport-openidconnect SessionStore - */ + // Store request state. + // Signature matches passport-openidconnect SessionStore public store( _req: unknown, ctx: { maxAge?: number; nonce?: string; issued?: Date }, @@ -45,10 +43,8 @@ export class OidcStateStore { } } - /** - * Verify request state. - * Signature matches passport-openidconnect SessionStore - */ + // Verify request state. + // Signature matches passport-openidconnect SessionStore public verify( _req: unknown, handle: string, @@ -80,9 +76,7 @@ export class OidcStateStore { } } - /** - * Generate a cryptographically secure random handle - */ + // Generate a cryptographically secure random handle private generateHandle(): string { return ( Math.random().toString(36).substring(2, 15) + @@ -91,9 +85,7 @@ export class OidcStateStore { ); } - /** - * Clean up expired states - */ + // Clean up expired states private cleanup(): void { const now = Date.now(); const expiredKeys: string[] = []; From 743d58da77528f9c1885b4be5071b7c759729cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:39:14 +0100 Subject: [PATCH 06/36] Feature: enhance OIDC strategy with additional options and type safety --- apps/api/src/app/auth/oidc.strategy.ts | 31 ++++++++++++++++++-------- package-lock.json | 25 +++++++++++++++++++++ package.json | 1 + 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 663592b06..34d300d0f 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -1,36 +1,49 @@ import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; +import { Request } from 'express'; import { Strategy } from 'passport-openidconnect'; import { AuthService } from './auth.service'; import { OidcStateStore } from './oidc-state.store'; +interface OidcStrategyOptions { + authorizationURL?: string; + callbackURL: string; + clientID: string; + clientSecret: string; + issuer?: string; + scope?: string[]; + tokenURL?: string; + userInfoURL?: string; +} + @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { private static readonly stateStore = new OidcStateStore(); public constructor( private readonly authService: AuthService, - options: any + options: OidcStrategyOptions ) { super({ ...options, passReqToCallback: true, scope: ['openid', 'profile', 'email'], store: OidcStrategy.stateStore - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); } public async validate( - _request: any, + _request: Request, _issuer: string, - profile: any, - context: any, - idToken: any, - _accessToken: any, - _refreshToken: any, - params: any + profile: { id?: string }, + context: { claims?: { sub?: string } }, + idToken: { sub?: string }, + _accessToken: string, + _refreshToken: string, + params: { sub?: string } ) { try { const thirdPartyId = diff --git a/package-lock.json b/package-lock.json index 5dde2a62b..57de6a9ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,6 +132,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", + "@types/passport-openidconnect": "^0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "cypress": "6.2.1", @@ -14476,6 +14477,30 @@ "@types/passport": "*" } }, + "node_modules/@types/passport-openidconnect": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@types/passport-openidconnect/-/passport-openidconnect-0.1.3.tgz", + "integrity": "sha512-k1Ni7bG/9OZNo2Qpjg2W6GajL+pww6ZPaNWMXfpteCX4dXf4QgaZLt2hjR5IiPrqwBT9+W8KjCTJ/uhGIoBx/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index 091571f2d..5e5d6bff6 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", + "@types/passport-openidconnect": "^0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "cypress": "6.2.1", From ae0ea8beb776b59ae6ef7f519ca5d5a0960e0029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 13:40:11 +0100 Subject: [PATCH 07/36] Feature: add OIDC provider to the Provider enum --- .../migrations/20251103162035_add_oidc_provider/migration.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 prisma/migrations/20251103162035_add_oidc_provider/migration.sql diff --git a/prisma/migrations/20251103162035_add_oidc_provider/migration.sql b/prisma/migrations/20251103162035_add_oidc_provider/migration.sql new file mode 100644 index 000000000..f71f6eded --- /dev/null +++ b/prisma/migrations/20251103162035_add_oidc_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Provider" ADD VALUE 'OIDC'; From 1a4b9218db3c82441a9e2e3240bc3478c001fd9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 13:55:47 +0100 Subject: [PATCH 08/36] Feature: add OIDC_SCOPE configuration and update OIDC strategy to use dynamic scope --- apps/api/src/app/auth/auth.module.ts | 9 ++++++++- apps/api/src/app/auth/oidc.strategy.ts | 1 - .../src/services/configuration/configuration.service.ts | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index a417563fd..be91058f4 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -45,12 +45,19 @@ import { OidcStrategy } from './oidc.strategy'; configurationService: ConfigurationService ) => { const issuer = configurationService.get('OIDC_ISSUER'); + const scopeString = configurationService.get('OIDC_SCOPE'); + const scope = scopeString + .split(' ') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const options: any = { callbackURL: `${configurationService.get( 'ROOT_URL' )}/api/auth/oidc/callback`, clientID: configurationService.get('OIDC_CLIENT_ID'), - clientSecret: configurationService.get('OIDC_CLIENT_SECRET') + clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), + scope }; if (issuer) { diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 34d300d0f..3224daed9 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -29,7 +29,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { super({ ...options, passReqToCallback: true, - scope: ['openid', 'profile', 'email'], store: OidcStrategy.stateStore // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 7c2b0adb9..56b0124fe 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -62,6 +62,7 @@ export class ConfigurationService { OIDC_CLIENT_ID: str({ default: '' }), OIDC_CLIENT_SECRET: str({ default: '' }), OIDC_ISSUER: str({ default: '' }), + OIDC_SCOPE: str({ default: 'profile' }), OIDC_TOKEN_URL: str({ default: '' }), OIDC_USER_INFO_URL: str({ default: '' }), PORT: port({ default: DEFAULT_PORT }), From 7f7c03aaef9a22fba9bd1790c41776b1fb0dd317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 14:01:48 +0100 Subject: [PATCH 09/36] Enhance OIDC strategy options and add OIDC_SCOPE to environment interface --- apps/api/src/app/auth/auth.module.ts | 17 +++++++++++++++-- .../interfaces/environment.interface.ts | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index be91058f4..0e8a1dc16 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -51,7 +51,16 @@ import { OidcStrategy } from './oidc.strategy'; .map((s) => s.trim()) .filter((s) => s.length > 0); - const options: any = { + const options: { + authorizationURL?: string; + callbackURL: string; + clientID: string; + clientSecret: string; + issuer?: string; + scope: string[]; + tokenURL?: string; + userInfoURL?: string; + } = { callbackURL: `${configurationService.get( 'ROOT_URL' )}/api/auth/oidc/callback`, @@ -65,7 +74,11 @@ import { OidcStrategy } from './oidc.strategy'; const response = await fetch( `${issuer}/.well-known/openid-configuration` ); - const config = await response.json(); + const config = (await response.json()) as { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + }; options.authorizationURL = config.authorization_endpoint; options.issuer = issuer; diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 10988bbcd..c6223d1c3 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -37,6 +37,7 @@ export interface Environment extends CleanedEnvAccessors { OIDC_CLIENT_ID: string; OIDC_CLIENT_SECRET: string; OIDC_ISSUER: string; + OIDC_SCOPE: string; OIDC_TOKEN_URL: string; OIDC_USER_INFO_URL: string; PORT: number; From bdeb89e9a7bac864322cdea2e942eb0ea1d9f75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 14:10:24 +0100 Subject: [PATCH 10/36] Feature: add OIDC_CALLBACK_URL to configuration and update auth module to use it --- apps/api/src/app/auth/auth.module.ts | 8 +++++--- .../src/services/configuration/configuration.service.ts | 1 + apps/api/src/services/interfaces/environment.interface.ts | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 0e8a1dc16..00494ccdb 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -51,6 +51,10 @@ import { OidcStrategy } from './oidc.strategy'; .map((s) => s.trim()) .filter((s) => s.length > 0); + const callbackUrl = + configurationService.get('OIDC_CALLBACK_URL') || + `${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; + const options: { authorizationURL?: string; callbackURL: string; @@ -61,9 +65,7 @@ import { OidcStrategy } from './oidc.strategy'; tokenURL?: string; userInfoURL?: string; } = { - callbackURL: `${configurationService.get( - 'ROOT_URL' - )}/api/auth/oidc/callback`, + callbackURL: callbackUrl, clientID: configurationService.get('OIDC_CLIENT_ID'), clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), scope diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 56b0124fe..75b7ed4d1 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -59,6 +59,7 @@ export class ConfigurationService { MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), OIDC_AUTHORIZATION_URL: str({ default: '' }), + OIDC_CALLBACK_URL: str({ default: '' }), OIDC_CLIENT_ID: str({ default: '' }), OIDC_CLIENT_SECRET: str({ default: '' }), OIDC_ISSUER: str({ default: '' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index c6223d1c3..c37442688 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -34,6 +34,7 @@ export interface Environment extends CleanedEnvAccessors { MAX_ACTIVITIES_TO_IMPORT: number; MAX_CHART_ITEMS: number; OIDC_AUTHORIZATION_URL: string; + OIDC_CALLBACK_URL: string; OIDC_CLIENT_ID: string; OIDC_CLIENT_SECRET: string; OIDC_ISSUER: string; From bf21ee578cef7bc80ac05c608479a28ac8a452b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 20:50:05 +0100 Subject: [PATCH 11/36] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a25101db8..eec6eb8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Extended the user detail dialog of the admin control panel’s users section by the authentication method +- Added OIDC (OpenID Connect) as a login auth provider ### Changed From 5bc176df716e34f2446108ea736e6e4882165f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sun, 23 Nov 2025 17:16:08 +0100 Subject: [PATCH 12/36] feat: enhance OIDC strategy and state store with improved error handling and type definitions --- apps/api/src/app/auth/auth.module.ts | 16 +++++++- apps/api/src/app/auth/oidc-state.store.ts | 10 ++--- apps/api/src/app/auth/oidc.strategy.ts | 50 +++++++++++++++++------ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 00494ccdb..9c535f947 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -88,16 +88,30 @@ import { OidcStrategy } from './oidc.strategy'; options.userInfoURL = config.userinfo_endpoint; } catch (error) { Logger.error(error, 'OidcStrategy'); + throw new Error('Failed to fetch OIDC configuration from issuer'); } } else { options.authorizationURL = configurationService.get( 'OIDC_AUTHORIZATION_URL' ); + options.issuer = configurationService.get('OIDC_ISSUER'); options.tokenURL = configurationService.get('OIDC_TOKEN_URL'); options.userInfoURL = configurationService.get('OIDC_USER_INFO_URL'); } - return new OidcStrategy(authService, options); + return new OidcStrategy( + authService, + options as { + authorizationURL: string; + callbackURL: string; + clientID: string; + clientSecret: string; + issuer: string; + scope: string[]; + tokenURL: string; + userInfoURL: string; + } + ); }, inject: [AuthService, ConfigurationService] }, diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 5c9ef2ace..d1b578e2b 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -6,8 +6,8 @@ export class OidcStateStore { private stateMap = new Map< string, { - ctx: { maxAge?: number; nonce?: string; issued?: Date }; appState?: unknown; + ctx: { maxAge?: number; nonce?: string; issued?: Date }; meta?: unknown; timestamp: number; } @@ -18,9 +18,9 @@ export class OidcStateStore { // Signature matches passport-openidconnect SessionStore public store( _req: unknown, - ctx: { maxAge?: number; nonce?: string; issued?: Date }, - appState: unknown, _meta: unknown, + appState: unknown, + ctx: { maxAge?: number; nonce?: string; issued?: Date }, callback: (err: Error | null, handle?: string) => void ): void { try { @@ -50,8 +50,8 @@ export class OidcStateStore { handle: string, callback: ( err: Error | null, - ctx?: { maxAge?: number; nonce?: string; issued?: Date }, - appState?: unknown + appState?: unknown, + ctx?: { maxAge?: number; nonce?: string; issued?: Date } ) => void ): void { try { diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 3224daed9..2ac1e0473 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -8,14 +8,33 @@ import { AuthService } from './auth.service'; import { OidcStateStore } from './oidc-state.store'; interface OidcStrategyOptions { - authorizationURL?: string; + authorizationURL: string; callbackURL: string; clientID: string; clientSecret: string; - issuer?: string; + issuer: string; scope?: string[]; - tokenURL?: string; - userInfoURL?: string; + tokenURL: string; + userInfoURL: string; +} + +interface OidcProfile { + id?: string; + sub?: string; +} + +interface OidcContext { + claims?: { + sub?: string; + }; +} + +interface OidcIdToken { + sub?: string; +} + +interface OidcParams { + sub?: string; } @Injectable() @@ -30,25 +49,32 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { ...options, passReqToCallback: true, store: OidcStrategy.stateStore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + }); } public async validate( _request: Request, - _issuer: string, - profile: { id?: string }, - context: { claims?: { sub?: string } }, - idToken: { sub?: string }, + issuer: string, + profile: OidcProfile, + context: OidcContext, + idToken: OidcIdToken, _accessToken: string, _refreshToken: string, - params: { sub?: string } + params: OidcParams ) { try { const thirdPartyId = - params?.sub || idToken?.sub || context?.claims?.sub || profile?.id; + profile?.id ?? + profile?.sub ?? + idToken?.sub ?? + params?.sub ?? + context?.claims?.sub; if (!thirdPartyId) { + Logger.error( + `Missing subject identifier in OIDC response from ${issuer}`, + 'OidcStrategy' + ); throw new Error('Missing subject identifier in OIDC response'); } From ea32440bd5ec50f12d700788a19beaba1364b4bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Wed, 26 Nov 2025 07:36:39 +0100 Subject: [PATCH 13/36] add OIDC as a login auth provider to the changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 433b1b953..be76df609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added OIDC (OpenID Connect) as a login auth provider + ## 2.219.0 - 2025-11-23 ### Added - Extended the user detail dialog of the admin control panel’s users section by the authentication method -- Added OIDC (OpenID Connect) as a login auth provider ### Changed From 80c98129b5b51222a0bb6bb06401432d4973f2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Fri, 28 Nov 2025 22:53:27 +0100 Subject: [PATCH 14/36] OIDC flow fixes --- CHANGELOG.md | 2 +- apps/api/src/app/auth/auth.controller.ts | 20 ++++-- apps/api/src/app/auth/auth.module.ts | 71 +++++++------------ apps/api/src/app/auth/oidc.strategy.ts | 15 +--- .../configuration/configuration.service.ts | 2 +- .../interfaces/environment.interface.ts | 2 +- .../login-with-access-token-dialog.html | 2 +- 7 files changed, 47 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f60446fcc..7bf1acd10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added OIDC (OpenID Connect) as a login auth provider +- Added OIDC (_OpenID Connect_) as a login auth provider ### Changed diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index d5a5e4bea..cd51e2954 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -104,7 +104,15 @@ export class AuthController { @Get('oidc') @UseGuards(AuthGuard('oidc')) + @Version(VERSION_NEUTRAL) public oidcLogin() { + if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + // Initiates the OIDC login flow } @@ -130,12 +138,6 @@ export class AuthController { } } - @Get('webauthn/generate-registration-options') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async generateRegistrationOptions() { - return this.webAuthService.generateRegistrationOptions(); - } - @Post('webauthn/generate-authentication-options') public async generateAuthenticationOptions( @Body() body: { deviceId: string } @@ -143,6 +145,12 @@ export class AuthController { return this.webAuthService.generateAuthenticationOptions(body.deviceId); } + @Get('webauthn/generate-registration-options') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async generateRegistrationOptions() { + return this.webAuthService.generateRegistrationOptions(); + } + @Post('webauthn/verify-attestation') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async verifyAttestation( diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 9c535f947..60a3a64d1 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -8,8 +8,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { Module, Logger } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; +import type { StrategyOptions } from 'passport-openidconnect'; import { ApiKeyStrategy } from './api-key.strategy'; import { AuthController } from './auth.controller'; @@ -45,31 +46,13 @@ import { OidcStrategy } from './oidc.strategy'; configurationService: ConfigurationService ) => { const issuer = configurationService.get('OIDC_ISSUER'); - const scopeString = configurationService.get('OIDC_SCOPE'); - const scope = scopeString - .split(' ') - .map((s) => s.trim()) - .filter((s) => s.length > 0); + const scope = configurationService.get('OIDC_SCOPE'); const callbackUrl = configurationService.get('OIDC_CALLBACK_URL') || `${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; - const options: { - authorizationURL?: string; - callbackURL: string; - clientID: string; - clientSecret: string; - issuer?: string; - scope: string[]; - tokenURL?: string; - userInfoURL?: string; - } = { - callbackURL: callbackUrl, - clientID: configurationService.get('OIDC_CLIENT_ID'), - clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), - scope - }; + let options: StrategyOptions; if (issuer) { try { @@ -82,36 +65,36 @@ import { OidcStrategy } from './oidc.strategy'; userinfo_endpoint: string; }; - options.authorizationURL = config.authorization_endpoint; - options.issuer = issuer; - options.tokenURL = config.token_endpoint; - options.userInfoURL = config.userinfo_endpoint; + options = { + authorizationURL: config.authorization_endpoint, + callbackURL: callbackUrl, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), + issuer, + scope, + tokenURL: config.token_endpoint, + userInfoURL: config.userinfo_endpoint + }; } catch (error) { Logger.error(error, 'OidcStrategy'); throw new Error('Failed to fetch OIDC configuration from issuer'); } } else { - options.authorizationURL = configurationService.get( - 'OIDC_AUTHORIZATION_URL' - ); - options.issuer = configurationService.get('OIDC_ISSUER'); - options.tokenURL = configurationService.get('OIDC_TOKEN_URL'); - options.userInfoURL = configurationService.get('OIDC_USER_INFO_URL'); + options = { + authorizationURL: configurationService.get( + 'OIDC_AUTHORIZATION_URL' + ), + callbackURL: callbackUrl, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), + issuer: configurationService.get('OIDC_ISSUER'), + scope, + tokenURL: configurationService.get('OIDC_TOKEN_URL'), + userInfoURL: configurationService.get('OIDC_USER_INFO_URL') + }; } - return new OidcStrategy( - authService, - options as { - authorizationURL: string; - callbackURL: string; - clientID: string; - clientSecret: string; - issuer: string; - scope: string[]; - tokenURL: string; - userInfoURL: string; - } - ); + return new OidcStrategy(authService, options); }, inject: [AuthService, ConfigurationService] }, diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 2ac1e0473..8366c58bc 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -2,22 +2,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; import { Request } from 'express'; -import { Strategy } from 'passport-openidconnect'; +import { Strategy, type StrategyOptions } from 'passport-openidconnect'; import { AuthService } from './auth.service'; import { OidcStateStore } from './oidc-state.store'; -interface OidcStrategyOptions { - authorizationURL: string; - callbackURL: string; - clientID: string; - clientSecret: string; - issuer: string; - scope?: string[]; - tokenURL: string; - userInfoURL: string; -} - interface OidcProfile { id?: string; sub?: string; @@ -43,7 +32,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { public constructor( private readonly authService: AuthService, - options: OidcStrategyOptions + options: StrategyOptions ) { super({ ...options, diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 75b7ed4d1..59de60354 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -63,7 +63,7 @@ export class ConfigurationService { OIDC_CLIENT_ID: str({ default: '' }), OIDC_CLIENT_SECRET: str({ default: '' }), OIDC_ISSUER: str({ default: '' }), - OIDC_SCOPE: str({ default: 'profile' }), + OIDC_SCOPE: json({ default: ['openid'] }), OIDC_TOKEN_URL: str({ default: '' }), OIDC_USER_INFO_URL: str({ default: '' }), PORT: port({ default: DEFAULT_PORT }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index c37442688..3c03744f1 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -38,7 +38,7 @@ export interface Environment extends CleanedEnvAccessors { OIDC_CLIENT_ID: string; OIDC_CLIENT_SECRET: string; OIDC_ISSUER: string; - OIDC_SCOPE: string; + OIDC_SCOPE: string[]; OIDC_TOKEN_URL: string; OIDC_USER_INFO_URL: string; PORT: number; diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 0623a0f36..6570ab3d6 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -50,7 +50,7 @@
Sign in with OIDC From 4fdf59899e835958bc9da19558b07e7087712312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Fri, 28 Nov 2025 23:28:51 +0100 Subject: [PATCH 15/36] Refactor OIDC authentication flow and update dependencies --- apps/api/src/app/auth/auth.controller.ts | 2 - apps/api/src/app/auth/auth.module.ts | 4 +- apps/api/src/app/auth/oidc-state.store.ts | 42 ++++++++++++------- apps/api/src/app/auth/oidc.strategy.ts | 10 ++--- .../configuration/configuration.service.ts | 14 +++---- .../login-with-access-token-dialog.html | 4 +- package-lock.json | 4 +- package.json | 4 +- 8 files changed, 46 insertions(+), 38 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index cd51e2954..29af3a3c2 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -84,7 +84,6 @@ export class AuthController { @Req() request: Request, @Res() response: Response ) { - // Handles the Google OAuth2 callback const jwt: string = (request.user as any).jwt; if (jwt) { @@ -120,7 +119,6 @@ export class AuthController { @UseGuards(AuthGuard('oidc')) @Version(VERSION_NEUTRAL) public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { - // Handles the OIDC callback const jwt: string = (request.user as any).jwt; if (jwt) { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 60a3a64d1..c31e66299 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -40,6 +40,7 @@ import { OidcStrategy } from './oidc.strategy'; GoogleStrategy, JwtStrategy, { + inject: [AuthService, ConfigurationService], provide: OidcStrategy, useFactory: async ( authService: AuthService, @@ -95,8 +96,7 @@ import { OidcStrategy } from './oidc.strategy'; } return new OidcStrategy(authService, options); - }, - inject: [AuthService, ConfigurationService] + } }, WebAuthService ] diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index d1b578e2b..437846cf1 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -1,3 +1,5 @@ +import ms from 'ms'; + /** * Custom state store for OIDC authentication that doesn't rely on express-session. * This store manages OAuth2 state parameters in memory with automatic cleanup. @@ -7,15 +9,17 @@ export class OidcStateStore { string, { appState?: unknown; - ctx: { maxAge?: number; nonce?: string; issued?: Date }; + ctx: { issued?: Date; maxAge?: number; nonce?: string }; meta?: unknown; timestamp: number; } >(); - private readonly STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes + private readonly STATE_EXPIRY_MS = ms('10 minutes'); - // Store request state. - // Signature matches passport-openidconnect SessionStore + /** + * Store request state. + * Signature matches passport-openidconnect SessionStore + */ public store( _req: unknown, _meta: unknown, @@ -43,8 +47,10 @@ export class OidcStateStore { } } - // Verify request state. - // Signature matches passport-openidconnect SessionStore + /** + * Verify request state. + * Signature matches passport-openidconnect SessionStore + */ public verify( _req: unknown, handle: string, @@ -76,16 +82,9 @@ export class OidcStateStore { } } - // 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 + /** + * Clean up expired states + */ private cleanup(): void { const now = Date.now(); const expiredKeys: string[] = []; @@ -100,4 +99,15 @@ export class OidcStateStore { this.stateMap.delete(key); } } + + /** + * 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) + ); + } } diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 8366c58bc..58fd7bd87 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -59,6 +59,11 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { params?.sub ?? context?.claims?.sub; + const jwt = await this.authService.validateOAuthLogin({ + provider: Provider.OIDC, + thirdPartyId + }); + if (!thirdPartyId) { Logger.error( `Missing subject identifier in OIDC response from ${issuer}`, @@ -67,11 +72,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { throw new Error('Missing subject identifier in OIDC response'); } - const jwt = await this.authService.validateOAuthLogin({ - provider: Provider.OIDC, - thirdPartyId - }); - return { jwt }; } catch (error) { Logger.error(error, 'OidcStrategy'); diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 59de60354..6f139b305 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -58,14 +58,14 @@ export class ConfigurationService { JWT_SECRET_KEY: str({}), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), - OIDC_AUTHORIZATION_URL: str({ default: '' }), - OIDC_CALLBACK_URL: str({ default: '' }), - OIDC_CLIENT_ID: str({ default: '' }), - OIDC_CLIENT_SECRET: str({ default: '' }), - OIDC_ISSUER: str({ default: '' }), + OIDC_AUTHORIZATION_URL: str({ default: undefined }), + OIDC_CALLBACK_URL: str({ default: undefined }), + OIDC_CLIENT_ID: str({ default: undefined }), + OIDC_CLIENT_SECRET: str({ default: undefined }), + OIDC_ISSUER: str({ default: undefined }), OIDC_SCOPE: json({ default: ['openid'] }), - OIDC_TOKEN_URL: str({ default: '' }), - OIDC_USER_INFO_URL: str({ default: '' }), + OIDC_TOKEN_URL: str({ default: undefined }), + OIDC_USER_INFO_URL: str({ default: undefined }), PORT: port({ default: DEFAULT_PORT }), PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 6570ab3d6..d345c4df5 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -41,7 +41,7 @@ class="mr-2" src="../assets/icons/google.svg" style="height: 1rem" - />Sign in with GoogleSign in with Google
} @@ -52,7 +52,7 @@ class="px-4 rounded-pill" href="../api/auth/oidc" mat-stroked-button - >Sign in with OIDCSign in with OIDC } diff --git a/package-lock.json b/package-lock.json index ac64925a8..81e6c2677 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,7 +83,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", - "passport-openidconnect": "^0.1.2", + "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -131,7 +131,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", - "@types/passport-openidconnect": "^0.1.3", + "@types/passport-openidconnect": "0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "cypress": "6.2.1", diff --git a/package.json b/package.json index d10f81a73..4de533702 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", - "passport-openidconnect": "^0.1.2", + "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -177,7 +177,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", - "@types/passport-openidconnect": "^0.1.3", + "@types/passport-openidconnect": "0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "cypress": "6.2.1", From 04cb1e1579ab864e48cb4a3c2a76e802d5e8cc2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 18:53:53 +0100 Subject: [PATCH 16/36] Feature/add OIDC authentication support --- apps/api/src/app/auth/auth.controller.ts | 28 ++++++ apps/api/src/app/auth/auth.module.ts | 45 ++++++++- apps/api/src/app/auth/oidc.strategy.ts | 40 ++++++++ apps/api/src/app/info/info.service.ts | 4 + apps/api/src/main.ts | 9 ++ .../configuration/configuration.service.ts | 7 ++ .../interfaces/environment.interface.ts | 7 ++ .../app/components/header/header.component.ts | 7 ++ .../interfaces/interfaces.ts | 1 + .../login-with-access-token-dialog.html | 11 +++ libs/common/src/lib/permissions.ts | 2 + package-lock.json | 92 ++++++++++++++++++- package.json | 3 + prisma/schema.prisma | 1 + 14 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/app/auth/oidc.strategy.ts diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index b45e7b97b..5f21ab9fa 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -102,6 +102,34 @@ export class AuthController { } } + @Get('oidc') + @UseGuards(AuthGuard('oidc')) + public oidcLogin() { + // Initiates the OIDC login flow + } + + @Get('oidc/callback') + @UseGuards(AuthGuard('oidc')) + @Version(VERSION_NEUTRAL) + public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { + // Handles the OIDC callback + const jwt: string = (request.user as any).jwt; + + if (jwt) { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` + ); + } else { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth` + ); + } + } + @Get('webauthn/generate-registration-options') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async generateRegistrationOptions() { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 824c432b1..a417563fd 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -4,10 +4,11 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { Module } from '@nestjs/common'; +import { Module, Logger } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ApiKeyStrategy } from './api-key.strategy'; @@ -15,6 +16,7 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './google.strategy'; import { JwtStrategy } from './jwt.strategy'; +import { OidcStrategy } from './oidc.strategy'; @Module({ controllers: [AuthController], @@ -36,6 +38,47 @@ import { JwtStrategy } from './jwt.strategy'; AuthService, GoogleStrategy, JwtStrategy, + { + provide: OidcStrategy, + useFactory: async ( + authService: AuthService, + configurationService: ConfigurationService + ) => { + const issuer = configurationService.get('OIDC_ISSUER'); + const options: any = { + callbackURL: `${configurationService.get( + 'ROOT_URL' + )}/api/auth/oidc/callback`, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET') + }; + + if (issuer) { + try { + const response = await fetch( + `${issuer}/.well-known/openid-configuration` + ); + const config = await response.json(); + + options.authorizationURL = config.authorization_endpoint; + options.issuer = issuer; + options.tokenURL = config.token_endpoint; + options.userInfoURL = config.userinfo_endpoint; + } catch (error) { + Logger.error(error, 'OidcStrategy'); + } + } else { + options.authorizationURL = configurationService.get( + 'OIDC_AUTHORIZATION_URL' + ); + options.tokenURL = configurationService.get('OIDC_TOKEN_URL'); + options.userInfoURL = configurationService.get('OIDC_USER_INFO_URL'); + } + + return new OidcStrategy(authService, options); + }, + inject: [AuthService, ConfigurationService] + }, WebAuthService ] }) diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts new file mode 100644 index 000000000..f3996ed67 --- /dev/null +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -0,0 +1,40 @@ +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'; + +@Injectable() +export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { + public constructor( + private readonly authService: AuthService, + options: any + ) { + super({ + ...options, + passReqToCallback: true, + scope: ['openid', 'profile', 'email'] + }); + } + + public async validate( + _request: any, + _issuer: string, + profile: any, + done: DoneCallback + ) { + try { + const jwt = await this.authService.validateOAuthLogin({ + provider: Provider.OIDC, + thirdPartyId: profile.id + }); + + done(null, { jwt }); + } catch (error) { + Logger.error(error, 'OidcStrategy'); + done(error, false); + } + } +} diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 634fc959c..3802e3ef4 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -55,6 +55,10 @@ export class InfoService { globalPermissions.push(permissions.enableAuthGoogle); } + if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { + globalPermissions.push(permissions.enableAuthOidc); + } + if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) { globalPermissions.push(permissions.enableAuthToken); } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a8de3dc5e..8e1fe787f 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,6 +15,7 @@ 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'; @@ -61,6 +62,14 @@ 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/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index f37189569..7c2b0adb9 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -41,6 +41,7 @@ export class ConfigurationService { default: [] }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), + ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), @@ -57,6 +58,12 @@ export class ConfigurationService { JWT_SECRET_KEY: str({}), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), + OIDC_AUTHORIZATION_URL: str({ default: '' }), + OIDC_CLIENT_ID: str({ default: '' }), + OIDC_CLIENT_SECRET: str({ default: '' }), + OIDC_ISSUER: str({ default: '' }), + OIDC_TOKEN_URL: str({ default: '' }), + OIDC_USER_INFO_URL: str({ default: '' }), PORT: port({ default: DEFAULT_PORT }), PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 3a2ac687c..10988bbcd 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -17,6 +17,7 @@ export interface Environment extends CleanedEnvAccessors { DATA_SOURCES: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; ENABLE_FEATURE_AUTH_GOOGLE: boolean; + ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; @@ -32,6 +33,12 @@ export interface Environment extends CleanedEnvAccessors { JWT_SECRET_KEY: string; MAX_ACTIVITIES_TO_IMPORT: number; MAX_CHART_ITEMS: number; + OIDC_AUTHORIZATION_URL: string; + OIDC_CLIENT_ID: string; + OIDC_CLIENT_SECRET: string; + OIDC_ISSUER: string; + OIDC_TOKEN_URL: string; + OIDC_USER_INFO_URL: string; PORT: number; PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number; PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number; diff --git a/apps/client/src/app/components/header/header.component.ts b/apps/client/src/app/components/header/header.component.ts index 9fb9a8351..80239d56f 100644 --- a/apps/client/src/app/components/header/header.component.ts +++ b/apps/client/src/app/components/header/header.component.ts @@ -105,6 +105,7 @@ export class GfHeaderComponent implements OnChanges { public hasFilters: boolean; public hasImpersonationId: boolean; public hasPermissionForAuthGoogle: boolean; + public hasPermissionForAuthOidc: boolean; public hasPermissionForAuthToken: boolean; public hasPermissionForSubscription: boolean; public hasPermissionToAccessAdminControl: boolean; @@ -170,6 +171,11 @@ export class GfHeaderComponent implements OnChanges { permissions.enableAuthGoogle ); + this.hasPermissionForAuthOidc = hasPermission( + this.info?.globalPermissions, + permissions.enableAuthOidc + ); + this.hasPermissionForAuthToken = hasPermission( this.info?.globalPermissions, permissions.enableAuthToken @@ -286,6 +292,7 @@ export class GfHeaderComponent implements OnChanges { data: { accessToken: '', hasPermissionToUseAuthGoogle: this.hasPermissionForAuthGoogle, + hasPermissionToUseAuthOidc: this.hasPermissionForAuthOidc, hasPermissionToUseAuthToken: this.hasPermissionForAuthToken, title: $localize`Sign in` }, diff --git a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts index c7c4ab3fd..e9222e142 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts @@ -1,6 +1,7 @@ export interface LoginWithAccessTokenDialogParams { accessToken: string; hasPermissionToUseAuthGoogle: boolean; + hasPermissionToUseAuthOidc: boolean; hasPermissionToUseAuthToken: boolean; title: string; } diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index bc232cfb7..0623a0f36 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -45,6 +45,17 @@ > } + + @if (data.hasPermissionToUseAuthOidc) { + + } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 597c27690..2e244568c 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -29,6 +29,7 @@ export const permissions = { deleteUser: 'deleteUser', deleteWatchlistItem: 'deleteWatchlistItem', enableAuthGoogle: 'enableAuthGoogle', + enableAuthOidc: 'enableAuthOidc', enableAuthToken: 'enableAuthToken', enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableFearAndGreedIndex: 'enableFearAndGreedIndex', @@ -159,6 +160,7 @@ export function filterGlobalPermissions( return globalPermissions.filter((permission) => { return ( permission !== permissions.enableAuthGoogle && + permission !== permissions.enableAuthOidc && permission !== permissions.enableSubscription ); }); diff --git a/package-lock.json b/package-lock.json index f3f9f3824..b6690cc8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "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", @@ -83,6 +84,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", + "passport-openidconnect": "^0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -123,6 +125,7 @@ "@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", @@ -14228,6 +14231,16 @@ "@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", @@ -22499,6 +22512,46 @@ "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", @@ -33967,7 +34020,6 @@ "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" @@ -34723,6 +34775,23 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-openidconnect": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz", + "integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==", + "license": "MIT", + "dependencies": { + "oauth": "0.10.x", + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -36114,6 +36183,15 @@ "optional": true, "peer": true }, + "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", @@ -40492,6 +40570,18 @@ "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 6cf778ee2..6a345d6ea 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "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,6 +128,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", + "passport-openidconnect": "^0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -167,6 +169,7 @@ "@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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ec79008..232dde9ca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -335,6 +335,7 @@ enum Provider { ANONYMOUS GOOGLE INTERNET_IDENTITY + OIDC } enum Role { From d1d7b972a5e86d303b3059d70754144753147492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:08:42 +0100 Subject: [PATCH 17/36] Feature/add custom OIDC state store and remove express-session dependency --- apps/api/src/app/auth/oidc-state.store.ts | 111 ++++++++++++++++++++++ apps/api/src/app/auth/oidc.strategy.ts | 26 +++-- apps/api/src/main.ts | 9 -- package-lock.json | 74 +-------------- package.json | 2 - 5 files changed, 132 insertions(+), 90 deletions(-) create mode 100644 apps/api/src/app/auth/oidc-state.store.ts 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 b6690cc8d..a92685e29 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", @@ -125,7 +124,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", @@ -14231,16 +14229,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", @@ -22512,46 +22500,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", @@ -34020,6 +33968,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" @@ -36183,15 +36132,6 @@ "optional": true, "peer": true }, - "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", @@ -40570,18 +40510,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 6a345d6ea..9f41c2c9a 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,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", @@ -169,7 +168,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", From 5c8d9b932acfda24dd59fffe531789ef7110e317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:14:48 +0100 Subject: [PATCH 18/36] Feature/refactor validateAnonymousLogin to simplify promise handling --- apps/api/src/app/auth/auth.service.ts | 36 +++++++++++---------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index a6ee5d260..6fe50dce0 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -17,30 +17,22 @@ export class AuthService { ) {} public async validateAnonymousLogin(accessToken: string): Promise { - return new Promise(async (resolve, reject) => { - try { - const hashedAccessToken = this.userService.createAccessToken({ - password: accessToken, - salt: this.configurationService.get('ACCESS_TOKEN_SALT') - }); + const hashedAccessToken = this.userService.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); - const [user] = await this.userService.users({ - where: { accessToken: hashedAccessToken } - }); + const [user] = await this.userService.users({ + where: { accessToken: hashedAccessToken } + }); - if (user) { - const jwt = this.jwtService.sign({ - id: user.id - }); + if (user) { + return this.jwtService.sign({ + id: user.id + }); + } - resolve(jwt); - } else { - throw new Error(); - } - } catch { - reject(); - } - }); + throw new Error(); } public async validateOAuthLogin({ @@ -75,7 +67,7 @@ export class AuthService { } catch (error) { throw new InternalServerErrorException( 'validateOAuthLogin', - error.message + error instanceof Error ? error.message : 'Unknown error' ); } } From bfb85e943e41e98f570f03af20290057401b82de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:21:11 +0100 Subject: [PATCH 19/36] Feature: reorder fuctions --- apps/api/src/app/auth/auth.controller.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 5f21ab9fa..d5a5e4bea 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -136,6 +136,13 @@ export class AuthController { return this.webAuthService.generateRegistrationOptions(); } + @Post('webauthn/generate-authentication-options') + public async generateAuthenticationOptions( + @Body() body: { deviceId: string } + ) { + return this.webAuthService.generateAuthenticationOptions(body.deviceId); + } + @Post('webauthn/verify-attestation') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async verifyAttestation( @@ -144,13 +151,6 @@ export class AuthController { return this.webAuthService.verifyAttestation(body.credential); } - @Post('webauthn/generate-authentication-options') - public async generateAuthenticationOptions( - @Body() body: { deviceId: string } - ) { - return this.webAuthService.generateAuthenticationOptions(body.deviceId); - } - @Post('webauthn/verify-authentication') public async verifyAuthentication( @Body() body: { deviceId: string; credential: AssertionCredentialJSON } From 2e7a53aedd464ed9c524e17486774f0244bda58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:30:44 +0100 Subject: [PATCH 20/36] Feature: update comments in OidcStateStore for clarity --- apps/api/src/app/auth/oidc-state.store.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 1ee4bd690..5c9ef2ace 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -14,10 +14,8 @@ export class OidcStateStore { >(); private readonly STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes - /** - * Store request state. - * Signature matches passport-openidconnect SessionStore - */ + // Store request state. + // Signature matches passport-openidconnect SessionStore public store( _req: unknown, ctx: { maxAge?: number; nonce?: string; issued?: Date }, @@ -45,10 +43,8 @@ export class OidcStateStore { } } - /** - * Verify request state. - * Signature matches passport-openidconnect SessionStore - */ + // Verify request state. + // Signature matches passport-openidconnect SessionStore public verify( _req: unknown, handle: string, @@ -80,9 +76,7 @@ export class OidcStateStore { } } - /** - * Generate a cryptographically secure random handle - */ + // Generate a cryptographically secure random handle private generateHandle(): string { return ( Math.random().toString(36).substring(2, 15) + @@ -91,9 +85,7 @@ export class OidcStateStore { ); } - /** - * Clean up expired states - */ + // Clean up expired states private cleanup(): void { const now = Date.now(); const expiredKeys: string[] = []; From 635760d760ca54242f421797118d621f0cff52e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 20 Nov 2025 19:39:14 +0100 Subject: [PATCH 21/36] Feature: enhance OIDC strategy with additional options and type safety --- apps/api/src/app/auth/oidc.strategy.ts | 31 ++++++++++++++++++-------- package-lock.json | 25 +++++++++++++++++++++ package.json | 1 + 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 663592b06..34d300d0f 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -1,36 +1,49 @@ import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; +import { Request } from 'express'; import { Strategy } from 'passport-openidconnect'; import { AuthService } from './auth.service'; import { OidcStateStore } from './oidc-state.store'; +interface OidcStrategyOptions { + authorizationURL?: string; + callbackURL: string; + clientID: string; + clientSecret: string; + issuer?: string; + scope?: string[]; + tokenURL?: string; + userInfoURL?: string; +} + @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { private static readonly stateStore = new OidcStateStore(); public constructor( private readonly authService: AuthService, - options: any + options: OidcStrategyOptions ) { super({ ...options, passReqToCallback: true, scope: ['openid', 'profile', 'email'], store: OidcStrategy.stateStore - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); } public async validate( - _request: any, + _request: Request, _issuer: string, - profile: any, - context: any, - idToken: any, - _accessToken: any, - _refreshToken: any, - params: any + profile: { id?: string }, + context: { claims?: { sub?: string } }, + idToken: { sub?: string }, + _accessToken: string, + _refreshToken: string, + params: { sub?: string } ) { try { const thirdPartyId = diff --git a/package-lock.json b/package-lock.json index a92685e29..c6f2e4155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -130,6 +130,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", + "@types/passport-openidconnect": "^0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "eslint": "9.35.0", @@ -14503,6 +14504,30 @@ "@types/passport": "*" } }, + "node_modules/@types/passport-openidconnect": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@types/passport-openidconnect/-/passport-openidconnect-0.1.3.tgz", + "integrity": "sha512-k1Ni7bG/9OZNo2Qpjg2W6GajL+pww6ZPaNWMXfpteCX4dXf4QgaZLt2hjR5IiPrqwBT9+W8KjCTJ/uhGIoBx/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index 9f41c2c9a..9fecefc05 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", + "@types/passport-openidconnect": "^0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "eslint": "9.35.0", From 0614db0b2344ce52cea725d2f62738eb703402ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 13:40:11 +0100 Subject: [PATCH 22/36] Feature: add OIDC provider to the Provider enum --- .../migrations/20251103162035_add_oidc_provider/migration.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 prisma/migrations/20251103162035_add_oidc_provider/migration.sql diff --git a/prisma/migrations/20251103162035_add_oidc_provider/migration.sql b/prisma/migrations/20251103162035_add_oidc_provider/migration.sql new file mode 100644 index 000000000..f71f6eded --- /dev/null +++ b/prisma/migrations/20251103162035_add_oidc_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Provider" ADD VALUE 'OIDC'; From 33b55e6cea58f8dccb4311bf00bc9c6c790c17b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 13:55:47 +0100 Subject: [PATCH 23/36] Feature: add OIDC_SCOPE configuration and update OIDC strategy to use dynamic scope --- apps/api/src/app/auth/auth.module.ts | 9 ++++++++- apps/api/src/app/auth/oidc.strategy.ts | 1 - .../src/services/configuration/configuration.service.ts | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index a417563fd..be91058f4 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -45,12 +45,19 @@ import { OidcStrategy } from './oidc.strategy'; configurationService: ConfigurationService ) => { const issuer = configurationService.get('OIDC_ISSUER'); + const scopeString = configurationService.get('OIDC_SCOPE'); + const scope = scopeString + .split(' ') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const options: any = { callbackURL: `${configurationService.get( 'ROOT_URL' )}/api/auth/oidc/callback`, clientID: configurationService.get('OIDC_CLIENT_ID'), - clientSecret: configurationService.get('OIDC_CLIENT_SECRET') + clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), + scope }; if (issuer) { diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 34d300d0f..3224daed9 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -29,7 +29,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { super({ ...options, passReqToCallback: true, - scope: ['openid', 'profile', 'email'], store: OidcStrategy.stateStore // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 7c2b0adb9..56b0124fe 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -62,6 +62,7 @@ export class ConfigurationService { OIDC_CLIENT_ID: str({ default: '' }), OIDC_CLIENT_SECRET: str({ default: '' }), OIDC_ISSUER: str({ default: '' }), + OIDC_SCOPE: str({ default: 'profile' }), OIDC_TOKEN_URL: str({ default: '' }), OIDC_USER_INFO_URL: str({ default: '' }), PORT: port({ default: DEFAULT_PORT }), From 43a8814e0269a034cf1e6660091ce2fceb4e30e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 14:01:48 +0100 Subject: [PATCH 24/36] Enhance OIDC strategy options and add OIDC_SCOPE to environment interface --- apps/api/src/app/auth/auth.module.ts | 17 +++++++++++++++-- .../interfaces/environment.interface.ts | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index be91058f4..0e8a1dc16 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -51,7 +51,16 @@ import { OidcStrategy } from './oidc.strategy'; .map((s) => s.trim()) .filter((s) => s.length > 0); - const options: any = { + const options: { + authorizationURL?: string; + callbackURL: string; + clientID: string; + clientSecret: string; + issuer?: string; + scope: string[]; + tokenURL?: string; + userInfoURL?: string; + } = { callbackURL: `${configurationService.get( 'ROOT_URL' )}/api/auth/oidc/callback`, @@ -65,7 +74,11 @@ import { OidcStrategy } from './oidc.strategy'; const response = await fetch( `${issuer}/.well-known/openid-configuration` ); - const config = await response.json(); + const config = (await response.json()) as { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + }; options.authorizationURL = config.authorization_endpoint; options.issuer = issuer; diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 10988bbcd..c6223d1c3 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -37,6 +37,7 @@ export interface Environment extends CleanedEnvAccessors { OIDC_CLIENT_ID: string; OIDC_CLIENT_SECRET: string; OIDC_ISSUER: string; + OIDC_SCOPE: string; OIDC_TOKEN_URL: string; OIDC_USER_INFO_URL: string; PORT: number; From 42f818c5d59dc65eb93814aed79ead4012ae87d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 14:10:24 +0100 Subject: [PATCH 25/36] Feature: add OIDC_CALLBACK_URL to configuration and update auth module to use it --- apps/api/src/app/auth/auth.module.ts | 8 +++++--- .../src/services/configuration/configuration.service.ts | 1 + apps/api/src/services/interfaces/environment.interface.ts | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 0e8a1dc16..00494ccdb 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -51,6 +51,10 @@ import { OidcStrategy } from './oidc.strategy'; .map((s) => s.trim()) .filter((s) => s.length > 0); + const callbackUrl = + configurationService.get('OIDC_CALLBACK_URL') || + `${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; + const options: { authorizationURL?: string; callbackURL: string; @@ -61,9 +65,7 @@ import { OidcStrategy } from './oidc.strategy'; tokenURL?: string; userInfoURL?: string; } = { - callbackURL: `${configurationService.get( - 'ROOT_URL' - )}/api/auth/oidc/callback`, + callbackURL: callbackUrl, clientID: configurationService.get('OIDC_CLIENT_ID'), clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), scope diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 56b0124fe..75b7ed4d1 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -59,6 +59,7 @@ export class ConfigurationService { MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), OIDC_AUTHORIZATION_URL: str({ default: '' }), + OIDC_CALLBACK_URL: str({ default: '' }), OIDC_CLIENT_ID: str({ default: '' }), OIDC_CLIENT_SECRET: str({ default: '' }), OIDC_ISSUER: str({ default: '' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index c6223d1c3..c37442688 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -34,6 +34,7 @@ export interface Environment extends CleanedEnvAccessors { MAX_ACTIVITIES_TO_IMPORT: number; MAX_CHART_ITEMS: number; OIDC_AUTHORIZATION_URL: string; + OIDC_CALLBACK_URL: string; OIDC_CLIENT_ID: string; OIDC_CLIENT_SECRET: string; OIDC_ISSUER: string; From ab4c7f819c4873d5a32a612a545cdce14c53a1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 22 Nov 2025 20:50:05 +0100 Subject: [PATCH 26/36] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1d50d91..e34bb4d11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Extended the user detail dialog of the admin control panel’s users section by the authentication method +- Added OIDC (OpenID Connect) as a login auth provider ### Changed From 5bc28bebe4c78e1ca7d50c5e00e588801eb8b79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sun, 23 Nov 2025 17:16:08 +0100 Subject: [PATCH 27/36] feat: enhance OIDC strategy and state store with improved error handling and type definitions --- apps/api/src/app/auth/auth.module.ts | 16 +++++++- apps/api/src/app/auth/oidc-state.store.ts | 10 ++--- apps/api/src/app/auth/oidc.strategy.ts | 50 +++++++++++++++++------ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 00494ccdb..9c535f947 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -88,16 +88,30 @@ import { OidcStrategy } from './oidc.strategy'; options.userInfoURL = config.userinfo_endpoint; } catch (error) { Logger.error(error, 'OidcStrategy'); + throw new Error('Failed to fetch OIDC configuration from issuer'); } } else { options.authorizationURL = configurationService.get( 'OIDC_AUTHORIZATION_URL' ); + options.issuer = configurationService.get('OIDC_ISSUER'); options.tokenURL = configurationService.get('OIDC_TOKEN_URL'); options.userInfoURL = configurationService.get('OIDC_USER_INFO_URL'); } - return new OidcStrategy(authService, options); + return new OidcStrategy( + authService, + options as { + authorizationURL: string; + callbackURL: string; + clientID: string; + clientSecret: string; + issuer: string; + scope: string[]; + tokenURL: string; + userInfoURL: string; + } + ); }, inject: [AuthService, ConfigurationService] }, diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 5c9ef2ace..d1b578e2b 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -6,8 +6,8 @@ export class OidcStateStore { private stateMap = new Map< string, { - ctx: { maxAge?: number; nonce?: string; issued?: Date }; appState?: unknown; + ctx: { maxAge?: number; nonce?: string; issued?: Date }; meta?: unknown; timestamp: number; } @@ -18,9 +18,9 @@ export class OidcStateStore { // Signature matches passport-openidconnect SessionStore public store( _req: unknown, - ctx: { maxAge?: number; nonce?: string; issued?: Date }, - appState: unknown, _meta: unknown, + appState: unknown, + ctx: { maxAge?: number; nonce?: string; issued?: Date }, callback: (err: Error | null, handle?: string) => void ): void { try { @@ -50,8 +50,8 @@ export class OidcStateStore { handle: string, callback: ( err: Error | null, - ctx?: { maxAge?: number; nonce?: string; issued?: Date }, - appState?: unknown + appState?: unknown, + ctx?: { maxAge?: number; nonce?: string; issued?: Date } ) => void ): void { try { diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 3224daed9..2ac1e0473 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -8,14 +8,33 @@ import { AuthService } from './auth.service'; import { OidcStateStore } from './oidc-state.store'; interface OidcStrategyOptions { - authorizationURL?: string; + authorizationURL: string; callbackURL: string; clientID: string; clientSecret: string; - issuer?: string; + issuer: string; scope?: string[]; - tokenURL?: string; - userInfoURL?: string; + tokenURL: string; + userInfoURL: string; +} + +interface OidcProfile { + id?: string; + sub?: string; +} + +interface OidcContext { + claims?: { + sub?: string; + }; +} + +interface OidcIdToken { + sub?: string; +} + +interface OidcParams { + sub?: string; } @Injectable() @@ -30,25 +49,32 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { ...options, passReqToCallback: true, store: OidcStrategy.stateStore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + }); } public async validate( _request: Request, - _issuer: string, - profile: { id?: string }, - context: { claims?: { sub?: string } }, - idToken: { sub?: string }, + issuer: string, + profile: OidcProfile, + context: OidcContext, + idToken: OidcIdToken, _accessToken: string, _refreshToken: string, - params: { sub?: string } + params: OidcParams ) { try { const thirdPartyId = - params?.sub || idToken?.sub || context?.claims?.sub || profile?.id; + profile?.id ?? + profile?.sub ?? + idToken?.sub ?? + params?.sub ?? + context?.claims?.sub; if (!thirdPartyId) { + Logger.error( + `Missing subject identifier in OIDC response from ${issuer}`, + 'OidcStrategy' + ); throw new Error('Missing subject identifier in OIDC response'); } From 44f809b1243c1550bb49574bbb4e89cae7d531a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Wed, 26 Nov 2025 07:36:39 +0100 Subject: [PATCH 28/36] add OIDC as a login auth provider to the changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e34bb4d11..abe1702c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added _OpenID Connect_ (`OIDC`) as a new login provider + ### Changed - Refactored the API query parameters in various data provider services @@ -36,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Extended the user detail dialog of the admin control panel’s users section by the authentication method -- Added OIDC (OpenID Connect) as a login auth provider ### Changed From 7485cfe6f3c79d493c9095a8ed0d42b60462314f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Fri, 28 Nov 2025 22:53:27 +0100 Subject: [PATCH 29/36] OIDC flow fixes --- CHANGELOG.md | 2 +- apps/api/src/app/auth/auth.controller.ts | 20 ++++-- apps/api/src/app/auth/auth.module.ts | 71 +++++++------------ apps/api/src/app/auth/oidc.strategy.ts | 15 +--- .../configuration/configuration.service.ts | 2 +- .../interfaces/environment.interface.ts | 2 +- .../login-with-access-token-dialog.html | 2 +- 7 files changed, 47 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abe1702c2..897cb577a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added _OpenID Connect_ (`OIDC`) as a new login provider +- Added OIDC (_OpenID Connect_) as a login auth provider ### Changed diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index d5a5e4bea..cd51e2954 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -104,7 +104,15 @@ export class AuthController { @Get('oidc') @UseGuards(AuthGuard('oidc')) + @Version(VERSION_NEUTRAL) public oidcLogin() { + if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + // Initiates the OIDC login flow } @@ -130,12 +138,6 @@ export class AuthController { } } - @Get('webauthn/generate-registration-options') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async generateRegistrationOptions() { - return this.webAuthService.generateRegistrationOptions(); - } - @Post('webauthn/generate-authentication-options') public async generateAuthenticationOptions( @Body() body: { deviceId: string } @@ -143,6 +145,12 @@ export class AuthController { return this.webAuthService.generateAuthenticationOptions(body.deviceId); } + @Get('webauthn/generate-registration-options') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async generateRegistrationOptions() { + return this.webAuthService.generateRegistrationOptions(); + } + @Post('webauthn/verify-attestation') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async verifyAttestation( diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 9c535f947..60a3a64d1 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -8,8 +8,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; -import { Module, Logger } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; +import type { StrategyOptions } from 'passport-openidconnect'; import { ApiKeyStrategy } from './api-key.strategy'; import { AuthController } from './auth.controller'; @@ -45,31 +46,13 @@ import { OidcStrategy } from './oidc.strategy'; configurationService: ConfigurationService ) => { const issuer = configurationService.get('OIDC_ISSUER'); - const scopeString = configurationService.get('OIDC_SCOPE'); - const scope = scopeString - .split(' ') - .map((s) => s.trim()) - .filter((s) => s.length > 0); + const scope = configurationService.get('OIDC_SCOPE'); const callbackUrl = configurationService.get('OIDC_CALLBACK_URL') || `${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; - const options: { - authorizationURL?: string; - callbackURL: string; - clientID: string; - clientSecret: string; - issuer?: string; - scope: string[]; - tokenURL?: string; - userInfoURL?: string; - } = { - callbackURL: callbackUrl, - clientID: configurationService.get('OIDC_CLIENT_ID'), - clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), - scope - }; + let options: StrategyOptions; if (issuer) { try { @@ -82,36 +65,36 @@ import { OidcStrategy } from './oidc.strategy'; userinfo_endpoint: string; }; - options.authorizationURL = config.authorization_endpoint; - options.issuer = issuer; - options.tokenURL = config.token_endpoint; - options.userInfoURL = config.userinfo_endpoint; + options = { + authorizationURL: config.authorization_endpoint, + callbackURL: callbackUrl, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), + issuer, + scope, + tokenURL: config.token_endpoint, + userInfoURL: config.userinfo_endpoint + }; } catch (error) { Logger.error(error, 'OidcStrategy'); throw new Error('Failed to fetch OIDC configuration from issuer'); } } else { - options.authorizationURL = configurationService.get( - 'OIDC_AUTHORIZATION_URL' - ); - options.issuer = configurationService.get('OIDC_ISSUER'); - options.tokenURL = configurationService.get('OIDC_TOKEN_URL'); - options.userInfoURL = configurationService.get('OIDC_USER_INFO_URL'); + options = { + authorizationURL: configurationService.get( + 'OIDC_AUTHORIZATION_URL' + ), + callbackURL: callbackUrl, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), + issuer: configurationService.get('OIDC_ISSUER'), + scope, + tokenURL: configurationService.get('OIDC_TOKEN_URL'), + userInfoURL: configurationService.get('OIDC_USER_INFO_URL') + }; } - return new OidcStrategy( - authService, - options as { - authorizationURL: string; - callbackURL: string; - clientID: string; - clientSecret: string; - issuer: string; - scope: string[]; - tokenURL: string; - userInfoURL: string; - } - ); + return new OidcStrategy(authService, options); }, inject: [AuthService, ConfigurationService] }, diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 2ac1e0473..8366c58bc 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -2,22 +2,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; import { Request } from 'express'; -import { Strategy } from 'passport-openidconnect'; +import { Strategy, type StrategyOptions } from 'passport-openidconnect'; import { AuthService } from './auth.service'; import { OidcStateStore } from './oidc-state.store'; -interface OidcStrategyOptions { - authorizationURL: string; - callbackURL: string; - clientID: string; - clientSecret: string; - issuer: string; - scope?: string[]; - tokenURL: string; - userInfoURL: string; -} - interface OidcProfile { id?: string; sub?: string; @@ -43,7 +32,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { public constructor( private readonly authService: AuthService, - options: OidcStrategyOptions + options: StrategyOptions ) { super({ ...options, diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 75b7ed4d1..59de60354 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -63,7 +63,7 @@ export class ConfigurationService { OIDC_CLIENT_ID: str({ default: '' }), OIDC_CLIENT_SECRET: str({ default: '' }), OIDC_ISSUER: str({ default: '' }), - OIDC_SCOPE: str({ default: 'profile' }), + OIDC_SCOPE: json({ default: ['openid'] }), OIDC_TOKEN_URL: str({ default: '' }), OIDC_USER_INFO_URL: str({ default: '' }), PORT: port({ default: DEFAULT_PORT }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index c37442688..3c03744f1 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -38,7 +38,7 @@ export interface Environment extends CleanedEnvAccessors { OIDC_CLIENT_ID: string; OIDC_CLIENT_SECRET: string; OIDC_ISSUER: string; - OIDC_SCOPE: string; + OIDC_SCOPE: string[]; OIDC_TOKEN_URL: string; OIDC_USER_INFO_URL: string; PORT: number; diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 0623a0f36..6570ab3d6 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -50,7 +50,7 @@
Sign in with OIDC From ba21ad07e0cf1e7c8dfe3258fb7387206bb34335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Fri, 28 Nov 2025 23:28:51 +0100 Subject: [PATCH 30/36] Refactor OIDC authentication flow and update dependencies --- apps/api/src/app/auth/auth.controller.ts | 2 - apps/api/src/app/auth/auth.module.ts | 4 +- apps/api/src/app/auth/oidc-state.store.ts | 42 ++++++++++++------- apps/api/src/app/auth/oidc.strategy.ts | 10 ++--- .../configuration/configuration.service.ts | 14 +++---- .../login-with-access-token-dialog.html | 4 +- package-lock.json | 4 +- package.json | 4 +- 8 files changed, 46 insertions(+), 38 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index cd51e2954..29af3a3c2 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -84,7 +84,6 @@ export class AuthController { @Req() request: Request, @Res() response: Response ) { - // Handles the Google OAuth2 callback const jwt: string = (request.user as any).jwt; if (jwt) { @@ -120,7 +119,6 @@ export class AuthController { @UseGuards(AuthGuard('oidc')) @Version(VERSION_NEUTRAL) public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { - // Handles the OIDC callback const jwt: string = (request.user as any).jwt; if (jwt) { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 60a3a64d1..c31e66299 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -40,6 +40,7 @@ import { OidcStrategy } from './oidc.strategy'; GoogleStrategy, JwtStrategy, { + inject: [AuthService, ConfigurationService], provide: OidcStrategy, useFactory: async ( authService: AuthService, @@ -95,8 +96,7 @@ import { OidcStrategy } from './oidc.strategy'; } return new OidcStrategy(authService, options); - }, - inject: [AuthService, ConfigurationService] + } }, WebAuthService ] diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index d1b578e2b..437846cf1 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -1,3 +1,5 @@ +import ms from 'ms'; + /** * Custom state store for OIDC authentication that doesn't rely on express-session. * This store manages OAuth2 state parameters in memory with automatic cleanup. @@ -7,15 +9,17 @@ export class OidcStateStore { string, { appState?: unknown; - ctx: { maxAge?: number; nonce?: string; issued?: Date }; + ctx: { issued?: Date; maxAge?: number; nonce?: string }; meta?: unknown; timestamp: number; } >(); - private readonly STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes + private readonly STATE_EXPIRY_MS = ms('10 minutes'); - // Store request state. - // Signature matches passport-openidconnect SessionStore + /** + * Store request state. + * Signature matches passport-openidconnect SessionStore + */ public store( _req: unknown, _meta: unknown, @@ -43,8 +47,10 @@ export class OidcStateStore { } } - // Verify request state. - // Signature matches passport-openidconnect SessionStore + /** + * Verify request state. + * Signature matches passport-openidconnect SessionStore + */ public verify( _req: unknown, handle: string, @@ -76,16 +82,9 @@ export class OidcStateStore { } } - // 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 + /** + * Clean up expired states + */ private cleanup(): void { const now = Date.now(); const expiredKeys: string[] = []; @@ -100,4 +99,15 @@ export class OidcStateStore { this.stateMap.delete(key); } } + + /** + * 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) + ); + } } diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 8366c58bc..58fd7bd87 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -59,6 +59,11 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { params?.sub ?? context?.claims?.sub; + const jwt = await this.authService.validateOAuthLogin({ + provider: Provider.OIDC, + thirdPartyId + }); + if (!thirdPartyId) { Logger.error( `Missing subject identifier in OIDC response from ${issuer}`, @@ -67,11 +72,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { throw new Error('Missing subject identifier in OIDC response'); } - const jwt = await this.authService.validateOAuthLogin({ - provider: Provider.OIDC, - thirdPartyId - }); - return { jwt }; } catch (error) { Logger.error(error, 'OidcStrategy'); diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 59de60354..6f139b305 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -58,14 +58,14 @@ export class ConfigurationService { JWT_SECRET_KEY: str({}), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), - OIDC_AUTHORIZATION_URL: str({ default: '' }), - OIDC_CALLBACK_URL: str({ default: '' }), - OIDC_CLIENT_ID: str({ default: '' }), - OIDC_CLIENT_SECRET: str({ default: '' }), - OIDC_ISSUER: str({ default: '' }), + OIDC_AUTHORIZATION_URL: str({ default: undefined }), + OIDC_CALLBACK_URL: str({ default: undefined }), + OIDC_CLIENT_ID: str({ default: undefined }), + OIDC_CLIENT_SECRET: str({ default: undefined }), + OIDC_ISSUER: str({ default: undefined }), OIDC_SCOPE: json({ default: ['openid'] }), - OIDC_TOKEN_URL: str({ default: '' }), - OIDC_USER_INFO_URL: str({ default: '' }), + OIDC_TOKEN_URL: str({ default: undefined }), + OIDC_USER_INFO_URL: str({ default: undefined }), PORT: port({ default: DEFAULT_PORT }), PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 6570ab3d6..d345c4df5 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -41,7 +41,7 @@ class="mr-2" src="../assets/icons/google.svg" style="height: 1rem" - />Sign in with GoogleSign in with Google
} @@ -52,7 +52,7 @@ class="px-4 rounded-pill" href="../api/auth/oidc" mat-stroked-button - >Sign in with OIDCSign in with OIDC } diff --git a/package-lock.json b/package-lock.json index c6f2e4155..3f6513901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,7 +83,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", - "passport-openidconnect": "^0.1.2", + "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -130,7 +130,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", - "@types/passport-openidconnect": "^0.1.3", + "@types/passport-openidconnect": "0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "eslint": "9.35.0", diff --git a/package.json b/package.json index 9fecefc05..221aa7b31 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", - "passport-openidconnect": "^0.1.2", + "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", "stripe": "18.5.0", @@ -174,7 +174,7 @@ "@types/node": "22.15.17", "@types/papaparse": "5.3.7", "@types/passport-google-oauth20": "2.0.16", - "@types/passport-openidconnect": "^0.1.3", + "@types/passport-openidconnect": "0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/parser": "8.43.0", "eslint": "9.35.0", From 3d89947589d3738b530d38c1be342a0b852d5b3b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:11:41 +0100 Subject: [PATCH 31/36] Refactoring --- apps/api/src/app/auth/auth.controller.ts | 2 -- apps/api/src/app/auth/auth.module.ts | 7 +++--- .../api/src/app/auth/interfaces/interfaces.ts | 19 ++++++++++++++ apps/api/src/app/auth/oidc-state.store.ts | 11 ++++---- apps/api/src/app/auth/oidc.strategy.ts | 25 +++++-------------- .../configuration/configuration.service.ts | 16 ++++++------ .../login-with-access-token-dialog.html | 2 +- .../migration.sql | 0 8 files changed, 44 insertions(+), 38 deletions(-) rename prisma/migrations/{20251103162035_add_oidc_provider => 20251103162035_added_oidc_to_provider}/migration.sql (100%) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 29af3a3c2..388f1dbd3 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -111,8 +111,6 @@ export class AuthController { StatusCodes.FORBIDDEN ); } - - // Initiates the OIDC login flow } @Get('oidc/callback') diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index c31e66299..4404205ce 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -60,6 +60,7 @@ import { OidcStrategy } from './oidc.strategy'; const response = await fetch( `${issuer}/.well-known/openid-configuration` ); + const config = (await response.json()) as { authorization_endpoint: string; token_endpoint: string; @@ -67,12 +68,12 @@ import { OidcStrategy } from './oidc.strategy'; }; options = { + issuer, + scope, authorizationURL: config.authorization_endpoint, callbackURL: callbackUrl, clientID: configurationService.get('OIDC_CLIENT_ID'), clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), - issuer, - scope, tokenURL: config.token_endpoint, userInfoURL: config.userinfo_endpoint }; @@ -82,6 +83,7 @@ import { OidcStrategy } from './oidc.strategy'; } } else { options = { + scope, authorizationURL: configurationService.get( 'OIDC_AUTHORIZATION_URL' ), @@ -89,7 +91,6 @@ import { OidcStrategy } from './oidc.strategy'; clientID: configurationService.get('OIDC_CLIENT_ID'), clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), issuer: configurationService.get('OIDC_ISSUER'), - scope, tokenURL: configurationService.get('OIDC_TOKEN_URL'), userInfoURL: configurationService.get('OIDC_USER_INFO_URL') }; diff --git a/apps/api/src/app/auth/interfaces/interfaces.ts b/apps/api/src/app/auth/interfaces/interfaces.ts index 4fdcc25b5..7ddfe41d2 100644 --- a/apps/api/src/app/auth/interfaces/interfaces.ts +++ b/apps/api/src/app/auth/interfaces/interfaces.ts @@ -6,6 +6,25 @@ export interface AuthDeviceDialogParams { authDevice: AuthDeviceDto; } +export interface OidcContext { + claims?: { + sub?: string; + }; +} + +export interface OidcIdToken { + sub?: string; +} + +export interface OidcParams { + sub?: string; +} + +export interface OidcProfile { + id?: string; + sub?: string; +} + export interface ValidateOAuthLoginParams { provider: Provider; thirdPartyId: string; diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 437846cf1..0d9bb5f0f 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -5,6 +5,8 @@ import ms from 'ms'; * This store manages OAuth2 state parameters in memory with automatic cleanup. */ export class OidcStateStore { + private readonly STATE_EXPIRY_MS = ms('10 minutes'); + private stateMap = new Map< string, { @@ -14,7 +16,6 @@ export class OidcStateStore { timestamp: number; } >(); - private readonly STATE_EXPIRY_MS = ms('10 minutes'); /** * Store request state. @@ -26,7 +27,7 @@ export class OidcStateStore { appState: unknown, ctx: { maxAge?: number; nonce?: string; issued?: Date }, callback: (err: Error | null, handle?: string) => void - ): void { + ) { try { // Generate a unique handle for this state const handle = this.generateHandle(); @@ -59,7 +60,7 @@ export class OidcStateStore { appState?: unknown, ctx?: { maxAge?: number; nonce?: string; issued?: Date } ) => void - ): void { + ) { try { const data = this.stateMap.get(handle); @@ -85,7 +86,7 @@ export class OidcStateStore { /** * Clean up expired states */ - private cleanup(): void { + private cleanup() { const now = Date.now(); const expiredKeys: string[] = []; @@ -103,7 +104,7 @@ export class OidcStateStore { /** * Generate a cryptographically secure random handle */ - private generateHandle(): string { + private generateHandle() { return ( Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 58fd7bd87..6ed03b5a8 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -5,27 +5,14 @@ import { Request } from 'express'; import { Strategy, type StrategyOptions } from 'passport-openidconnect'; import { AuthService } from './auth.service'; +import { + OidcContext, + OidcIdToken, + OidcParams, + OidcProfile +} from './interfaces/interfaces'; import { OidcStateStore } from './oidc-state.store'; -interface OidcProfile { - id?: string; - sub?: string; -} - -interface OidcContext { - claims?: { - sub?: string; - }; -} - -interface OidcIdToken { - sub?: string; -} - -interface OidcParams { - sub?: string; -} - @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { private static readonly stateStore = new OidcStateStore(); diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 6f139b305..2a0546961 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -55,17 +55,17 @@ export class ConfigurationService { GOOGLE_SHEETS_ID: str({ default: '' }), GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), HOST: host({ default: DEFAULT_HOST }), - JWT_SECRET_KEY: str({}), + JWT_SECRET_KEY: str(), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), - OIDC_AUTHORIZATION_URL: str({ default: undefined }), - OIDC_CALLBACK_URL: str({ default: undefined }), - OIDC_CLIENT_ID: str({ default: undefined }), - OIDC_CLIENT_SECRET: str({ default: undefined }), - OIDC_ISSUER: str({ default: undefined }), + OIDC_AUTHORIZATION_URL: str(), + OIDC_CALLBACK_URL: str(), + OIDC_CLIENT_ID: str(), + OIDC_CLIENT_SECRET: str(), + OIDC_ISSUER: str(), OIDC_SCOPE: json({ default: ['openid'] }), - OIDC_TOKEN_URL: str({ default: undefined }), - OIDC_USER_INFO_URL: str({ default: undefined }), + OIDC_TOKEN_URL: str(), + OIDC_USER_INFO_URL: str(), PORT: port({ default: DEFAULT_PORT }), PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index d345c4df5..cf5611ef7 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -41,7 +41,7 @@ class="mr-2" src="../assets/icons/google.svg" style="height: 1rem" - />Sign in with GoogleSign in with Google } diff --git a/prisma/migrations/20251103162035_add_oidc_provider/migration.sql b/prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql similarity index 100% rename from prisma/migrations/20251103162035_add_oidc_provider/migration.sql rename to prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql From f8324c38029caa9a67f1a99e507e37ecd38c3496 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:16:20 +0100 Subject: [PATCH 32/36] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897cb577a..d9159499e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added OIDC (_OpenID Connect_) as a login auth provider +- Added _OpenID Connect_ (`OIDC`) as a new login provider (experimental) ### Changed From 542ca1c159ff5a0668e110079e4d48a1d6b4c658 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:33:14 +0100 Subject: [PATCH 33/36] Refactoring --- apps/api/src/app/auth/oidc.strategy.ts | 5 +++-- .../configuration/configuration.service.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 6ed03b5a8..96b284121 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -47,8 +47,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { context?.claims?.sub; const jwt = await this.authService.validateOAuthLogin({ - provider: Provider.OIDC, - thirdPartyId + thirdPartyId, + provider: Provider.OIDC }); if (!thirdPartyId) { @@ -56,6 +56,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { `Missing subject identifier in OIDC response from ${issuer}`, 'OidcStrategy' ); + throw new Error('Missing subject identifier in OIDC response'); } diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 2a0546961..f31a8ca37 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -58,14 +58,14 @@ export class ConfigurationService { JWT_SECRET_KEY: str(), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), - OIDC_AUTHORIZATION_URL: str(), - OIDC_CALLBACK_URL: str(), - OIDC_CLIENT_ID: str(), - OIDC_CLIENT_SECRET: str(), - OIDC_ISSUER: str(), + OIDC_AUTHORIZATION_URL: str({ default: '' }), + OIDC_CALLBACK_URL: str({ default: '' }), + OIDC_CLIENT_ID: str({ default: '' }), + OIDC_CLIENT_SECRET: str({ default: '' }), + OIDC_ISSUER: str({ default: '' }), OIDC_SCOPE: json({ default: ['openid'] }), - OIDC_TOKEN_URL: str(), - OIDC_USER_INFO_URL: str(), + OIDC_TOKEN_URL: str({ default: '' }), + OIDC_USER_INFO_URL: str({ default: '' }), PORT: port({ default: DEFAULT_PORT }), PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY From 7d63e987b2f88bb25f70d51b7400a8af2481b5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Mon, 1 Dec 2025 00:27:52 +0100 Subject: [PATCH 34/36] Implement OIDC configuration validation --- apps/api/src/app/auth/auth.module.ts | 93 +++++++++++++++++++ .../login-with-access-token-dialog.html | 11 --- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 4404205ce..996cf397f 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -19,6 +19,89 @@ import { GoogleStrategy } from './google.strategy'; import { JwtStrategy } from './jwt.strategy'; import { OidcStrategy } from './oidc.strategy'; +// ANSI color codes +const colors = { + blue: '\x1b[34m', + reset: '\x1b[0m', + white: '\x1b[37m', + yellow: '\x1b[33m' +}; + +function validateOidcConfiguration( + configurationService: ConfigurationService +): void { + const missingVariables: string[] = []; + + // Common required variables for both configurations + const clientId = configurationService.get('OIDC_CLIENT_ID'); + const clientSecret = configurationService.get('OIDC_CLIENT_SECRET'); + const rootUrl = configurationService.get('ROOT_URL'); + + if (!clientId) { + missingVariables.push('OIDC_CLIENT_ID'); + } + + if (!clientSecret) { + missingVariables.push('OIDC_CLIENT_SECRET'); + } + + if (!rootUrl) { + missingVariables.push('ROOT_URL'); + } + + // Check for automatic or manual configuration + const authorizationUrl = configurationService.get('OIDC_AUTHORIZATION_URL'); + const issuer = configurationService.get('OIDC_ISSUER'); + const tokenUrl = configurationService.get('OIDC_TOKEN_URL'); + const userInfoUrl = configurationService.get('OIDC_USER_INFO_URL'); + + const hasAutomaticConfig = !!issuer; + const hasManualConfig = authorizationUrl || tokenUrl || userInfoUrl; + + if (!hasAutomaticConfig && !hasManualConfig) { + missingVariables.push( + 'OIDC_ISSUER (for automatic configuration) or OIDC_AUTHORIZATION_URL, OIDC_TOKEN_URL, OIDC_USER_INFO_URL (for manual configuration)' + ); + } else if (!hasAutomaticConfig && hasManualConfig) { + // Manual configuration: all three URLs are required + if (!authorizationUrl) { + missingVariables.push('OIDC_AUTHORIZATION_URL'); + } + + if (!tokenUrl) { + missingVariables.push('OIDC_TOKEN_URL'); + } + + if (!userInfoUrl) { + missingVariables.push('OIDC_USER_INFO_URL'); + } + } + + if (missingVariables.length > 0) { + const formattedVariables = missingVariables + .map( + (variable) => + ` ${colors.blue}${variable}:${colors.white} undefined${colors.reset}` + ) + .join('\n'); + + const errorMessage = ` +================================ + ${colors.yellow}Missing${colors.white} OIDC environment variables:${colors.reset} +${formattedVariables} + + ${colors.white}Configuration options:${colors.reset} + 1. Automatic: Set ${colors.blue}OIDC_ISSUER${colors.reset} (endpoints discovered automatically) + 2. Manual: Set ${colors.blue}OIDC_AUTHORIZATION_URL${colors.reset}, ${colors.blue}OIDC_TOKEN_URL${colors.reset}, ${colors.blue}OIDC_USER_INFO_URL${colors.reset} + + Both options require: ${colors.blue}ROOT_URL${colors.reset}, ${colors.blue}OIDC_CLIENT_ID${colors.reset}, ${colors.blue}OIDC_CLIENT_SECRET${colors.reset} +================================ +`; + Logger.error(errorMessage, 'OidcStrategy'); + process.exit(1); + } +} + @Module({ controllers: [AuthController], imports: [ @@ -46,6 +129,16 @@ import { OidcStrategy } from './oidc.strategy'; authService: AuthService, configurationService: ConfigurationService ) => { + const isOidcEnabled = configurationService.get( + 'ENABLE_FEATURE_AUTH_OIDC' + ); + + if (!isOidcEnabled) { + return null; + } + + validateOidcConfiguration(configurationService); + const issuer = configurationService.get('OIDC_ISSUER'); const scope = configurationService.get('OIDC_SCOPE'); diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 055699d7f..d345c4df5 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -56,17 +56,6 @@ > } - - @if (data.hasPermissionToUseAuthOidc) { - - } From 48600349ddea050d6b61ca4195212fe503865315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Mon, 1 Dec 2025 15:06:05 +0100 Subject: [PATCH 35/36] Refactor OIDC provider migration to be idempotent --- .../20251103162035_add_oidc_provider/migration.sql | 9 +++++++-- .../20251103162035_added_oidc_to_provider/migration.sql | 2 -- 2 files changed, 7 insertions(+), 4 deletions(-) delete mode 100644 prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql diff --git a/prisma/migrations/20251103162035_add_oidc_provider/migration.sql b/prisma/migrations/20251103162035_add_oidc_provider/migration.sql index f71f6eded..220c54d9d 100644 --- a/prisma/migrations/20251103162035_add_oidc_provider/migration.sql +++ b/prisma/migrations/20251103162035_add_oidc_provider/migration.sql @@ -1,2 +1,7 @@ --- AlterEnum -ALTER TYPE "Provider" ADD VALUE 'OIDC'; +-- AlterEnum (idempotent - only add if not exists) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'OIDC' AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'Provider')) THEN + ALTER TYPE "Provider" ADD VALUE 'OIDC'; + END IF; +END $$; diff --git a/prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql b/prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql deleted file mode 100644 index f71f6eded..000000000 --- a/prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterEnum -ALTER TYPE "Provider" ADD VALUE 'OIDC'; From 48c800659b5b502ea01f180b44716c9404d55bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Fri, 5 Dec 2025 23:50:11 +0100 Subject: [PATCH 36/36] Fix: Use envalid to check OIDC mandatory variables --- apps/api/src/app/auth/auth.module.ts | 145 ++++-------------- .../configuration/configuration.service.ts | 15 +- .../interfaces/environment.interface.ts | 6 +- 3 files changed, 49 insertions(+), 117 deletions(-) diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 996cf397f..7e9e103ad 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -19,89 +19,6 @@ import { GoogleStrategy } from './google.strategy'; import { JwtStrategy } from './jwt.strategy'; import { OidcStrategy } from './oidc.strategy'; -// ANSI color codes -const colors = { - blue: '\x1b[34m', - reset: '\x1b[0m', - white: '\x1b[37m', - yellow: '\x1b[33m' -}; - -function validateOidcConfiguration( - configurationService: ConfigurationService -): void { - const missingVariables: string[] = []; - - // Common required variables for both configurations - const clientId = configurationService.get('OIDC_CLIENT_ID'); - const clientSecret = configurationService.get('OIDC_CLIENT_SECRET'); - const rootUrl = configurationService.get('ROOT_URL'); - - if (!clientId) { - missingVariables.push('OIDC_CLIENT_ID'); - } - - if (!clientSecret) { - missingVariables.push('OIDC_CLIENT_SECRET'); - } - - if (!rootUrl) { - missingVariables.push('ROOT_URL'); - } - - // Check for automatic or manual configuration - const authorizationUrl = configurationService.get('OIDC_AUTHORIZATION_URL'); - const issuer = configurationService.get('OIDC_ISSUER'); - const tokenUrl = configurationService.get('OIDC_TOKEN_URL'); - const userInfoUrl = configurationService.get('OIDC_USER_INFO_URL'); - - const hasAutomaticConfig = !!issuer; - const hasManualConfig = authorizationUrl || tokenUrl || userInfoUrl; - - if (!hasAutomaticConfig && !hasManualConfig) { - missingVariables.push( - 'OIDC_ISSUER (for automatic configuration) or OIDC_AUTHORIZATION_URL, OIDC_TOKEN_URL, OIDC_USER_INFO_URL (for manual configuration)' - ); - } else if (!hasAutomaticConfig && hasManualConfig) { - // Manual configuration: all three URLs are required - if (!authorizationUrl) { - missingVariables.push('OIDC_AUTHORIZATION_URL'); - } - - if (!tokenUrl) { - missingVariables.push('OIDC_TOKEN_URL'); - } - - if (!userInfoUrl) { - missingVariables.push('OIDC_USER_INFO_URL'); - } - } - - if (missingVariables.length > 0) { - const formattedVariables = missingVariables - .map( - (variable) => - ` ${colors.blue}${variable}:${colors.white} undefined${colors.reset}` - ) - .join('\n'); - - const errorMessage = ` -================================ - ${colors.yellow}Missing${colors.white} OIDC environment variables:${colors.reset} -${formattedVariables} - - ${colors.white}Configuration options:${colors.reset} - 1. Automatic: Set ${colors.blue}OIDC_ISSUER${colors.reset} (endpoints discovered automatically) - 2. Manual: Set ${colors.blue}OIDC_AUTHORIZATION_URL${colors.reset}, ${colors.blue}OIDC_TOKEN_URL${colors.reset}, ${colors.blue}OIDC_USER_INFO_URL${colors.reset} - - Both options require: ${colors.blue}ROOT_URL${colors.reset}, ${colors.blue}OIDC_CLIENT_ID${colors.reset}, ${colors.blue}OIDC_CLIENT_SECRET${colors.reset} -================================ -`; - Logger.error(errorMessage, 'OidcStrategy'); - process.exit(1); - } -} - @Module({ controllers: [AuthController], imports: [ @@ -137,8 +54,6 @@ ${formattedVariables} return null; } - validateOidcConfiguration(configurationService); - const issuer = configurationService.get('OIDC_ISSUER'); const scope = configurationService.get('OIDC_SCOPE'); @@ -146,9 +61,24 @@ ${formattedVariables} configurationService.get('OIDC_CALLBACK_URL') || `${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; - let options: StrategyOptions; - - if (issuer) { + // Check for manual URL overrides + const manualAuthorizationUrl = configurationService.get( + 'OIDC_AUTHORIZATION_URL' + ); + const manualTokenUrl = configurationService.get('OIDC_TOKEN_URL'); + const manualUserInfoUrl = + configurationService.get('OIDC_USER_INFO_URL'); + + let authorizationURL: string; + let tokenURL: string; + let userInfoURL: string; + + // If all manual URLs are provided, use them; otherwise fetch from discovery + if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) { + authorizationURL = manualAuthorizationUrl; + tokenURL = manualTokenUrl; + userInfoURL = manualUserInfoUrl; + } else { try { const response = await fetch( `${issuer}/.well-known/openid-configuration` @@ -160,35 +90,28 @@ ${formattedVariables} userinfo_endpoint: string; }; - options = { - issuer, - scope, - authorizationURL: config.authorization_endpoint, - callbackURL: callbackUrl, - clientID: configurationService.get('OIDC_CLIENT_ID'), - clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), - tokenURL: config.token_endpoint, - userInfoURL: config.userinfo_endpoint - }; + // Manual URLs take priority over discovered ones + authorizationURL = + manualAuthorizationUrl || config.authorization_endpoint; + tokenURL = manualTokenUrl || config.token_endpoint; + userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; } catch (error) { Logger.error(error, 'OidcStrategy'); throw new Error('Failed to fetch OIDC configuration from issuer'); } - } else { - options = { - scope, - authorizationURL: configurationService.get( - 'OIDC_AUTHORIZATION_URL' - ), - callbackURL: callbackUrl, - clientID: configurationService.get('OIDC_CLIENT_ID'), - clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), - issuer: configurationService.get('OIDC_ISSUER'), - tokenURL: configurationService.get('OIDC_TOKEN_URL'), - userInfoURL: configurationService.get('OIDC_USER_INFO_URL') - }; } + const options: StrategyOptions = { + issuer, + scope, + authorizationURL, + callbackURL: callbackUrl, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET'), + tokenURL, + userInfoURL + }; + return new OidcStrategy(authService, options); } }, diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index f31a8ca37..00029af8f 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -60,9 +60,18 @@ export class ConfigurationService { MAX_CHART_ITEMS: num({ default: 365 }), OIDC_AUTHORIZATION_URL: str({ default: '' }), OIDC_CALLBACK_URL: str({ default: '' }), - OIDC_CLIENT_ID: str({ default: '' }), - OIDC_CLIENT_SECRET: str({ default: '' }), - OIDC_ISSUER: str({ default: '' }), + OIDC_CLIENT_ID: str({ + default: undefined, + requiredWhen: (env) => env.ENABLE_FEATURE_AUTH_OIDC === true + }), + OIDC_CLIENT_SECRET: str({ + default: undefined, + requiredWhen: (env) => env.ENABLE_FEATURE_AUTH_OIDC === true + }), + OIDC_ISSUER: str({ + default: undefined, + requiredWhen: (env) => env.ENABLE_FEATURE_AUTH_OIDC === true + }), OIDC_SCOPE: json({ default: ['openid'] }), OIDC_TOKEN_URL: str({ default: '' }), OIDC_USER_INFO_URL: str({ default: '' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 3c03744f1..733e62b61 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -35,9 +35,9 @@ export interface Environment extends CleanedEnvAccessors { MAX_CHART_ITEMS: number; OIDC_AUTHORIZATION_URL: string; OIDC_CALLBACK_URL: string; - OIDC_CLIENT_ID: string; - OIDC_CLIENT_SECRET: string; - OIDC_ISSUER: string; + OIDC_CLIENT_ID: string | undefined; + OIDC_CLIENT_SECRET: string | undefined; + OIDC_ISSUER: string | undefined; OIDC_SCOPE: string[]; OIDC_TOKEN_URL: string; OIDC_USER_INFO_URL: string;