diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bdfb7eb4..701962975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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 + +## Unreleased + +### Added + +- Added the feature to link an existing token-based user account to a OpenID Connect (`OIDC`) authentication provider + ## 2.224.2 - 2025-12-20 ### Added diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 388f1dbd3..620bf314a 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -10,9 +10,12 @@ import { import { Body, + ConflictException, Controller, Get, HttpException, + Logger, + NotFoundException, Param, Post, Req, @@ -26,6 +29,7 @@ import { Request, Response } from 'express'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { AuthService } from './auth.service'; +import { OidcValidationResult } from './interfaces/interfaces'; @Controller('auth') export class AuthController { @@ -116,21 +120,29 @@ export class AuthController { @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 { linkState, thirdPartyId, jwt } = + 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}` + if (linkState) { + await this.handleOidcLinkFlow( + thirdPartyId, + linkState.userId, + rootUrl, + response ); + return; + } + + // Normal OIDC login flow + 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`); } } @@ -172,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}` + ); + } + } } diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 9fc5d0925..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'; @@ -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,14 @@ import { OidcStrategy } from './oidc.strategy'; AuthService, GoogleStrategy, JwtStrategy, + OidcStateStore, { - inject: [AuthService, ConfigurationService], + inject: [AuthService, JwtService, OidcStateStore, ConfigurationService], provide: OidcStrategy, useFactory: async ( authService: AuthService, + jwtService: JwtService, + stateStore: OidcStateStore, configurationService: ConfigurationService ) => { const isOidcEnabled = configurationService.get( @@ -74,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` @@ -91,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; @@ -113,7 +114,7 @@ import { OidcStrategy } from './oidc.strategy'; clientSecret: configurationService.get('OIDC_CLIENT_SECRET') }; - return new OidcStrategy(authService, options); + return new OidcStrategy(authService, jwtService, stateStore, options); } }, WebAuthService diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 6fe50dce0..aa6d2206d 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -2,10 +2,20 @@ 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, + ForbiddenException, + Injectable, + InternalServerErrorException, + 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 { @@ -40,35 +50,107 @@ export class AuthService { thirdPartyId }: ValidateOAuthLoginParams): Promise { try { + // 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) { + return this.jwtService.sign({ + id: user.id + }); + } - if (!isUserSignupEnabled) { - throw new Error('Sign up forbidden'); - } + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); - // Create new user if not found - user = await this.userService.createUser({ - data: { - provider, - thirdPartyId - } - }); + if (!isUserSignupEnabled) { + throw new ForbiddenException('Sign up forbidden'); } + // Create new user if not found + 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 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({ + thirdPartyId, + userId + }: LinkOidcToUserParams): Promise { + // 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) { + throw new NotFoundException('User not found'); + } + + if (user.provider !== 'ANONYMOUS') { + throw new ConflictException( + 'Only users with token authentication can link OIDC' + ); + } + + // Update user with thirdPartyId and switch provider to OIDC + await this.userService.updateUser({ + 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..9fc0a36bf 100644 --- a/apps/api/src/app/auth/interfaces/interfaces.ts +++ b/apps/api/src/app/auth/interfaces/interfaces.ts @@ -25,6 +25,21 @@ export interface OidcProfile { sub?: string; } +export interface LinkOidcToUserParams { + thirdPartyId: string; + userId: string; +} + +export interface OidcLinkState { + 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 653451166..116d14284 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -1,9 +1,11 @@ +import { Injectable, Logger } from '@nestjs/common'; 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. */ +@Injectable() export class OidcStateStore { private readonly STATE_EXPIRY_MS = ms('10 minutes'); @@ -11,8 +13,8 @@ export class OidcStateStore { string, { appState?: unknown; - ctx: { issued?: Date; maxAge?: number; nonce?: string }; - meta?: unknown; + ctx: { issued?: string; maxAge?: number; nonce?: string }; + linkToken?: string; timestamp: number; } >(); @@ -22,28 +24,30 @@ export class OidcStateStore { * Signature matches passport-openidconnect SessionStore */ 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(); + const request = req as { query?: { linkToken?: string } }; + const linkToken = request?.query?.linkToken; + this.stateMap.set(handle, { appState, ctx, - meta: _meta, + linkToken, timestamp: Date.now() }); - // Clean up expired states this.cleanup(); callback(null, handle); } catch (error) { + Logger.error(`Error storing OIDC state: ${error}`, 'OidcStateStore'); callback(error as Error); } } @@ -53,12 +57,12 @@ export class OidcStateStore { * Signature matches passport-openidconnect SessionStore */ 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 { @@ -69,16 +73,19 @@ export class OidcStateStore { } if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { - // State has expired this.stateMap.delete(handle); return callback(null, undefined, undefined); } - // Remove state after verification (one-time use) this.stateMap.delete(handle); + if (data.linkToken) { + (req as any).oidcLinkToken = data.linkToken; + } + callback(null, data.ctx, data.appState); } catch (error) { + Logger.error(`Error verifying OIDC state: ${error}`, 'OidcStateStore'); callback(error as Error); } } diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 96b284121..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'; @@ -9,27 +10,28 @@ import { OidcContext, OidcIdToken, OidcParams, - OidcProfile + OidcProfile, + OidcValidationResult } from './interfaces/interfaces'; 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, + private readonly jwtService: JwtService, + stateStore: OidcStateStore, options: StrategyOptions ) { super({ ...options, passReqToCallback: true, - store: OidcStrategy.stateStore + store: stateStore }); } public async validate( - _request: Request, + request: Request, issuer: string, profile: OidcProfile, context: OidcContext, @@ -46,11 +48,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,10 +57,49 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { throw new Error('Missing subject identifier in OIDC response'); } - return { jwt }; + // 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 (authenticatedUserId) { + // User is authenticated → Link mode + // Return linkState for controller to handle linking + return { + linkState: { + userId: authenticatedUserId + }, + thirdPartyId + } as OidcValidationResult; + } + + // No authenticated user → Normal OIDC login flow + const jwt = await this.authService.validateOAuthLogin({ + thirdPartyId, + provider: Provider.OIDC + }); + + return { jwt, thirdPartyId } as OidcValidationResult; } catch (error) { Logger.error(error, 'OidcStrategy'); 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/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 3280fbfac..1a04a675c 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 e0028bb5c..fa0e10ab2 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 @@ -38,11 +38,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'; @@ -69,10 +69,14 @@ import { catchError, takeUntil } from 'rxjs/operators'; export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { public appearancePlaceholder = $localize`Auto`; public baseCurrency: string; + public canLinkOidc = false; public currencies: string[] = []; public deleteOwnUserForm = this.formBuilder.group({ accessToken: ['', Validators.required] }); + public hasOidcLinked = false; + public hasPermissionForAuthOidc = false; + public hasPermissionForAuthToken = false; public hasPermissionToDeleteOwnUser: boolean; public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateUserSettings: boolean; @@ -101,6 +105,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, @@ -111,17 +116,40 @@ 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; + 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; + 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 @@ -144,11 +172,55 @@ 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 getAuthProviderDisplayName(): string { + switch (this.user?.provider) { + case 'ANONYMOUS': + return 'Security Token'; + case 'GOOGLE': + return 'Google'; + case 'OIDC': + return 'OpenID Connect (OIDC)'; + default: + return this.user?.provider || 'Unknown'; + } } public isCommunityLanguage() { @@ -179,6 +251,38 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { }); } + public onLinkOidc() { + this.notificationService.confirm({ + confirmFn: () => { + const token = this.tokenStorageService.getToken(); + if (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.`, + 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: () => { @@ -353,7 +457,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 e6ab544c8..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 @@ -261,6 +261,25 @@ +
+
+
Authentication Provider
+
+
+
{{ getAuthProviderDisplayName() }}
+
+
+ @if (canLinkOidc) { +
+
+
+ +
+
+ } @if (hasPermissionToDeleteOwnUser) {