Browse Source

Refactor: OIDC linking process. Streamline state handling and update token management

pull/6075/head
Germán Martín 1 week ago
parent
commit
94ecca45bf
  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. 85
      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
);
}
// 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')

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

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

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

85
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);

42
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;
}
}
}

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

Loading…
Cancel
Save