Browse Source

Merge c37a457dc1 into 1a2ca71ef8

pull/6075/merge
Germán Martín 4 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. 114
      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/),
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
### Added

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

@ -10,9 +10,12 @@ import {
import {
Body,
ConflictException,
Controller,
Get,
HttpException,
Logger,
NotFoundException,
Param,
Post,
Req,
@ -26,6 +29,7 @@ import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service';
import { OidcValidationResult } from './interfaces/interfaces';
@Controller('auth')
export class AuthController {
@ -116,21 +120,29 @@ export class AuthController {
@Get('oidc/callback')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) {
const jwt: string = (request.user as any).jwt;
public async oidcLoginCallback(
@Req() request: Request,
@Res() response: Response
) {
const { linkState, thirdPartyId, jwt } =
request.user as OidcValidationResult;
const rootUrl = this.configurationService.get('ROOT_URL');
if (jwt) {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
if (linkState) {
await this.handleOidcLinkFlow(
thirdPartyId,
linkState.userId,
rootUrl,
response
);
return;
}
// Normal OIDC login flow
if (jwt) {
response.redirect(`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`);
} else {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
response.redirect(`${rootUrl}/${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 { 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';
@ -17,6 +17,7 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy';
import { OidcStateStore } from './oidc-state.store';
import { OidcStrategy } from './oidc.strategy';
@Module({
@ -39,11 +40,14 @@ import { OidcStrategy } from './oidc.strategy';
AuthService,
GoogleStrategy,
JwtStrategy,
OidcStateStore,
{
inject: [AuthService, ConfigurationService],
inject: [AuthService, JwtService, OidcStateStore, ConfigurationService],
provide: OidcStrategy,
useFactory: async (
authService: AuthService,
jwtService: JwtService,
stateStore: OidcStateStore,
configurationService: ConfigurationService
) => {
const isOidcEnabled = configurationService.get(
@ -74,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`
@ -91,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;
@ -113,7 +114,7 @@ import { OidcStrategy } from './oidc.strategy';
clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
};
return new OidcStrategy(authService, options);
return new OidcStrategy(authService, jwtService, stateStore, options);
}
},
WebAuthService

114
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 { 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 { ValidateOAuthLoginParams } from './interfaces/interfaces';
import {
LinkOidcToUserParams,
ValidateOAuthLoginParams
} from './interfaces/interfaces';
@Injectable()
export class AuthService {
@ -40,35 +50,107 @@ export class AuthService {
thirdPartyId
}: ValidateOAuthLoginParams): Promise<string> {
try {
// First, search by thirdPartyId only to support linked accounts
// (users with provider ANONYMOUS but with thirdPartyId set)
let [user] = await this.userService.users({
where: { provider, thirdPartyId }
where: { thirdPartyId }
});
if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (user) {
return this.jwtService.sign({
id: user.id
});
}
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
// Create new user if not found
user = await this.userService.createUser({
data: {
provider,
thirdPartyId
}
});
if (!isUserSignupEnabled) {
throw new ForbiddenException('Sign up forbidden');
}
// Create new user if not found
user = await this.userService.createUser({
data: {
provider,
thirdPartyId
}
});
return this.jwtService.sign({
id: user.id
});
} catch (error) {
Logger.error(
`validateOAuthLogin: Error - ${error instanceof Error ? error.message : 'Unknown error'}`,
'AuthService'
);
throw new InternalServerErrorException(
'validateOAuthLogin',
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;
}
export interface LinkOidcToUserParams {
thirdPartyId: string;
userId: string;
}
export interface OidcLinkState {
userId: string;
}
export interface OidcValidationResult {
jwt?: string;
linkState?: OidcLinkState;
thirdPartyId: string;
}
export interface ValidateOAuthLoginParams {
provider: Provider;
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';
/**
* Custom state store for OIDC authentication that doesn't rely on express-session.
* This store manages OAuth2 state parameters in memory with automatic cleanup.
*/
@Injectable()
export class OidcStateStore {
private readonly STATE_EXPIRY_MS = ms('10 minutes');
@ -11,8 +13,8 @@ export class OidcStateStore {
string,
{
appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string };
meta?: unknown;
ctx: { issued?: string; maxAge?: number; nonce?: string };
linkToken?: string;
timestamp: number;
}
>();
@ -22,28 +24,30 @@ export class OidcStateStore {
* Signature matches passport-openidconnect SessionStore
*/
public store(
_req: unknown,
req: unknown,
_meta: unknown,
appState: unknown,
ctx: { maxAge?: number; nonce?: string; issued?: Date },
ctx: { maxAge?: number; nonce?: string; issued?: string },
callback: (err: Error | null, handle?: string) => void
) {
try {
// Generate a unique handle for this state
const handle = this.generateHandle();
const request = req as { query?: { linkToken?: string } };
const linkToken = request?.query?.linkToken;
this.stateMap.set(handle, {
appState,
ctx,
meta: _meta,
linkToken,
timestamp: Date.now()
});
// Clean up expired states
this.cleanup();
callback(null, handle);
} catch (error) {
Logger.error(`Error storing OIDC state: ${error}`, 'OidcStateStore');
callback(error as Error);
}
}
@ -53,12 +57,12 @@ export class OidcStateStore {
* Signature matches passport-openidconnect SessionStore
*/
public verify(
_req: unknown,
req: unknown,
handle: string,
callback: (
err: Error | null,
appState?: unknown,
ctx?: { maxAge?: number; nonce?: string; issued?: Date }
ctx?: { maxAge?: number; nonce?: string; issued?: string },
state?: unknown
) => void
) {
try {
@ -69,16 +73,19 @@ export class OidcStateStore {
}
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) {
// State has expired
this.stateMap.delete(handle);
return callback(null, undefined, undefined);
}
// Remove state after verification (one-time use)
this.stateMap.delete(handle);
if (data.linkToken) {
(req as any).oidcLinkToken = data.linkToken;
}
callback(null, data.ctx, data.appState);
} catch (error) {
Logger.error(`Error verifying OIDC state: ${error}`, 'OidcStateStore');
callback(error as Error);
}
}

58
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';
@ -9,27 +10,28 @@ import {
OidcContext,
OidcIdToken,
OidcParams,
OidcProfile
OidcProfile,
OidcValidationResult
} from './interfaces/interfaces';
import { OidcStateStore } from './oidc-state.store';
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private static readonly stateStore = new OidcStateStore();
public constructor(
private readonly authService: AuthService,
private readonly jwtService: JwtService,
stateStore: OidcStateStore,
options: StrategyOptions
) {
super({
...options,
passReqToCallback: true,
store: OidcStrategy.stateStore
store: stateStore
});
}
public async validate(
_request: Request,
request: Request,
issuer: string,
profile: OidcProfile,
context: OidcContext,
@ -46,11 +48,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
params?.sub ??
context?.claims?.sub;
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
if (!thirdPartyId) {
Logger.error(
`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');
}
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) {
Logger.error(error, 'OidcStrategy');
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(
{ accounts, id, permissions, settings, subscription }: UserWithSettings,
{
accounts,
id,
permissions,
provider,
settings,
subscription,
thirdPartyId
}: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const userData = await Promise.all([
@ -150,9 +158,11 @@ export class UserService {
activitiesCount,
id,
permissions,
provider,
subscription,
systemMessage,
tags,
thirdPartyId,
access: access.map((accessItem) => {
return {
alias: accessItem.alias,

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

@ -38,11 +38,11 @@ import {
MatSlideToggleModule
} from '@angular/material/slide-toggle';
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 { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { eyeOffOutline, eyeOutline, linkOutline } from 'ionicons/icons';
import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -69,10 +69,14 @@ import { catchError, takeUntil } from 'rxjs/operators';
export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public canLinkOidc = false;
public currencies: string[] = [];
public deleteOwnUserForm = this.formBuilder.group({
accessToken: ['', Validators.required]
});
public hasOidcLinked = false;
public hasPermissionForAuthOidc = false;
public hasPermissionForAuthToken = false;
public hasPermissionToDeleteOwnUser: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
@ -101,6 +105,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private formBuilder: FormBuilder,
@ -111,17 +116,40 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
private userService: UserService,
public webAuthnService: WebAuthnService
) {
const { baseCurrency, currencies } = this.dataService.fetchInfo();
const { baseCurrency, currencies, globalPermissions } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies;
this.hasPermissionForAuthOidc = hasPermission(
globalPermissions,
permissions.enableAuthOidc
);
this.hasPermissionForAuthToken = hasPermission(
globalPermissions,
permissions.enableAuthToken
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (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.user.permissions,
permissions.deleteOwnUser
@ -144,11 +172,55 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
}
});
addIcons({ eyeOffOutline, eyeOutline });
addIcons({ eyeOffOutline, eyeOutline, linkOutline });
}
public ngOnInit() {
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() {
@ -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() {
this.notificationService.confirm({
confirmFn: () => {
@ -353,7 +457,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
this.update();
resolve();
},
error: (error) => {
error: (error: Error) => {
reject(error);
}
});

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

@ -261,6 +261,25 @@
</button>
</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) {
<hr class="mt-5" />
<form

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

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

Loading…
Cancel
Save