Browse Source

Feature/migrate token user to oidc

pull/6075/head
Germán Martín 2 weeks ago
parent
commit
970566aca1
  1. 90
      apps/api/src/app/auth/auth.controller.ts
  2. 5
      apps/api/src/app/auth/auth.module.ts
  3. 144
      apps/api/src/app/auth/auth.service.ts
  4. 138
      apps/api/src/app/auth/oidc-state.store.ts
  5. 64
      apps/api/src/app/auth/oidc.strategy.ts
  6. 12
      apps/api/src/app/user/user.service.ts
  7. 92
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  8. 23
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  9. 4
      libs/common/src/lib/interfaces/user.interface.ts

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

@ -13,8 +13,10 @@ import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Logger,
Param, Param,
Post, Post,
Query,
Req, Req,
Res, Res,
UseGuards, UseGuards,
@ -26,6 +28,7 @@ import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { OidcValidationResult } from './oidc.strategy';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -104,33 +107,94 @@ export class AuthController {
@Get('oidc') @Get('oidc')
@UseGuards(AuthGuard('oidc')) @UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public oidcLogin() { public oidcLogin(@Query('linkMode') linkMode: string) {
if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
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') @Get('oidc/callback')
@UseGuards(AuthGuard('oidc')) @UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { public async oidcLoginCallback(
const jwt: string = (request.user as any).jwt; @Req() request: Request,
@Res() response: Response
) {
const result = request.user as OidcValidationResult;
const rootUrl = this.configurationService.get('ROOT_URL');
if (jwt) { // Check if this is a link mode callback
response.redirect( if (result.linkState?.linkMode) {
`${this.configurationService.get( Logger.log(
'ROOT_URL' `OIDC callback: Link mode detected for user ${result.linkState.userId.substring(0, 8)}...`,
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` '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 { } else {
response.redirect( response.redirect(`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/auth`);
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
} }
} }

5
apps/api/src/app/auth/auth.module.ts

@ -113,7 +113,10 @@ import { OidcStrategy } from './oidc.strategy';
clientSecret: configurationService.get('OIDC_CLIENT_SECRET') 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 WebAuthService

144
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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 { JwtService } from '@nestjs/jwt';
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -40,35 +45,144 @@ export class AuthService {
thirdPartyId thirdPartyId
}: ValidateOAuthLoginParams): Promise<string> { }: ValidateOAuthLoginParams): Promise<string> {
try { 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({ let [user] = await this.userService.users({
where: { provider, thirdPartyId } where: { thirdPartyId }
}); });
if (!user) { if (user) {
const isUserSignupEnabled = Logger.log(
await this.propertyService.isUserSignupEnabled(); `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) { Logger.debug(
throw new Error('Sign up forbidden'); `validateOAuthLogin: No user found with thirdPartyId, checking if signup is enabled`,
} 'AuthService'
);
// Create new user if not found const isUserSignupEnabled =
user = await this.userService.createUser({ await this.propertyService.isUserSignupEnabled();
data: {
provider, if (!isUserSignupEnabled) {
thirdPartyId 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({ return this.jwtService.sign({
id: user.id id: user.id
}); });
} catch (error) { } catch (error) {
Logger.error(
`validateOAuthLogin: Error - ${error instanceof Error ? error.message : 'Unknown error'}`,
'AuthService'
);
throw new InternalServerErrorException( throw new InternalServerErrorException(
'validateOAuthLogin', 'validateOAuthLogin',
error instanceof Error ? error.message : 'Unknown error' 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<string> {
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 });
}
} }

138
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'; import ms from 'ms';
export interface OidcLinkState {
linkMode: boolean;
userId: string;
}
/** /**
* 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.
*/ */
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 jwtSecret?: string;
private stateMap = new Map< private stateMap = new Map<
string, string,
{ {
appState?: unknown; appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string }; ctx: { issued?: string; maxAge?: number; nonce?: string };
linkState?: OidcLinkState;
meta?: unknown; meta?: unknown;
timestamp: number; timestamp: number;
} }
>(); >();
/**
* Set the JWT secret for token validation in link mode
*/
public setJwtSecret(secret: string) {
this.jwtSecret = secret;
}
/** /**
* 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,
_meta: unknown, _meta: unknown,
appState: unknown, appState: unknown,
ctx: { maxAge?: number; nonce?: string; issued?: Date }, ctx: { maxAge?: number; nonce?: string; issued?: string },
callback: (err: Error | null, handle?: string) => void callback: (err: Error | null, handle?: string) => void
) { ) {
try { try {
// Generate a unique handle for this state // 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
// 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, { this.stateMap.set(handle, {
appState, appState,
ctx, ctx,
linkState,
meta: _meta, meta: _meta,
timestamp: Date.now() timestamp: Date.now()
}); });
@ -44,6 +123,7 @@ export class OidcStateStore {
callback(null, handle); callback(null, handle);
} catch (error) { } catch (error) {
Logger.error(`Error storing OIDC state: ${error}`, 'OidcStateStore');
callback(error as Error); callback(error as Error);
} }
} }
@ -51,25 +131,34 @@ 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,
handle: string, handle: string,
callback: ( callback: (
err: Error | null, err: Error | null,
appState?: unknown, ctx?: { maxAge?: number; nonce?: string; issued?: string },
ctx?: { maxAge?: number; nonce?: string; issued?: Date } state?: unknown
) => void ) => void
) { ) {
try { try {
const data = this.stateMap.get(handle); const data = this.stateMap.get(handle);
if (!data) { if (!data) {
Logger.debug(
`OIDC state not found for handle ${handle.substring(0, 8)}...`,
'OidcStateStore'
);
return callback(null, undefined, undefined); return callback(null, undefined, undefined);
} }
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) {
// State has expired // State has expired
Logger.debug(
`OIDC state expired for handle ${handle.substring(0, 8)}...`,
'OidcStateStore'
);
this.stateMap.delete(handle); this.stateMap.delete(handle);
return callback(null, undefined, undefined); return callback(null, undefined, undefined);
} }
@ -77,8 +166,24 @@ export class OidcStateStore {
// Remove state after verification (one-time use) // Remove state after verification (one-time use)
this.stateMap.delete(handle); 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); callback(null, data.ctx, data.appState);
} catch (error) { } catch (error) {
Logger.error(`Error verifying OIDC state: ${error}`, 'OidcStateStore');
callback(error as Error); callback(error as Error);
} }
} }
@ -111,4 +216,25 @@ export class OidcStateStore {
Date.now().toString(36) 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;
}
} }

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

@ -11,25 +11,45 @@ import {
OidcParams, OidcParams,
OidcProfile OidcProfile
} from './interfaces/interfaces'; } 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() @Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private static readonly stateStore = new OidcStateStore(); private static readonly stateStore = new OidcStateStore();
public static getStateStore(): OidcStateStore {
return OidcStrategy.stateStore;
}
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
options: StrategyOptions options: OidcStrategyOptions
) { ) {
super({ super({
...options, ...options,
passReqToCallback: true, passReqToCallback: true,
store: OidcStrategy.stateStore 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( public async validate(
_request: Request, request: Request,
issuer: string, issuer: string,
profile: OidcProfile, profile: OidcProfile,
context: OidcContext, context: OidcContext,
@ -46,11 +66,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
params?.sub ?? params?.sub ??
context?.claims?.sub; context?.claims?.sub;
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
if (!thirdPartyId) { if (!thirdPartyId) {
Logger.error( Logger.error(
`Missing subject identifier in OIDC response from ${issuer}`, `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'); 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) { } catch (error) {
Logger.error(error, 'OidcStrategy'); Logger.error(error, 'OidcStrategy');
throw error; throw error;

12
apps/api/src/app/user/user.service.ts

@ -97,7 +97,15 @@ export class UserService {
} }
public async getUser( public async getUser(
{ accounts, id, permissions, settings, subscription }: UserWithSettings, {
accounts,
id,
permissions,
provider,
settings,
subscription,
thirdPartyId
}: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const userData = await Promise.all([ const userData = await Promise.all([
@ -150,9 +158,11 @@ export class UserService {
activitiesCount, activitiesCount,
id, id,
permissions, permissions,
provider,
subscription, subscription,
systemMessage, systemMessage,
tags, tags,
thirdPartyId,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.alias, alias: accessItem.alias,

92
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -37,11 +37,11 @@ import {
MatSlideToggleModule MatSlideToggleModule
} from '@angular/material/slide-toggle'; } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar'; 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 { IonIcon } from '@ionic/angular/standalone';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; import { eyeOffOutline, eyeOutline, linkOutline } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs'; import { EMPTY, Subject, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -68,10 +68,14 @@ import { catchError, takeUntil } from 'rxjs/operators';
export class GfUserAccountSettingsComponent implements OnDestroy, OnInit { export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public appearancePlaceholder = $localize`Auto`; public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string; public baseCurrency: string;
public canLinkOidc: boolean = false;
public currencies: string[] = []; public currencies: string[] = [];
public deleteOwnUserForm = this.formBuilder.group({ public deleteOwnUserForm = this.formBuilder.group({
accessToken: ['', Validators.required] accessToken: ['', Validators.required]
}); });
public hasOidcLinked: boolean = false;
public hasPermissionForAuthOidc: boolean = false;
public hasPermissionForAuthToken: boolean = false;
public hasPermissionToDeleteOwnUser: boolean; public hasPermissionToDeleteOwnUser: boolean;
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
@ -100,6 +104,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@ -110,17 +115,42 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
private userService: UserService, private userService: UserService,
public webAuthnService: WebAuthnService public webAuthnService: WebAuthnService
) { ) {
const { baseCurrency, currencies } = this.dataService.fetchInfo(); const { baseCurrency, currencies, globalPermissions } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
this.currencies = currencies; this.currencies = currencies;
// Check global permissions for auth methods
this.hasPermissionForAuthOidc = hasPermission(
globalPermissions,
permissions.enableAuthOidc
);
this.hasPermissionForAuthToken = hasPermission(
globalPermissions,
permissions.enableAuthToken
);
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
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.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.hasPermissionToDeleteOwnUser = hasPermission(
this.user.permissions, this.user.permissions,
permissions.deleteOwnUser permissions.deleteOwnUser
@ -143,11 +173,42 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
} }
}); });
addIcons({ eyeOffOutline, eyeOutline }); addIcons({ eyeOffOutline, eyeOutline, linkOutline });
} }
public ngOnInit() { public ngOnInit() {
this.update(); 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() { 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() { public onCloseAccount() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {

23
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -261,6 +261,29 @@
</button> </button>
</div> </div>
</div> </div>
@if (canLinkOidc || hasOidcLinked) {
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>OIDC Provider</div>
<div class="hint-text text-muted" i18n>
Link your account to sign in with OpenID Connect
</div>
</div>
<div class="pl-1 w-50">
@if (hasOidcLinked) {
<span class="badge badge-success" i18n>
<ion-icon class="mr-1" name="link-outline"></ion-icon>
Linked
</span>
} @else {
<button color="primary" mat-flat-button (click)="onLinkOidc()">
<ion-icon class="mr-2" name="link-outline"></ion-icon>
<span i18n>Link OIDC Provider</span>
</button>
}
</div>
</div>
}
@if (hasPermissionToDeleteOwnUser) { @if (hasPermissionToDeleteOwnUser) {
<hr class="mt-5" /> <hr class="mt-5" />
<form <form

4
libs/common/src/lib/interfaces/user.interface.ts

@ -1,7 +1,7 @@
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { AccountWithPlatform } from '@ghostfolio/common/types'; import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Access, Tag } from '@prisma/client'; import { Access, Provider, Tag } from '@prisma/client';
import { SubscriptionOffer } from './subscription-offer.interface'; import { SubscriptionOffer } from './subscription-offer.interface';
import { SystemMessage } from './system-message.interface'; import { SystemMessage } from './system-message.interface';
@ -15,6 +15,7 @@ export interface User {
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
id: string; id: string;
permissions: string[]; permissions: string[];
provider: Provider;
settings: UserSettings; settings: UserSettings;
systemMessage?: SystemMessage; systemMessage?: SystemMessage;
subscription: { subscription: {
@ -23,4 +24,5 @@ export interface User {
type: SubscriptionType; type: SubscriptionType;
}; };
tags: (Tag & { isUsed: boolean })[]; tags: (Tag & { isUsed: boolean })[];
thirdPartyId?: string;
} }

Loading…
Cancel
Save