From 970566aca1ab357e6e364418d5315698aeb8bd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 11 Dec 2025 22:49:23 +0100 Subject: [PATCH 01/12] Feature/migrate token user to oidc --- apps/api/src/app/auth/auth.controller.ts | 90 +++++++++-- apps/api/src/app/auth/auth.module.ts | 5 +- apps/api/src/app/auth/auth.service.ts | 144 ++++++++++++++++-- apps/api/src/app/auth/oidc-state.store.ts | 138 ++++++++++++++++- apps/api/src/app/auth/oidc.strategy.ts | 64 ++++++-- apps/api/src/app/user/user.service.ts | 12 +- .../user-account-settings.component.ts | 92 ++++++++++- .../user-account-settings.html | 23 +++ .../src/lib/interfaces/user.interface.ts | 4 +- 9 files changed, 522 insertions(+), 50 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 388f1dbd3..7d99d5b26 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -13,8 +13,10 @@ import { Controller, Get, HttpException, + Logger, Param, Post, + Query, Req, Res, UseGuards, @@ -26,6 +28,7 @@ import { Request, Response } from 'express'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { AuthService } from './auth.service'; +import { OidcValidationResult } from './oidc.strategy'; @Controller('auth') export class AuthController { @@ -104,33 +107,94 @@ export class AuthController { @Get('oidc') @UseGuards(AuthGuard('oidc')) @Version(VERSION_NEUTRAL) - public oidcLogin() { + public oidcLogin(@Query('linkMode') linkMode: string) { if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN ); } + + // Link mode is handled automatically by OidcStateStore.store() + // which extracts the token from query params and validates it + if (linkMode === 'true') { + Logger.log( + 'OIDC link mode requested - token validation handled by OidcStateStore', + 'AuthController' + ); + } else { + Logger.debug('OIDC normal login flow initiated', 'AuthController'); + } + + // The AuthGuard('oidc') handles the redirect to the OIDC provider } @Get('oidc/callback') @UseGuards(AuthGuard('oidc')) @Version(VERSION_NEUTRAL) - public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { - const jwt: string = (request.user as any).jwt; + public async oidcLoginCallback( + @Req() request: Request, + @Res() response: Response + ) { + const result = request.user as OidcValidationResult; + const rootUrl = this.configurationService.get('ROOT_URL'); - if (jwt) { - response.redirect( - `${this.configurationService.get( - 'ROOT_URL' - )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` + // Check if this is a link mode callback + if (result.linkState?.linkMode) { + Logger.log( + `OIDC callback: Link mode detected for user ${result.linkState.userId.substring(0, 8)}...`, + 'AuthController' ); + + try { + // Link the OIDC account to the existing user + await this.authService.linkOidcToUser( + result.linkState.userId, + result.thirdPartyId + ); + + Logger.log( + `OIDC callback: Successfully linked OIDC to user ${result.linkState.userId.substring(0, 8)}...`, + 'AuthController' + ); + + // Redirect to account page with success message + response.redirect( + `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true` + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + Logger.error( + `OIDC callback: Link failed - ${errorMessage}`, + 'AuthController' + ); + + // Determine error type for frontend + let errorCode = 'unknown'; + if (errorMessage.includes('already linked')) { + errorCode = 'already-linked'; + } else if (errorMessage.includes('not found')) { + errorCode = 'invalid-session'; + } else if (errorMessage.includes('token authentication')) { + errorCode = 'invalid-provider'; + } + + response.redirect( + `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkError=${errorCode}` + ); + } + return; + } + + // Normal OIDC login flow + Logger.debug('OIDC callback: Normal login flow', 'AuthController'); + const jwt: string = result.jwt; + + if (jwt) { + response.redirect(`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`); } else { - response.redirect( - `${this.configurationService.get( - 'ROOT_URL' - )}/${DEFAULT_LANGUAGE_CODE}/auth` - ); + response.redirect(`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/auth`); } } diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 9fc5d0925..4d981fde1 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -113,7 +113,10 @@ import { OidcStrategy } from './oidc.strategy'; clientSecret: configurationService.get('OIDC_CLIENT_SECRET') }; - return new OidcStrategy(authService, options); + // Pass JWT secret for link mode validation + const jwtSecret = configurationService.get('JWT_SECRET_KEY'); + + return new OidcStrategy(authService, { ...options, jwtSecret }); } }, WebAuthService diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 6fe50dce0..8da94fcc6 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -2,7 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + InternalServerErrorException, + Logger +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ValidateOAuthLoginParams } from './interfaces/interfaces'; @@ -40,35 +45,144 @@ export class AuthService { thirdPartyId }: ValidateOAuthLoginParams): Promise { try { + Logger.debug( + `validateOAuthLogin: Validating login for provider ${provider}, thirdPartyId ${thirdPartyId?.substring(0, 8)}...`, + 'AuthService' + ); + + // First, search by thirdPartyId only to support linked accounts + // (users with provider ANONYMOUS but with thirdPartyId set) let [user] = await this.userService.users({ - where: { provider, thirdPartyId } + where: { thirdPartyId } }); - if (!user) { - const isUserSignupEnabled = - await this.propertyService.isUserSignupEnabled(); + if (user) { + Logger.log( + `validateOAuthLogin: Found existing user ${user.id.substring(0, 8)}... with provider ${user.provider} for thirdPartyId`, + 'AuthService' + ); + return this.jwtService.sign({ + id: user.id + }); + } - if (!isUserSignupEnabled) { - throw new Error('Sign up forbidden'); - } + Logger.debug( + `validateOAuthLogin: No user found with thirdPartyId, checking if signup is enabled`, + 'AuthService' + ); - // Create new user if not found - user = await this.userService.createUser({ - data: { - provider, - thirdPartyId - } - }); + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); + + if (!isUserSignupEnabled) { + Logger.warn( + `validateOAuthLogin: Sign up is disabled, rejecting new user`, + 'AuthService' + ); + throw new Error('Sign up forbidden'); } + // Create new user if not found + Logger.log( + `validateOAuthLogin: Creating new user with provider ${provider}`, + 'AuthService' + ); + user = await this.userService.createUser({ + data: { + provider, + thirdPartyId + } + }); + return this.jwtService.sign({ id: user.id }); } catch (error) { + Logger.error( + `validateOAuthLogin: Error - ${error instanceof Error ? error.message : 'Unknown error'}`, + 'AuthService' + ); throw new InternalServerErrorException( 'validateOAuthLogin', error instanceof Error ? error.message : 'Unknown error' ); } } + + /** + * Links an OIDC provider to an existing user account. + * The user must have provider ANONYMOUS (token-based auth). + * The thirdPartyId must not be already linked to another user. + * + * @param userId - The ID of the user to link + * @param thirdPartyId - The OIDC subject identifier + * @returns JWT token for the linked user + * @throws ConflictException if thirdPartyId is already linked to another user + * @throws Error if user not found or has invalid provider + */ + public async linkOidcToUser( + userId: string, + thirdPartyId: string + ): Promise { + Logger.log( + `linkOidcToUser: Starting link process for user ${userId.substring(0, 8)}... with thirdPartyId ${thirdPartyId.substring(0, 8)}...`, + 'AuthService' + ); + + // Check if thirdPartyId is already linked to another user + const [existingUser] = await this.userService.users({ + where: { thirdPartyId } + }); + + if (existingUser) { + if (existingUser.id === userId) { + Logger.warn( + `linkOidcToUser: User ${userId.substring(0, 8)}... is already linked to this thirdPartyId`, + 'AuthService' + ); + // Already linked to the same user, just return token + return this.jwtService.sign({ id: userId }); + } + + Logger.warn( + `linkOidcToUser: thirdPartyId already linked to another user ${existingUser.id.substring(0, 8)}...`, + 'AuthService' + ); + throw new ConflictException( + 'This OIDC account is already linked to another user' + ); + } + + // Get the current user + const user = await this.userService.user({ id: userId }); + + if (!user) { + Logger.error( + `linkOidcToUser: User ${userId.substring(0, 8)}... not found`, + 'AuthService' + ); + throw new Error('User not found'); + } + + if (user.provider !== 'ANONYMOUS') { + Logger.error( + `linkOidcToUser: User ${userId.substring(0, 8)}... has provider ${user.provider}, expected ANONYMOUS`, + 'AuthService' + ); + throw new Error('Only users with token authentication can link OIDC'); + } + + // Update user with thirdPartyId (keeping provider as ANONYMOUS for dual auth) + await this.userService.updateUser({ + where: { id: userId }, + data: { thirdPartyId } + }); + + Logger.log( + `linkOidcToUser: Successfully linked OIDC to user ${userId.substring(0, 8)}...`, + 'AuthService' + ); + + return this.jwtService.sign({ id: userId }); + } } diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 653451166..5493dc327 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -1,40 +1,119 @@ +import { Logger } from '@nestjs/common'; +import * as jwt from 'jsonwebtoken'; import ms from 'ms'; +export interface OidcLinkState { + linkMode: boolean; + userId: string; +} + /** * Custom state store for OIDC authentication that doesn't rely on express-session. * This store manages OAuth2 state parameters in memory with automatic cleanup. + * Supports link mode for linking existing token-authenticated users to OIDC. */ export class OidcStateStore { private readonly STATE_EXPIRY_MS = ms('10 minutes'); + private pendingLinkState?: OidcLinkState; + + private jwtSecret?: string; + private stateMap = new Map< string, { appState?: unknown; - ctx: { issued?: Date; maxAge?: number; nonce?: string }; + ctx: { issued?: string; maxAge?: number; nonce?: string }; + linkState?: OidcLinkState; meta?: unknown; timestamp: number; } >(); + /** + * Set the JWT secret for token validation in link mode + */ + public setJwtSecret(secret: string) { + this.jwtSecret = secret; + } + /** * Store request state. * Signature matches passport-openidconnect SessionStore + * Automatically extracts linkMode from request query params and validates JWT token */ public store( - _req: unknown, + req: unknown, _meta: unknown, appState: unknown, - ctx: { maxAge?: number; nonce?: string; issued?: Date }, + ctx: { maxAge?: number; nonce?: string; issued?: string }, callback: (err: Error | null, handle?: string) => void ) { try { // Generate a unique handle for this state const handle = this.generateHandle(); + // Check if there's a pending link state from the controller + // or extract from request query params + let linkState = this.getPendingLinkState(); + + // If no pending state, check request query params for linkMode + if (!linkState) { + const request = req as { + query?: { linkMode?: string; token?: string }; + headers?: { authorization?: string }; + }; + + if (request?.query?.linkMode === 'true') { + // Get token from query param or Authorization header + let token = request?.query?.token; + if ( + !token && + request?.headers?.authorization?.startsWith('Bearer ') + ) { + token = request.headers.authorization.substring(7); + } + + if (token && this.jwtSecret) { + try { + const decoded = jwt.verify(token, this.jwtSecret) as { + id: string; + }; + if (decoded?.id) { + linkState = { + linkMode: true, + userId: decoded.id + }; + Logger.log( + `Link mode validated for user ${decoded.id.substring(0, 8)}... from request`, + 'OidcStateStore' + ); + } + } catch (error) { + Logger.warn( + `Failed to validate JWT in link mode: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'OidcStateStore' + ); + } + } else { + Logger.warn( + 'Link mode requested but no valid token provided', + 'OidcStateStore' + ); + } + } + } + + const isLinkMode = linkState?.linkMode ?? false; + Logger.debug( + `Storing OIDC state with handle ${handle.substring(0, 8)}... (linkMode: ${isLinkMode})`, + 'OidcStateStore' + ); + this.stateMap.set(handle, { appState, ctx, + linkState, meta: _meta, timestamp: Date.now() }); @@ -44,6 +123,7 @@ export class OidcStateStore { callback(null, handle); } catch (error) { + Logger.error(`Error storing OIDC state: ${error}`, 'OidcStateStore'); callback(error as Error); } } @@ -51,25 +131,34 @@ export class OidcStateStore { /** * Verify request state. * Signature matches passport-openidconnect SessionStore + * Attaches linkState directly to request for retrieval in validate() */ public verify( - _req: unknown, + req: unknown, handle: string, callback: ( err: Error | null, - appState?: unknown, - ctx?: { maxAge?: number; nonce?: string; issued?: Date } + ctx?: { maxAge?: number; nonce?: string; issued?: string }, + state?: unknown ) => void ) { try { const data = this.stateMap.get(handle); if (!data) { + Logger.debug( + `OIDC state not found for handle ${handle.substring(0, 8)}...`, + 'OidcStateStore' + ); return callback(null, undefined, undefined); } if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { // State has expired + Logger.debug( + `OIDC state expired for handle ${handle.substring(0, 8)}...`, + 'OidcStateStore' + ); this.stateMap.delete(handle); return callback(null, undefined, undefined); } @@ -77,8 +166,24 @@ export class OidcStateStore { // Remove state after verification (one-time use) this.stateMap.delete(handle); + const isLinkMode = data.linkState?.linkMode ?? false; + Logger.debug( + `Verified OIDC state for handle ${handle.substring(0, 8)}... (linkMode: ${isLinkMode})`, + 'OidcStateStore' + ); + + // Attach linkState directly to request object for retrieval in validate() + if (data.linkState) { + (req as any).oidcLinkState = data.linkState; + Logger.log( + `Attached linkState to request for user ${data.linkState.userId.substring(0, 8)}...`, + 'OidcStateStore' + ); + } + callback(null, data.ctx, data.appState); } catch (error) { + Logger.error(`Error verifying OIDC state: ${error}`, 'OidcStateStore'); callback(error as Error); } } @@ -111,4 +216,25 @@ export class OidcStateStore { Date.now().toString(36) ); } + + /** + * Set link state for an existing or upcoming state entry. + * This allows the controller to attach user information before the OIDC flow starts. + */ + public setLinkStateForNextStore(linkState: OidcLinkState) { + this.pendingLinkState = linkState; + Logger.log( + `Link state prepared for user ${linkState.userId.substring(0, 8)}...`, + 'OidcStateStore' + ); + } + + /** + * Get and clear pending link state (used internally by store) + */ + public getPendingLinkState(): OidcLinkState | undefined { + const linkState = this.pendingLinkState; + this.pendingLinkState = undefined; + return linkState; + } } diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 96b284121..3c5f596d5 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -11,25 +11,45 @@ import { OidcParams, OidcProfile } from './interfaces/interfaces'; -import { OidcStateStore } from './oidc-state.store'; +import { OidcLinkState, OidcStateStore } from './oidc-state.store'; + +export interface OidcValidationResult { + jwt?: string; + linkState?: OidcLinkState; + thirdPartyId: string; +} + +export interface OidcStrategyOptions extends StrategyOptions { + jwtSecret?: string; +} @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { private static readonly stateStore = new OidcStateStore(); + public static getStateStore(): OidcStateStore { + return OidcStrategy.stateStore; + } + public constructor( private readonly authService: AuthService, - options: StrategyOptions + options: OidcStrategyOptions ) { super({ ...options, passReqToCallback: true, store: OidcStrategy.stateStore }); + + // Configure JWT secret for link mode validation + if (options.jwtSecret) { + OidcStrategy.stateStore.setJwtSecret(options.jwtSecret); + Logger.debug('JWT secret configured for OIDC link mode', 'OidcStrategy'); + } } public async validate( - _request: Request, + request: Request, issuer: string, profile: OidcProfile, context: OidcContext, @@ -46,11 +66,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { params?.sub ?? context?.claims?.sub; - const jwt = await this.authService.validateOAuthLogin({ - thirdPartyId, - provider: Provider.OIDC - }); - if (!thirdPartyId) { Logger.error( `Missing subject identifier in OIDC response from ${issuer}`, @@ -60,7 +75,38 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { throw new Error('Missing subject identifier in OIDC response'); } - return { jwt }; + // Check if this is a link mode request + // The linkState is attached to the request by OidcStateStore.verify() + const linkState = (request as any).oidcLinkState as + | OidcLinkState + | undefined; + + if (linkState?.linkMode) { + Logger.log( + `OidcStrategy: Link mode detected for user ${linkState.userId.substring(0, 8)}...`, + 'OidcStrategy' + ); + + // In link mode, we don't validate OAuth login (which would create a new user) + // Instead, we return the thirdPartyId for the controller to link + return { + linkState, + thirdPartyId + } as OidcValidationResult; + } + + // Normal OIDC login flow + Logger.debug( + `OidcStrategy: Normal login flow for thirdPartyId ${thirdPartyId.substring(0, 8)}...`, + 'OidcStrategy' + ); + + const jwt = await this.authService.validateOAuthLogin({ + thirdPartyId, + provider: Provider.OIDC + }); + + return { jwt, thirdPartyId } as OidcValidationResult; } catch (error) { Logger.error(error, 'OidcStrategy'); throw error; diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 65ce92cb2..b900343cc 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -97,7 +97,15 @@ export class UserService { } public async getUser( - { accounts, id, permissions, settings, subscription }: UserWithSettings, + { + accounts, + id, + permissions, + provider, + settings, + subscription, + thirdPartyId + }: UserWithSettings, aLocale = locale ): Promise { const userData = await Promise.all([ @@ -150,9 +158,11 @@ export class UserService { activitiesCount, id, permissions, + provider, subscription, systemMessage, tags, + thirdPartyId, access: access.map((accessItem) => { return { alias: accessItem.alias, diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index e17425676..8b01956d5 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -37,11 +37,11 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { RouterModule } from '@angular/router'; +import { ActivatedRoute, RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; import { format, parseISO } from 'date-fns'; import { addIcons } from 'ionicons'; -import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; +import { eyeOffOutline, eyeOutline, linkOutline } from 'ionicons/icons'; import ms from 'ms'; import { EMPTY, Subject, throwError } from 'rxjs'; import { catchError, takeUntil } from 'rxjs/operators'; @@ -68,10 +68,14 @@ import { catchError, takeUntil } from 'rxjs/operators'; export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { public appearancePlaceholder = $localize`Auto`; public baseCurrency: string; + public canLinkOidc: boolean = false; public currencies: string[] = []; public deleteOwnUserForm = this.formBuilder.group({ accessToken: ['', Validators.required] }); + public hasOidcLinked: boolean = false; + public hasPermissionForAuthOidc: boolean = false; + public hasPermissionForAuthToken: boolean = false; public hasPermissionToDeleteOwnUser: boolean; public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateUserSettings: boolean; @@ -100,6 +104,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { private unsubscribeSubject = new Subject(); public constructor( + private activatedRoute: ActivatedRoute, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private formBuilder: FormBuilder, @@ -110,17 +115,42 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { private userService: UserService, public webAuthnService: WebAuthnService ) { - const { baseCurrency, currencies } = this.dataService.fetchInfo(); + const { baseCurrency, currencies, globalPermissions } = + this.dataService.fetchInfo(); this.baseCurrency = baseCurrency; this.currencies = currencies; + // Check global permissions for auth methods + this.hasPermissionForAuthOidc = hasPermission( + globalPermissions, + permissions.enableAuthOidc + ); + this.hasPermissionForAuthToken = hasPermission( + globalPermissions, + permissions.enableAuthToken + ); + this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { this.user = state.user; + // Check if user can link OIDC + // Both OIDC and Token auth must be enabled to show linking feature + // Only show for users with token auth (provider ANONYMOUS) + this.hasOidcLinked = + this.hasPermissionForAuthOidc && + this.hasPermissionForAuthToken && + this.user.provider === 'ANONYMOUS' && + !!this.user.thirdPartyId; + this.canLinkOidc = + this.hasPermissionForAuthOidc && + this.hasPermissionForAuthToken && + this.user.provider === 'ANONYMOUS' && + !this.user.thirdPartyId; + this.hasPermissionToDeleteOwnUser = hasPermission( this.user.permissions, permissions.deleteOwnUser @@ -143,11 +173,42 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { } }); - addIcons({ eyeOffOutline, eyeOutline }); + addIcons({ eyeOffOutline, eyeOutline, linkOutline }); } public ngOnInit() { this.update(); + + // Handle query params for link results + this.activatedRoute.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if (params['linkSuccess'] === 'true') { + this.snackBar.open( + $localize`Your OIDC account has been successfully linked.`, + undefined, + { duration: ms('5 seconds') } + ); + // Refresh user data + this.userService.get(true).subscribe(); + } else if (params['linkError']) { + let errorMessage = $localize`Failed to link OIDC account.`; + switch (params['linkError']) { + case 'already-linked': + errorMessage = $localize`This OIDC account is already linked to another user.`; + break; + case 'invalid-session': + errorMessage = $localize`Your session is invalid. Please log in again.`; + break; + case 'invalid-provider': + errorMessage = $localize`Only token-authenticated users can link OIDC.`; + break; + } + this.snackBar.open(errorMessage, undefined, { + duration: ms('5 seconds') + }); + } + }); } public isCommunityLanguage() { @@ -178,6 +239,29 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { }); } + public onLinkOidc() { + this.notificationService.confirm({ + confirmFn: () => { + // Get current JWT token and navigate to OIDC with linkMode + const token = this.tokenStorageService.getToken(); + if (token) { + // Navigate to OIDC endpoint with linkMode and token + window.location.href = `../api/auth/oidc?linkMode=true&token=${encodeURIComponent(token)}`; + } else { + this.snackBar.open( + $localize`Unable to initiate linking. Please log in again.`, + undefined, + { duration: ms('3 seconds') } + ); + } + }, + confirmType: ConfirmationDialogType.Warn, + discardLabel: $localize`Cancel`, + title: $localize`Link OIDC Provider`, + message: $localize`This will link your current account to an OIDC provider. After linking, you will be able to sign in using both your Security Token and OIDC. This action cannot be undone. Do you want to continue?` + }); + } + public onCloseAccount() { this.notificationService.confirm({ confirmFn: () => { diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index e6ab544c8..4884ae4ae 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -261,6 +261,29 @@ + @if (canLinkOidc || hasOidcLinked) { +
+
+
OIDC Provider
+
+ Link your account to sign in with OpenID Connect +
+
+
+ @if (hasOidcLinked) { + + + Linked + + } @else { + + } +
+
+ } @if (hasPermissionToDeleteOwnUser) {
Date: Thu, 11 Dec 2025 22:50:58 +0100 Subject: [PATCH 02/12] Remove logging --- apps/api/src/app/auth/auth.controller.ts | 23 +-------------- apps/api/src/app/auth/auth.service.ts | 35 ----------------------- apps/api/src/app/auth/oidc-state.store.ts | 33 --------------------- apps/api/src/app/auth/oidc.strategy.ts | 10 ------- 4 files changed, 1 insertion(+), 100 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 7d99d5b26..2f42ab255 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -16,7 +16,6 @@ import { Logger, Param, Post, - Query, Req, Res, UseGuards, @@ -107,7 +106,7 @@ export class AuthController { @Get('oidc') @UseGuards(AuthGuard('oidc')) @Version(VERSION_NEUTRAL) - public oidcLogin(@Query('linkMode') linkMode: string) { + public oidcLogin() { if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -117,15 +116,6 @@ export class AuthController { // Link mode is handled automatically by OidcStateStore.store() // which extracts the token from query params and validates it - if (linkMode === 'true') { - Logger.log( - 'OIDC link mode requested - token validation handled by OidcStateStore', - 'AuthController' - ); - } else { - Logger.debug('OIDC normal login flow initiated', 'AuthController'); - } - // The AuthGuard('oidc') handles the redirect to the OIDC provider } @@ -141,11 +131,6 @@ export class AuthController { // Check if this is a link mode callback if (result.linkState?.linkMode) { - Logger.log( - `OIDC callback: Link mode detected for user ${result.linkState.userId.substring(0, 8)}...`, - 'AuthController' - ); - try { // Link the OIDC account to the existing user await this.authService.linkOidcToUser( @@ -153,11 +138,6 @@ export class AuthController { result.thirdPartyId ); - Logger.log( - `OIDC callback: Successfully linked OIDC to user ${result.linkState.userId.substring(0, 8)}...`, - 'AuthController' - ); - // Redirect to account page with success message response.redirect( `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true` @@ -188,7 +168,6 @@ export class AuthController { } // Normal OIDC login flow - Logger.debug('OIDC callback: Normal login flow', 'AuthController'); const jwt: string = result.jwt; if (jwt) { diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 8da94fcc6..4061f6299 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -45,11 +45,6 @@ export class AuthService { thirdPartyId }: ValidateOAuthLoginParams): Promise { try { - Logger.debug( - `validateOAuthLogin: Validating login for provider ${provider}, thirdPartyId ${thirdPartyId?.substring(0, 8)}...`, - 'AuthService' - ); - // First, search by thirdPartyId only to support linked accounts // (users with provider ANONYMOUS but with thirdPartyId set) let [user] = await this.userService.users({ @@ -57,36 +52,19 @@ export class AuthService { }); if (user) { - Logger.log( - `validateOAuthLogin: Found existing user ${user.id.substring(0, 8)}... with provider ${user.provider} for thirdPartyId`, - 'AuthService' - ); return this.jwtService.sign({ id: user.id }); } - Logger.debug( - `validateOAuthLogin: No user found with thirdPartyId, checking if signup is enabled`, - 'AuthService' - ); - const isUserSignupEnabled = await this.propertyService.isUserSignupEnabled(); if (!isUserSignupEnabled) { - Logger.warn( - `validateOAuthLogin: Sign up is disabled, rejecting new user`, - 'AuthService' - ); throw new Error('Sign up forbidden'); } // Create new user if not found - Logger.log( - `validateOAuthLogin: Creating new user with provider ${provider}`, - 'AuthService' - ); user = await this.userService.createUser({ data: { provider, @@ -157,18 +135,10 @@ export class AuthService { const user = await this.userService.user({ id: userId }); if (!user) { - Logger.error( - `linkOidcToUser: User ${userId.substring(0, 8)}... not found`, - 'AuthService' - ); throw new Error('User not found'); } if (user.provider !== 'ANONYMOUS') { - Logger.error( - `linkOidcToUser: User ${userId.substring(0, 8)}... has provider ${user.provider}, expected ANONYMOUS`, - 'AuthService' - ); throw new Error('Only users with token authentication can link OIDC'); } @@ -178,11 +148,6 @@ export class AuthService { data: { thirdPartyId } }); - Logger.log( - `linkOidcToUser: Successfully linked OIDC to user ${userId.substring(0, 8)}...`, - 'AuthService' - ); - return this.jwtService.sign({ id: userId }); } } diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 5493dc327..df9b961c5 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -84,10 +84,6 @@ export class OidcStateStore { linkMode: true, userId: decoded.id }; - Logger.log( - `Link mode validated for user ${decoded.id.substring(0, 8)}... from request`, - 'OidcStateStore' - ); } } catch (error) { Logger.warn( @@ -104,12 +100,6 @@ export class OidcStateStore { } } - const isLinkMode = linkState?.linkMode ?? false; - Logger.debug( - `Storing OIDC state with handle ${handle.substring(0, 8)}... (linkMode: ${isLinkMode})`, - 'OidcStateStore' - ); - this.stateMap.set(handle, { appState, ctx, @@ -146,19 +136,10 @@ export class OidcStateStore { const data = this.stateMap.get(handle); if (!data) { - Logger.debug( - `OIDC state not found for handle ${handle.substring(0, 8)}...`, - 'OidcStateStore' - ); return callback(null, undefined, undefined); } if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { - // State has expired - Logger.debug( - `OIDC state expired for handle ${handle.substring(0, 8)}...`, - 'OidcStateStore' - ); this.stateMap.delete(handle); return callback(null, undefined, undefined); } @@ -166,19 +147,9 @@ export class OidcStateStore { // Remove state after verification (one-time use) this.stateMap.delete(handle); - const isLinkMode = data.linkState?.linkMode ?? false; - Logger.debug( - `Verified OIDC state for handle ${handle.substring(0, 8)}... (linkMode: ${isLinkMode})`, - 'OidcStateStore' - ); - // Attach linkState directly to request object for retrieval in validate() if (data.linkState) { (req as any).oidcLinkState = data.linkState; - Logger.log( - `Attached linkState to request for user ${data.linkState.userId.substring(0, 8)}...`, - 'OidcStateStore' - ); } callback(null, data.ctx, data.appState); @@ -223,10 +194,6 @@ export class OidcStateStore { */ public setLinkStateForNextStore(linkState: OidcLinkState) { this.pendingLinkState = linkState; - Logger.log( - `Link state prepared for user ${linkState.userId.substring(0, 8)}...`, - 'OidcStateStore' - ); } /** diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 3c5f596d5..63fbdd492 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -44,7 +44,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { // Configure JWT secret for link mode validation if (options.jwtSecret) { OidcStrategy.stateStore.setJwtSecret(options.jwtSecret); - Logger.debug('JWT secret configured for OIDC link mode', 'OidcStrategy'); } } @@ -82,11 +81,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { | undefined; if (linkState?.linkMode) { - Logger.log( - `OidcStrategy: Link mode detected for user ${linkState.userId.substring(0, 8)}...`, - 'OidcStrategy' - ); - // In link mode, we don't validate OAuth login (which would create a new user) // Instead, we return the thirdPartyId for the controller to link return { @@ -96,10 +90,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { } // Normal OIDC login flow - Logger.debug( - `OidcStrategy: Normal login flow for thirdPartyId ${thirdPartyId.substring(0, 8)}...`, - 'OidcStrategy' - ); const jwt = await this.authService.validateOAuthLogin({ thirdPartyId, From 810531b7e0be014d79acb40b72e3b202c3d0e3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Fri, 12 Dec 2025 12:00:10 +0100 Subject: [PATCH 03/12] Remove logging from linkOidcToUser method in AuthService --- apps/api/src/app/auth/auth.service.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 4061f6299..3a7f338f0 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -102,11 +102,6 @@ export class AuthService { userId: string, thirdPartyId: string ): Promise { - Logger.log( - `linkOidcToUser: Starting link process for user ${userId.substring(0, 8)}... with thirdPartyId ${thirdPartyId.substring(0, 8)}...`, - 'AuthService' - ); - // Check if thirdPartyId is already linked to another user const [existingUser] = await this.userService.users({ where: { thirdPartyId } From e1a68e82f25c3c524118ed9a2851e4461b895360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Fri, 12 Dec 2025 17:18:27 +0100 Subject: [PATCH 04/12] Update user linking to OIDC by changing provider from ANONYMOUS to OIDC --- apps/api/src/app/auth/auth.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 3a7f338f0..21d7c4248 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -137,10 +137,10 @@ export class AuthService { throw new Error('Only users with token authentication can link OIDC'); } - // Update user with thirdPartyId (keeping provider as ANONYMOUS for dual auth) + // Update user with thirdPartyId and switch provider to OIDC await this.userService.updateUser({ where: { id: userId }, - data: { thirdPartyId } + data: { thirdPartyId, provider: 'OIDC' } }); return this.jwtService.sign({ id: userId }); From c1e9c321a81716199bfd690767c5339b19d64514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Tue, 16 Dec 2025 14:30:43 +0100 Subject: [PATCH 05/12] Feature: Improve UI for authentication provider display --- .../user-account-settings.component.ts | 25 +++++++++--- .../user-account-settings.html | 40 +++++++++---------- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index 8b01956d5..99a8312aa 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -68,14 +68,14 @@ import { catchError, takeUntil } from 'rxjs/operators'; export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { public appearancePlaceholder = $localize`Auto`; public baseCurrency: string; - public canLinkOidc: boolean = false; + public canLinkOidc = false; public currencies: string[] = []; public deleteOwnUserForm = this.formBuilder.group({ accessToken: ['', Validators.required] }); - public hasOidcLinked: boolean = false; - public hasPermissionForAuthOidc: boolean = false; - public hasPermissionForAuthToken: boolean = false; + public hasOidcLinked = false; + public hasPermissionForAuthOidc = false; + public hasPermissionForAuthToken = false; public hasPermissionToDeleteOwnUser: boolean; public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateUserSettings: boolean; @@ -211,6 +211,21 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { }); } + public getAuthProviderDisplayName(): string { + switch (this.user?.provider) { + case 'ANONYMOUS': + return 'Security Token'; + case 'GOOGLE': + return 'Google'; + case 'INTERNET_IDENTITY': + return 'Internet Identity'; + case 'OIDC': + return 'OpenID Connect (OIDC)'; + default: + return this.user?.provider || 'Unknown'; + } + } + public isCommunityLanguage() { return !['de', 'en'].includes(this.language); } @@ -436,7 +451,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { this.update(); resolve(); }, - error: (error) => { + error: (error: Error) => { reject(error); } }); diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 4884ae4ae..857547b2f 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -261,29 +261,25 @@ - @if (canLinkOidc || hasOidcLinked) { -
-
-
OIDC Provider
-
- Link your account to sign in with OpenID Connect -
-
-
- @if (hasOidcLinked) { - - - Linked - - } @else { - - } -
+
+
+
Authentication Provider
- } +
+
{{ getAuthProviderDisplayName() }}
+ @if (canLinkOidc) { + + } +
+
@if (hasPermissionToDeleteOwnUser) {
Date: Wed, 17 Dec 2025 23:15:36 +0100 Subject: [PATCH 06/12] Fix: Solve comments to pull request --- apps/api/src/app/auth/auth.controller.ts | 29 +++++----- apps/api/src/app/auth/auth.module.ts | 10 ++-- apps/api/src/app/auth/auth.service.ts | 34 +++++++----- .../api/src/app/auth/interfaces/interfaces.ts | 16 ++++++ apps/api/src/app/auth/oidc-state.store.ts | 53 ++++++++----------- apps/api/src/app/auth/oidc.strategy.ts | 32 +++-------- .../user-account-settings.component.ts | 5 +- 7 files changed, 87 insertions(+), 92 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 2f42ab255..cb0bcf2ed 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -10,10 +10,12 @@ import { import { Body, + ConflictException, Controller, Get, HttpException, Logger, + NotFoundException, Param, Post, Req, @@ -126,17 +128,18 @@ export class AuthController { @Req() request: Request, @Res() response: Response ) { - const result = request.user as OidcValidationResult; + const { linkState, thirdPartyId, jwt } = + request.user as OidcValidationResult; const rootUrl = this.configurationService.get('ROOT_URL'); // Check if this is a link mode callback - if (result.linkState?.linkMode) { + if (linkState?.linkMode) { try { // Link the OIDC account to the existing user - await this.authService.linkOidcToUser( - result.linkState.userId, - result.thirdPartyId - ); + await this.authService.linkOidcToUser({ + thirdPartyId, + userId: linkState.userId + }); // Redirect to account page with success message response.redirect( @@ -150,14 +153,14 @@ export class AuthController { 'AuthController' ); - // Determine error type for frontend + // Determine error type for frontend based on error type let errorCode = 'unknown'; - if (errorMessage.includes('already linked')) { - errorCode = 'already-linked'; - } else if (errorMessage.includes('not found')) { + if (error instanceof ConflictException) { + errorCode = error.message.includes('token authentication') + ? 'invalid-provider' + : 'already-linked'; + } else if (error instanceof NotFoundException) { errorCode = 'invalid-session'; - } else if (errorMessage.includes('token authentication')) { - errorCode = 'invalid-provider'; } response.redirect( @@ -168,8 +171,6 @@ export class AuthController { } // Normal OIDC login flow - const jwt: string = result.jwt; - if (jwt) { response.redirect(`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`); } else { diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 4d981fde1..0f24e38b0 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -17,6 +17,7 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './google.strategy'; import { JwtStrategy } from './jwt.strategy'; +import { OidcStateStore } from './oidc-state.store'; import { OidcStrategy } from './oidc.strategy'; @Module({ @@ -39,11 +40,13 @@ import { OidcStrategy } from './oidc.strategy'; AuthService, GoogleStrategy, JwtStrategy, + OidcStateStore, { - inject: [AuthService, ConfigurationService], + inject: [AuthService, OidcStateStore, ConfigurationService], provide: OidcStrategy, useFactory: async ( authService: AuthService, + stateStore: OidcStateStore, configurationService: ConfigurationService ) => { const isOidcEnabled = configurationService.get( @@ -113,10 +116,7 @@ import { OidcStrategy } from './oidc.strategy'; clientSecret: configurationService.get('OIDC_CLIENT_SECRET') }; - // Pass JWT secret for link mode validation - const jwtSecret = configurationService.get('JWT_SECRET_KEY'); - - return new OidcStrategy(authService, { ...options, jwtSecret }); + return new OidcStrategy(authService, stateStore, options); } }, WebAuthService diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 21d7c4248..aa6d2206d 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -4,13 +4,18 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { ConflictException, + ForbiddenException, Injectable, InternalServerErrorException, - Logger + Logger, + NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { ValidateOAuthLoginParams } from './interfaces/interfaces'; +import { + LinkOidcToUserParams, + ValidateOAuthLoginParams +} from './interfaces/interfaces'; @Injectable() export class AuthService { @@ -61,7 +66,7 @@ export class AuthService { await this.propertyService.isUserSignupEnabled(); if (!isUserSignupEnabled) { - throw new Error('Sign up forbidden'); + throw new ForbiddenException('Sign up forbidden'); } // Create new user if not found @@ -92,16 +97,17 @@ export class AuthService { * The user must have provider ANONYMOUS (token-based auth). * The thirdPartyId must not be already linked to another user. * - * @param userId - The ID of the user to link - * @param thirdPartyId - The OIDC subject identifier + * @param params - Parameters for linking OIDC to user + * @param params.userId - The ID of the user to link + * @param params.thirdPartyId - The OIDC subject identifier * @returns JWT token for the linked user * @throws ConflictException if thirdPartyId is already linked to another user * @throws Error if user not found or has invalid provider */ - public async linkOidcToUser( - userId: string, - thirdPartyId: string - ): Promise { + public async linkOidcToUser({ + thirdPartyId, + userId + }: LinkOidcToUserParams): Promise { // Check if thirdPartyId is already linked to another user const [existingUser] = await this.userService.users({ where: { thirdPartyId } @@ -130,17 +136,19 @@ export class AuthService { const user = await this.userService.user({ id: userId }); if (!user) { - throw new Error('User not found'); + throw new NotFoundException('User not found'); } if (user.provider !== 'ANONYMOUS') { - throw new Error('Only users with token authentication can link OIDC'); + throw new ConflictException( + 'Only users with token authentication can link OIDC' + ); } // Update user with thirdPartyId and switch provider to OIDC await this.userService.updateUser({ - where: { id: userId }, - data: { thirdPartyId, provider: 'OIDC' } + data: { thirdPartyId, provider: 'OIDC' }, + where: { id: userId } }); return this.jwtService.sign({ id: userId }); diff --git a/apps/api/src/app/auth/interfaces/interfaces.ts b/apps/api/src/app/auth/interfaces/interfaces.ts index 7ddfe41d2..3fd35e4e3 100644 --- a/apps/api/src/app/auth/interfaces/interfaces.ts +++ b/apps/api/src/app/auth/interfaces/interfaces.ts @@ -25,6 +25,22 @@ export interface OidcProfile { sub?: string; } +export interface LinkOidcToUserParams { + thirdPartyId: string; + userId: string; +} + +export interface OidcLinkState { + linkMode: boolean; + userId: string; +} + +export interface OidcValidationResult { + jwt?: string; + linkState?: OidcLinkState; + thirdPartyId: 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 df9b961c5..cf177e07e 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -1,24 +1,20 @@ -import { Logger } from '@nestjs/common'; -import * as jwt from 'jsonwebtoken'; +import { Injectable, Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import ms from 'ms'; -export interface OidcLinkState { - linkMode: boolean; - userId: string; -} +import { OidcLinkState } from './interfaces/interfaces'; /** * Custom state store for OIDC authentication that doesn't rely on express-session. * This store manages OAuth2 state parameters in memory with automatic cleanup. * Supports link mode for linking existing token-authenticated users to OIDC. */ +@Injectable() export class OidcStateStore { private readonly STATE_EXPIRY_MS = ms('10 minutes'); private pendingLinkState?: OidcLinkState; - private jwtSecret?: string; - private stateMap = new Map< string, { @@ -30,11 +26,23 @@ export class OidcStateStore { } >(); + public constructor(private readonly jwtService: JwtService) {} + /** - * Set the JWT secret for token validation in link mode + * Get and clear pending link state (used internally by store) */ - public setJwtSecret(secret: string) { - this.jwtSecret = secret; + public getPendingLinkState(): OidcLinkState | undefined { + const linkState = this.pendingLinkState; + this.pendingLinkState = undefined; + return linkState; + } + + /** + * Set link state for an existing or upcoming state entry. + * This allows the controller to attach user information before the OIDC flow starts. + */ + public setLinkStateForNextStore(linkState: OidcLinkState) { + this.pendingLinkState = linkState; } /** @@ -74,11 +82,9 @@ export class OidcStateStore { token = request.headers.authorization.substring(7); } - if (token && this.jwtSecret) { + if (token) { try { - const decoded = jwt.verify(token, this.jwtSecret) as { - id: string; - }; + const decoded = this.jwtService.verify<{ id: string }>(token); if (decoded?.id) { linkState = { linkMode: true, @@ -187,21 +193,4 @@ export class OidcStateStore { Date.now().toString(36) ); } - - /** - * Set link state for an existing or upcoming state entry. - * This allows the controller to attach user information before the OIDC flow starts. - */ - public setLinkStateForNextStore(linkState: OidcLinkState) { - this.pendingLinkState = linkState; - } - - /** - * Get and clear pending link state (used internally by store) - */ - public getPendingLinkState(): OidcLinkState | undefined { - const linkState = this.pendingLinkState; - this.pendingLinkState = undefined; - return linkState; - } } diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 63fbdd492..095455a30 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -8,43 +8,25 @@ import { AuthService } from './auth.service'; import { OidcContext, OidcIdToken, + OidcLinkState, OidcParams, - OidcProfile + OidcProfile, + OidcValidationResult } from './interfaces/interfaces'; -import { OidcLinkState, OidcStateStore } from './oidc-state.store'; - -export interface OidcValidationResult { - jwt?: string; - linkState?: OidcLinkState; - thirdPartyId: string; -} - -export interface OidcStrategyOptions extends StrategyOptions { - jwtSecret?: string; -} +import { OidcStateStore } from './oidc-state.store'; @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { - private static readonly stateStore = new OidcStateStore(); - - public static getStateStore(): OidcStateStore { - return OidcStrategy.stateStore; - } - public constructor( private readonly authService: AuthService, - options: OidcStrategyOptions + stateStore: OidcStateStore, + options: StrategyOptions ) { super({ ...options, passReqToCallback: true, - store: OidcStrategy.stateStore + store: stateStore }); - - // Configure JWT secret for link mode validation - if (options.jwtSecret) { - OidcStrategy.stateStore.setJwtSecret(options.jwtSecret); - } } public async validate( diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index 99a8312aa..15bd4e6f4 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -121,11 +121,11 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { this.baseCurrency = baseCurrency; this.currencies = currencies; - // Check global permissions for auth methods this.hasPermissionForAuthOidc = hasPermission( globalPermissions, permissions.enableAuthOidc ); + this.hasPermissionForAuthToken = hasPermission( globalPermissions, permissions.enableAuthToken @@ -145,6 +145,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { this.hasPermissionForAuthToken && this.user.provider === 'ANONYMOUS' && !!this.user.thirdPartyId; + this.canLinkOidc = this.hasPermissionForAuthOidc && this.hasPermissionForAuthToken && @@ -217,8 +218,6 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { return 'Security Token'; case 'GOOGLE': return 'Google'; - case 'INTERNET_IDENTITY': - return 'Internet Identity'; case 'OIDC': return 'OpenID Connect (OIDC)'; default: From 3f6a513a5491f06f5ca67acbb8b55dea93e76bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Wed, 17 Dec 2025 23:17:40 +0100 Subject: [PATCH 07/12] Fix: Update OidcValidationResult import path to use interfaces --- apps/api/src/app/auth/auth.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index cb0bcf2ed..31c5586e3 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -29,7 +29,7 @@ import { Request, Response } from 'express'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { AuthService } from './auth.service'; -import { OidcValidationResult } from './oidc.strategy'; +import { OidcValidationResult } from './interfaces/interfaces'; @Controller('auth') export class AuthController { From 94ecca45bf51c9ecc1914223f24ee6c92bb8050a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 18 Dec 2025 10:16:22 +0100 Subject: [PATCH 08/12] Refactor: OIDC linking process. Streamline state handling and update token management --- apps/api/src/app/auth/auth.controller.ts | 10 +-- apps/api/src/app/auth/auth.module.ts | 10 +-- .../api/src/app/auth/interfaces/interfaces.ts | 1 - apps/api/src/app/auth/oidc-state.store.ts | 85 ++----------------- apps/api/src/app/auth/oidc.strategy.ts | 42 ++++++--- .../user-account-settings.component.ts | 18 ++-- 6 files changed, 53 insertions(+), 113 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 31c5586e3..7af6863a3 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -115,10 +115,6 @@ export class AuthController { StatusCodes.FORBIDDEN ); } - - // Link mode is handled automatically by OidcStateStore.store() - // which extracts the token from query params and validates it - // The AuthGuard('oidc') handles the redirect to the OIDC provider } @Get('oidc/callback') @@ -132,16 +128,13 @@ export class AuthController { request.user as OidcValidationResult; const rootUrl = this.configurationService.get('ROOT_URL'); - // Check if this is a link mode callback - if (linkState?.linkMode) { + if (linkState) { try { - // Link the OIDC account to the existing user await this.authService.linkOidcToUser({ thirdPartyId, userId: linkState.userId }); - // Redirect to account page with success message response.redirect( `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true` ); @@ -153,7 +146,6 @@ export class AuthController { 'AuthController' ); - // Determine error type for frontend based on error type let errorCode = 'unknown'; if (error instanceof ConflictException) { errorCode = error.message.includes('token authentication') diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 0f24e38b0..40fcda4af 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -9,7 +9,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Logger, Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; +import { JwtModule, JwtService } from '@nestjs/jwt'; import type { StrategyOptions } from 'passport-openidconnect'; import { ApiKeyStrategy } from './api-key.strategy'; @@ -42,10 +42,11 @@ import { OidcStrategy } from './oidc.strategy'; JwtStrategy, OidcStateStore, { - inject: [AuthService, OidcStateStore, ConfigurationService], + inject: [AuthService, JwtService, OidcStateStore, ConfigurationService], provide: OidcStrategy, useFactory: async ( authService: AuthService, + jwtService: JwtService, stateStore: OidcStateStore, configurationService: ConfigurationService ) => { @@ -77,12 +78,10 @@ import { OidcStrategy } from './oidc.strategy'; let userInfoURL: string; if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) { - // Use manual URLs authorizationURL = manualAuthorizationUrl; tokenURL = manualTokenUrl; userInfoURL = manualUserInfoUrl; } else { - // Fetch OIDC configuration from discovery endpoint try { const response = await fetch( `${issuer}/.well-known/openid-configuration` @@ -94,7 +93,6 @@ import { OidcStrategy } from './oidc.strategy'; userinfo_endpoint: string; }; - // Manual URLs take priority over discovered ones authorizationURL = manualAuthorizationUrl || config.authorization_endpoint; tokenURL = manualTokenUrl || config.token_endpoint; @@ -116,7 +114,7 @@ import { OidcStrategy } from './oidc.strategy'; clientSecret: configurationService.get('OIDC_CLIENT_SECRET') }; - return new OidcStrategy(authService, stateStore, options); + return new OidcStrategy(authService, jwtService, stateStore, options); } }, WebAuthService diff --git a/apps/api/src/app/auth/interfaces/interfaces.ts b/apps/api/src/app/auth/interfaces/interfaces.ts index 3fd35e4e3..9fc0a36bf 100644 --- a/apps/api/src/app/auth/interfaces/interfaces.ts +++ b/apps/api/src/app/auth/interfaces/interfaces.ts @@ -31,7 +31,6 @@ export interface LinkOidcToUserParams { } export interface OidcLinkState { - linkMode: boolean; userId: string; } diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index cf177e07e..2a081a93e 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -1,54 +1,28 @@ import { Injectable, Logger } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; import ms from 'ms'; -import { OidcLinkState } from './interfaces/interfaces'; - /** * Custom state store for OIDC authentication that doesn't rely on express-session. * This store manages OAuth2 state parameters in memory with automatic cleanup. - * Supports link mode for linking existing token-authenticated users to OIDC. */ @Injectable() export class OidcStateStore { private readonly STATE_EXPIRY_MS = ms('10 minutes'); - private pendingLinkState?: OidcLinkState; - private stateMap = new Map< string, { appState?: unknown; ctx: { issued?: string; maxAge?: number; nonce?: string }; - linkState?: OidcLinkState; + linkToken?: string; meta?: unknown; timestamp: number; } >(); - public constructor(private readonly jwtService: JwtService) {} - - /** - * Get and clear pending link state (used internally by store) - */ - public getPendingLinkState(): OidcLinkState | undefined { - const linkState = this.pendingLinkState; - this.pendingLinkState = undefined; - return linkState; - } - - /** - * Set link state for an existing or upcoming state entry. - * This allows the controller to attach user information before the OIDC flow starts. - */ - public setLinkStateForNextStore(linkState: OidcLinkState) { - this.pendingLinkState = linkState; - } - /** * Store request state. * Signature matches passport-openidconnect SessionStore - * Automatically extracts linkMode from request query params and validates JWT token */ public store( req: unknown, @@ -58,63 +32,19 @@ export class OidcStateStore { callback: (err: Error | null, handle?: string) => void ) { try { - // Generate a unique handle for this state const handle = this.generateHandle(); - // Check if there's a pending link state from the controller - // or extract from request query params - let linkState = this.getPendingLinkState(); - - // If no pending state, check request query params for linkMode - if (!linkState) { - const request = req as { - query?: { linkMode?: string; token?: string }; - headers?: { authorization?: string }; - }; - - if (request?.query?.linkMode === 'true') { - // Get token from query param or Authorization header - let token = request?.query?.token; - if ( - !token && - request?.headers?.authorization?.startsWith('Bearer ') - ) { - token = request.headers.authorization.substring(7); - } - - if (token) { - try { - const decoded = this.jwtService.verify<{ id: string }>(token); - if (decoded?.id) { - linkState = { - linkMode: true, - userId: decoded.id - }; - } - } catch (error) { - Logger.warn( - `Failed to validate JWT in link mode: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'OidcStateStore' - ); - } - } else { - Logger.warn( - 'Link mode requested but no valid token provided', - 'OidcStateStore' - ); - } - } - } + const request = req as { query?: { linkToken?: string } }; + const linkToken = request?.query?.linkToken; this.stateMap.set(handle, { appState, ctx, - linkState, + linkToken, meta: _meta, timestamp: Date.now() }); - // Clean up expired states this.cleanup(); callback(null, handle); @@ -127,7 +57,6 @@ export class OidcStateStore { /** * Verify request state. * Signature matches passport-openidconnect SessionStore - * Attaches linkState directly to request for retrieval in validate() */ public verify( req: unknown, @@ -150,12 +79,10 @@ export class OidcStateStore { return callback(null, undefined, undefined); } - // Remove state after verification (one-time use) this.stateMap.delete(handle); - // Attach linkState directly to request object for retrieval in validate() - if (data.linkState) { - (req as any).oidcLinkState = data.linkState; + if (data.linkToken) { + (req as any).oidcLinkToken = data.linkToken; } callback(null, data.ctx, data.appState); diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 095455a30..7437ae1b2 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import { PassportStrategy } from '@nestjs/passport'; import { Provider } from '@prisma/client'; import { Request } from 'express'; @@ -8,7 +9,6 @@ import { AuthService } from './auth.service'; import { OidcContext, OidcIdToken, - OidcLinkState, OidcParams, OidcProfile, OidcValidationResult @@ -19,6 +19,7 @@ import { OidcStateStore } from './oidc-state.store'; export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { public constructor( private readonly authService: AuthService, + private readonly jwtService: JwtService, stateStore: OidcStateStore, options: StrategyOptions ) { @@ -56,23 +57,24 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { throw new Error('Missing subject identifier in OIDC response'); } - // Check if this is a link mode request - // The linkState is attached to the request by OidcStateStore.verify() - const linkState = (request as any).oidcLinkState as - | OidcLinkState - | undefined; + // Check if user is already authenticated via JWT + // If authenticated, this is a link operation; otherwise, normal login + // The linkToken is attached by OidcStateStore.verify() from the OAuth state + const linkToken = (request as any).oidcLinkToken as string | undefined; + const authenticatedUserId = this.extractAuthenticatedUserId(linkToken); - if (linkState?.linkMode) { - // In link mode, we don't validate OAuth login (which would create a new user) - // Instead, we return the thirdPartyId for the controller to link + if (authenticatedUserId) { + // User is authenticated → Link mode + // Return linkState for controller to handle linking return { - linkState, + linkState: { + userId: authenticatedUserId + }, thirdPartyId } as OidcValidationResult; } - // Normal OIDC login flow - + // No authenticated user → Normal OIDC login flow const jwt = await this.authService.validateOAuthLogin({ thirdPartyId, provider: Provider.OIDC @@ -84,4 +86,20 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { throw error; } } + + /** + * Extract authenticated user ID from linkToken passed via OAuth state + */ + private extractAuthenticatedUserId(linkToken?: string): string | null { + if (!linkToken) { + return null; + } + + try { + const decoded = this.jwtService.verify<{ id: string }>(linkToken); + return decoded?.id || null; + } catch { + return null; + } + } } diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index 15bd4e6f4..a3e675728 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -137,9 +137,6 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { if (state?.user) { this.user = state.user; - // Check if user can link OIDC - // Both OIDC and Token auth must be enabled to show linking feature - // Only show for users with token auth (provider ANONYMOUS) this.hasOidcLinked = this.hasPermissionForAuthOidc && this.hasPermissionForAuthToken && @@ -256,11 +253,20 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { public onLinkOidc() { this.notificationService.confirm({ confirmFn: () => { - // Get current JWT token and navigate to OIDC with linkMode const token = this.tokenStorageService.getToken(); if (token) { - // Navigate to OIDC endpoint with linkMode and token - window.location.href = `../api/auth/oidc?linkMode=true&token=${encodeURIComponent(token)}`; + const form = document.createElement('form'); + form.method = 'GET'; + form.action = '../api/auth/oidc'; + + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'linkToken'; + input.value = token; + form.appendChild(input); + + document.body.appendChild(form); + form.submit(); } else { this.snackBar.open( $localize`Unable to initiate linking. Please log in again.`, From a46658574bc32613cedc1f273f0996e402d05163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 18 Dec 2025 18:12:27 +0100 Subject: [PATCH 09/12] Refactor: Remove unused meta parameter from OidcStateStore --- apps/api/src/app/auth/oidc-state.store.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 2a081a93e..116d14284 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -15,7 +15,6 @@ export class OidcStateStore { appState?: unknown; ctx: { issued?: string; maxAge?: number; nonce?: string }; linkToken?: string; - meta?: unknown; timestamp: number; } >(); @@ -41,7 +40,6 @@ export class OidcStateStore { appState, ctx, linkToken, - meta: _meta, timestamp: Date.now() }); From cec911e9ff32a1e2629a3865688a53b21c41b365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Thu, 18 Dec 2025 18:41:10 +0100 Subject: [PATCH 10/12] Refactor: Improve OIDC linking button layout and structure --- .../user-account-settings.html | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 857547b2f..1c86a51f6 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -267,19 +267,19 @@
{{ getAuthProviderDisplayName() }}
- @if (canLinkOidc) { -
+ + @if (canLinkOidc) { +
+
+
+ - } +
- + } @if (hasPermissionToDeleteOwnUser) {
Date: Sat, 20 Dec 2025 12:48:22 +0100 Subject: [PATCH 11/12] Refactor: Extract OIDC linking logic into a separate method for better readability and maintainability --- apps/api/src/app/auth/auth.controller.ts | 74 ++++++++++++++---------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 7af6863a3..620bf314a 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -129,36 +129,12 @@ export class AuthController { const rootUrl = this.configurationService.get('ROOT_URL'); if (linkState) { - try { - await this.authService.linkOidcToUser({ - thirdPartyId, - userId: linkState.userId - }); - - response.redirect( - `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true` - ); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - Logger.error( - `OIDC callback: Link failed - ${errorMessage}`, - 'AuthController' - ); - - let errorCode = 'unknown'; - if (error instanceof ConflictException) { - errorCode = error.message.includes('token authentication') - ? 'invalid-provider' - : 'already-linked'; - } else if (error instanceof NotFoundException) { - errorCode = 'invalid-session'; - } - - response.redirect( - `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkError=${errorCode}` - ); - } + await this.handleOidcLinkFlow( + thirdPartyId, + linkState.userId, + rootUrl, + response + ); return; } @@ -208,4 +184,42 @@ export class AuthController { ); } } + + private async handleOidcLinkFlow( + thirdPartyId: string, + userId: string, + rootUrl: string, + response: Response + ): Promise { + try { + await this.authService.linkOidcToUser({ + thirdPartyId, + userId + }); + + response.redirect( + `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true` + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + Logger.error( + `OIDC callback: Link failed - ${errorMessage}`, + 'AuthController' + ); + + let errorCode = 'unknown'; + if (error instanceof ConflictException) { + errorCode = error.message.includes('token authentication') + ? 'invalid-provider' + : 'already-linked'; + } else if (error instanceof NotFoundException) { + errorCode = 'invalid-session'; + } + + response.redirect( + `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkError=${errorCode}` + ); + } + } } From d77adf5ad36bc46adfffc3aabdae4da52bee0818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Sat, 20 Dec 2025 12:53:24 +0100 Subject: [PATCH 12/12] chore: Update changelog to include OIDC user account linking feature --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e8587c4f..9a9695cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 the feature to link an existing token-based user account to a OpenID Connect (`OIDC`) authentication provider + ## 2.224.0 - 2025-12-20 ### Added