Browse Source

Merge branch 'feature/oidc-user-link-refactor' into feature/oidc-user-link

pull/6075/head
Germán Martín 1 week ago
parent
commit
92a28cdf9a
  1. 10
      apps/api/src/app/auth/auth.controller.ts
  2. 10
      apps/api/src/app/auth/auth.module.ts
  3. 1
      apps/api/src/app/auth/interfaces/interfaces.ts
  4. 87
      apps/api/src/app/auth/oidc-state.store.ts
  5. 42
      apps/api/src/app/auth/oidc.strategy.ts
  6. 18
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

10
apps/api/src/app/auth/auth.controller.ts

@ -115,10 +115,6 @@ export class AuthController {
StatusCodes.FORBIDDEN 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') @Get('oidc/callback')
@ -132,16 +128,13 @@ export class AuthController {
request.user as OidcValidationResult; request.user as OidcValidationResult;
const rootUrl = this.configurationService.get('ROOT_URL'); const rootUrl = this.configurationService.get('ROOT_URL');
// Check if this is a link mode callback if (linkState) {
if (linkState?.linkMode) {
try { try {
// Link the OIDC account to the existing user
await this.authService.linkOidcToUser({ await this.authService.linkOidcToUser({
thirdPartyId, thirdPartyId,
userId: linkState.userId userId: linkState.userId
}); });
// Redirect to account page with success message
response.redirect( response.redirect(
`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true` `${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true`
); );
@ -153,7 +146,6 @@ export class AuthController {
'AuthController' 'AuthController'
); );
// Determine error type for frontend based on error type
let errorCode = 'unknown'; let errorCode = 'unknown';
if (error instanceof ConflictException) { if (error instanceof ConflictException) {
errorCode = error.message.includes('token authentication') errorCode = error.message.includes('token authentication')

10
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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Logger, Module } from '@nestjs/common'; import { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule, JwtService } from '@nestjs/jwt';
import type { StrategyOptions } from 'passport-openidconnect'; import type { StrategyOptions } from 'passport-openidconnect';
import { ApiKeyStrategy } from './api-key.strategy'; import { ApiKeyStrategy } from './api-key.strategy';
@ -42,10 +42,11 @@ import { OidcStrategy } from './oidc.strategy';
JwtStrategy, JwtStrategy,
OidcStateStore, OidcStateStore,
{ {
inject: [AuthService, OidcStateStore, ConfigurationService], inject: [AuthService, JwtService, OidcStateStore, ConfigurationService],
provide: OidcStrategy, provide: OidcStrategy,
useFactory: async ( useFactory: async (
authService: AuthService, authService: AuthService,
jwtService: JwtService,
stateStore: OidcStateStore, stateStore: OidcStateStore,
configurationService: ConfigurationService configurationService: ConfigurationService
) => { ) => {
@ -77,12 +78,10 @@ import { OidcStrategy } from './oidc.strategy';
let userInfoURL: string; let userInfoURL: string;
if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) { if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) {
// Use manual URLs
authorizationURL = manualAuthorizationUrl; authorizationURL = manualAuthorizationUrl;
tokenURL = manualTokenUrl; tokenURL = manualTokenUrl;
userInfoURL = manualUserInfoUrl; userInfoURL = manualUserInfoUrl;
} else { } else {
// Fetch OIDC configuration from discovery endpoint
try { try {
const response = await fetch( const response = await fetch(
`${issuer}/.well-known/openid-configuration` `${issuer}/.well-known/openid-configuration`
@ -94,7 +93,6 @@ import { OidcStrategy } from './oidc.strategy';
userinfo_endpoint: string; userinfo_endpoint: string;
}; };
// Manual URLs take priority over discovered ones
authorizationURL = authorizationURL =
manualAuthorizationUrl || config.authorization_endpoint; manualAuthorizationUrl || config.authorization_endpoint;
tokenURL = manualTokenUrl || config.token_endpoint; tokenURL = manualTokenUrl || config.token_endpoint;
@ -116,7 +114,7 @@ import { OidcStrategy } from './oidc.strategy';
clientSecret: configurationService.get('OIDC_CLIENT_SECRET') clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
}; };
return new OidcStrategy(authService, stateStore, options); return new OidcStrategy(authService, jwtService, stateStore, options);
} }
}, },
WebAuthService WebAuthService

1
apps/api/src/app/auth/interfaces/interfaces.ts

@ -31,7 +31,6 @@ export interface LinkOidcToUserParams {
} }
export interface OidcLinkState { export interface OidcLinkState {
linkMode: boolean;
userId: string; userId: string;
} }

87
apps/api/src/app/auth/oidc-state.store.ts

@ -1,54 +1,27 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import ms from 'ms'; import ms from 'ms';
import { OidcLinkState } from './interfaces/interfaces';
/** /**
* Custom state store for OIDC authentication that doesn't rely on express-session. * Custom state store for OIDC authentication that doesn't rely on express-session.
* This store manages OAuth2 state parameters in memory with automatic cleanup. * This store manages OAuth2 state parameters in memory with automatic cleanup.
* Supports link mode for linking existing token-authenticated users to OIDC.
*/ */
@Injectable() @Injectable()
export class OidcStateStore { export class OidcStateStore {
private readonly STATE_EXPIRY_MS = ms('10 minutes'); private readonly STATE_EXPIRY_MS = ms('10 minutes');
private pendingLinkState?: OidcLinkState;
private stateMap = new Map< private stateMap = new Map<
string, string,
{ {
appState?: unknown; appState?: unknown;
ctx: { issued?: string; maxAge?: number; nonce?: string }; ctx: { issued?: string; maxAge?: number; nonce?: string };
linkState?: OidcLinkState; linkToken?: string;
meta?: unknown;
timestamp: number; 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. * Store request state.
* Signature matches passport-openidconnect SessionStore * Signature matches passport-openidconnect SessionStore
* Automatically extracts linkMode from request query params and validates JWT token
*/ */
public store( public store(
req: unknown, req: unknown,
@ -58,63 +31,18 @@ export class OidcStateStore {
callback: (err: Error | null, handle?: string) => void callback: (err: Error | null, handle?: string) => void
) { ) {
try { try {
// Generate a unique handle for this state
const handle = this.generateHandle(); const handle = this.generateHandle();
// Check if there's a pending link state from the controller const request = req as { query?: { linkToken?: string } };
// or extract from request query params const linkToken = request?.query?.linkToken;
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'
);
}
}
}
this.stateMap.set(handle, { this.stateMap.set(handle, {
appState, appState,
ctx, ctx,
linkState, linkToken,
meta: _meta,
timestamp: Date.now() timestamp: Date.now()
}); });
// Clean up expired states
this.cleanup(); this.cleanup();
callback(null, handle); callback(null, handle);
@ -127,7 +55,6 @@ export class OidcStateStore {
/** /**
* Verify request state. * Verify request state.
* Signature matches passport-openidconnect SessionStore * Signature matches passport-openidconnect SessionStore
* Attaches linkState directly to request for retrieval in validate()
*/ */
public verify( public verify(
req: unknown, req: unknown,
@ -150,12 +77,10 @@ export class OidcStateStore {
return callback(null, undefined, undefined); return callback(null, undefined, undefined);
} }
// Remove state after verification (one-time use)
this.stateMap.delete(handle); this.stateMap.delete(handle);
// Attach linkState directly to request object for retrieval in validate() if (data.linkToken) {
if (data.linkState) { (req as any).oidcLinkToken = data.linkToken;
(req as any).oidcLinkState = data.linkState;
} }
callback(null, data.ctx, data.appState); callback(null, data.ctx, data.appState);

42
apps/api/src/app/auth/oidc.strategy.ts

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { Request } from 'express'; import { Request } from 'express';
@ -8,7 +9,6 @@ import { AuthService } from './auth.service';
import { import {
OidcContext, OidcContext,
OidcIdToken, OidcIdToken,
OidcLinkState,
OidcParams, OidcParams,
OidcProfile, OidcProfile,
OidcValidationResult OidcValidationResult
@ -19,6 +19,7 @@ import { OidcStateStore } from './oidc-state.store';
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly jwtService: JwtService,
stateStore: OidcStateStore, stateStore: OidcStateStore,
options: StrategyOptions options: StrategyOptions
) { ) {
@ -56,23 +57,24 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
throw new Error('Missing subject identifier in OIDC response'); throw new Error('Missing subject identifier in OIDC response');
} }
// Check if this is a link mode request // Check if user is already authenticated via JWT
// The linkState is attached to the request by OidcStateStore.verify() // If authenticated, this is a link operation; otherwise, normal login
const linkState = (request as any).oidcLinkState as // The linkToken is attached by OidcStateStore.verify() from the OAuth state
| OidcLinkState const linkToken = (request as any).oidcLinkToken as string | undefined;
| undefined; const authenticatedUserId = this.extractAuthenticatedUserId(linkToken);
if (linkState?.linkMode) { if (authenticatedUserId) {
// In link mode, we don't validate OAuth login (which would create a new user) // User is authenticated → Link mode
// Instead, we return the thirdPartyId for the controller to link // Return linkState for controller to handle linking
return { return {
linkState, linkState: {
userId: authenticatedUserId
},
thirdPartyId thirdPartyId
} as OidcValidationResult; } as OidcValidationResult;
} }
// Normal OIDC login flow // No authenticated user → Normal OIDC login flow
const jwt = await this.authService.validateOAuthLogin({ const jwt = await this.authService.validateOAuthLogin({
thirdPartyId, thirdPartyId,
provider: Provider.OIDC provider: Provider.OIDC
@ -84,4 +86,20 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
throw error; 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;
}
}
} }

18
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) { if (state?.user) {
this.user = 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.hasOidcLinked =
this.hasPermissionForAuthOidc && this.hasPermissionForAuthOidc &&
this.hasPermissionForAuthToken && this.hasPermissionForAuthToken &&
@ -256,11 +253,20 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public onLinkOidc() { public onLinkOidc() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
// Get current JWT token and navigate to OIDC with linkMode
const token = this.tokenStorageService.getToken(); const token = this.tokenStorageService.getToken();
if (token) { if (token) {
// Navigate to OIDC endpoint with linkMode and token const form = document.createElement('form');
window.location.href = `../api/auth/oidc?linkMode=true&token=${encodeURIComponent(token)}`; 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 { } else {
this.snackBar.open( this.snackBar.open(
$localize`Unable to initiate linking. Please log in again.`, $localize`Unable to initiate linking. Please log in again.`,

Loading…
Cancel
Save