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] 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 {