Browse Source

Merge c37a457dc1 into 1a2ca71ef8

pull/6075/merge
Germán Martín 5 days ago
committed by GitHub
parent
commit
9050fa2689
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      CHANGELOG.md
  2. 74
      apps/api/src/app/auth/auth.controller.ts
  3. 13
      apps/api/src/app/auth/auth.module.ts
  4. 94
      apps/api/src/app/auth/auth.service.ts
  5. 15
      apps/api/src/app/auth/interfaces/interfaces.ts
  6. 31
      apps/api/src/app/auth/oidc-state.store.ts
  7. 58
      apps/api/src/app/auth/oidc.strategy.ts
  8. 12
      apps/api/src/app/user/user.service.ts
  9. 114
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  10. 19
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  11. 4
      libs/common/src/lib/interfaces/user.interface.ts

12
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/), 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). 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 ## 2.224.2 - 2025-12-20
### Added ### Added

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

@ -10,9 +10,12 @@ import {
import { import {
Body, Body,
ConflictException,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Logger,
NotFoundException,
Param, Param,
Post, Post,
Req, Req,
@ -26,6 +29,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 './interfaces/interfaces';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -116,21 +120,29 @@ export class AuthController {
@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 { linkState, thirdPartyId, jwt } =
request.user as OidcValidationResult;
const rootUrl = this.configurationService.get('ROOT_URL');
if (jwt) { if (linkState) {
response.redirect( await this.handleOidcLinkFlow(
`${this.configurationService.get( thirdPartyId,
'ROOT_URL' linkState.userId,
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` rootUrl,
response
); );
return;
}
// Normal OIDC login flow
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`
);
} }
} }
@ -172,4 +184,42 @@ export class AuthController {
); );
} }
} }
private async handleOidcLinkFlow(
thirdPartyId: string,
userId: string,
rootUrl: string,
response: Response
): Promise<void> {
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}`
);
}
}
} }

