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.`,