13
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';
@ -17,6 +17,7 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { OidcStateStore } from './oidc-state.store';
import { OidcStrategy } from './oidc.strategy'; import { OidcStrategy } from './oidc.strategy';
@Module({ @Module({
@ -39,11 +40,14 @@ import { OidcStrategy } from './oidc.strategy';
AuthService, AuthService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
OidcStateStore,
{ {
inject: [AuthService, ConfigurationService], inject: [AuthService, JwtService, OidcStateStore, ConfigurationService],
provide: OidcStrategy, provide: OidcStrategy,
useFactory: async ( useFactory: async (
authService: AuthService, authService: AuthService,
jwtService: JwtService,
stateStore: OidcStateStore,
configurationService: ConfigurationService configurationService: ConfigurationService
) => { ) => {
const isOidcEnabled = configurationService.get( const isOidcEnabled = configurationService.get(
@ -74,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`
@ -91,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;
@ -113,7 +114,7 @@ import { OidcStrategy } from './oidc.strategy';
clientSecret: configurationService.get('OIDC_CLIENT_SECRET') clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
}; };
return new OidcStrategy(authService, options); return new OidcStrategy(authService, jwtService, stateStore, options);
} }
}, },
WebAuthService WebAuthService

94
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 { 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,
ForbiddenException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; import {
LinkOidcToUserParams,
ValidateOAuthLoginParams
} from './interfaces/interfaces';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -40,16 +50,23 @@ export class AuthService {
thirdPartyId thirdPartyId
}: ValidateOAuthLoginParams): Promise<string> { }: ValidateOAuthLoginParams): Promise<string> {
try { try {
// 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) {
return this.jwtService.sign({
id: user.id
});
}
const isUserSignupEnabled = const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled(); await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) { if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden'); throw new ForbiddenException('Sign up forbidden');
} }
// Create new user if not found // Create new user if not found
@ -59,16 +76,81 @@ export class AuthService {
thirdPartyId 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 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<string> {
// 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 });
}
} }

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

@ -25,6 +25,21 @@ export interface OidcProfile {
sub?: string; 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 { export interface ValidateOAuthLoginParams {
provider: Provider; provider: Provider;
thirdPartyId: string; thirdPartyId: string;

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

@ -1,9 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import ms from 'ms'; import ms from 'ms';
/** /**
* 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.
*/ */
@Injectable()
export class OidcStateStore { export class OidcStateStore {
private readonly STATE_EXPIRY_MS = ms('10 minutes'); private readonly STATE_EXPIRY_MS = ms('10 minutes');
@ -11,8 +13,8 @@ export class OidcStateStore {
string, string,
{ {
appState?: unknown; appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string }; ctx: { issued?: string; maxAge?: number; nonce?: string };
meta?: unknown; linkToken?: string;
timestamp: number; timestamp: number;
} }
>(); >();
@ -22,28 +24,30 @@ export class OidcStateStore {
* Signature matches passport-openidconnect SessionStore * Signature matches passport-openidconnect SessionStore
*/ */
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
const handle = this.generateHandle(); const handle = this.generateHandle();
const request = req as { query?: { linkToken?: string } };
const linkToken = request?.query?.linkToken;
this.stateMap.set(handle, { this.stateMap.set(handle, {
appState, appState,
ctx, ctx,
meta: _meta, linkToken,
timestamp: Date.now() timestamp: Date.now()
}); });
// Clean up expired states
this.cleanup(); this.cleanup();
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);
} }
} }
@ -53,12 +57,12 @@ export class OidcStateStore {
* Signature matches passport-openidconnect SessionStore * Signature matches passport-openidconnect SessionStore
*/ */
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 {
@ -69,16 +73,19 @@ export class OidcStateStore {
} }
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) {
// State has expired
this.stateMap.delete(handle); this.stateMap.delete(handle);
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);
if (data.linkToken) {
(req as any).oidcLinkToken = data.linkToken;
}
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);
} }
} }

58
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';
@ -9,27 +10,28 @@ import {
OidcContext, OidcContext,
OidcIdToken, OidcIdToken,
OidcParams, OidcParams,
OidcProfile OidcProfile,
OidcValidationResult
} from './interfaces/interfaces'; } from './interfaces/interfaces';
import { OidcStateStore } from './oidc-state.store'; import { OidcStateStore } from './oidc-state.store';
@Injectable() @Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private static readonly stateStore = new OidcStateStore();
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly jwtService: JwtService,
stateStore: OidcStateStore,
options: StrategyOptions options: StrategyOptions
) { ) {
super({ super({
...options, ...options,
passReqToCallback: true, passReqToCallback: true,
store: OidcStrategy.stateStore store: stateStore
}); });
} }
public async validate( public async validate(
_request: Request, request: Request,
issuer: string, issuer: string,
profile: OidcProfile, profile: OidcProfile,
context: OidcContext, context: OidcContext,
@ -46,11 +48,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,10 +57,49 @@ 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 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) { } catch (error) {
Logger.error(error, 'OidcStrategy'); Logger.error(error, 'OidcStrategy');
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;
}
}
} }

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,

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

@ -38,11 +38,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';
@ -69,10 +69,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 = 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 = false;
public hasPermissionForAuthOidc = false;
public hasPermissionForAuthToken = false;
public hasPermissionToDeleteOwnUser: boolean; public hasPermissionToDeleteOwnUser: boolean;
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
@ -101,6 +105,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,
@ -111,17 +116,40 @@ 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;
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;
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
@ -144,11 +172,55 @@ 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 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() { 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() { public onCloseAccount() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
@ -353,7 +457,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
this.update(); this.update();
resolve(); resolve();
}, },
error: (error) => { error: (error: Error) => {
reject(error); reject(error);
} }
}); });

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

@ -261,6 +261,25 @@
</button> </button>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Authentication Provider</div>
</div>
<div class="pl-1 w-50">
<div>{{ getAuthProviderDisplayName() }}</div>
</div>
</div>
@if (canLinkOidc) {
<div class="align-items-center d-flex py-1">
<div class="pr-1 w-50"></div>
<div class="pl-1 text-monospace w-50">
<button color="primary" mat-flat-button (click)="onLinkOidc()">
<ion-icon class="mr-2" name="link-outline"></ion-icon>
<span i18n>Link OIDC</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