mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
18 changed files with 447 additions and 34 deletions
@ -0,0 +1,114 @@ |
|||
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. |
|||
*/ |
|||
export class OidcStateStore { |
|||
private readonly STATE_EXPIRY_MS = ms('10 minutes'); |
|||
|
|||
private stateMap = new Map< |
|||
string, |
|||
{ |
|||
appState?: unknown; |
|||
ctx: { issued?: Date; maxAge?: number; nonce?: string }; |
|||
meta?: unknown; |
|||
timestamp: number; |
|||
} |
|||
>(); |
|||
|
|||
/** |
|||
* Store request state. |
|||
* Signature matches passport-openidconnect SessionStore |
|||
*/ |
|||
public store( |
|||
_req: unknown, |
|||
_meta: unknown, |
|||
appState: unknown, |
|||
ctx: { maxAge?: number; nonce?: string; issued?: Date }, |
|||
callback: (err: Error | null, handle?: string) => void |
|||
) { |
|||
try { |
|||
// Generate a unique handle for this state
|
|||
const handle = this.generateHandle(); |
|||
|
|||
this.stateMap.set(handle, { |
|||
appState, |
|||
ctx, |
|||
meta: _meta, |
|||
timestamp: Date.now() |
|||
}); |
|||
|
|||
// Clean up expired states
|
|||
this.cleanup(); |
|||
|
|||
callback(null, handle); |
|||
} catch (error) { |
|||
callback(error as Error); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Verify request state. |
|||
* Signature matches passport-openidconnect SessionStore |
|||
*/ |
|||
public verify( |
|||
_req: unknown, |
|||
handle: string, |
|||
callback: ( |
|||
err: Error | null, |
|||
appState?: unknown, |
|||
ctx?: { maxAge?: number; nonce?: string; issued?: Date } |
|||
) => void |
|||
) { |
|||
try { |
|||
const data = this.stateMap.get(handle); |
|||
|
|||
if (!data) { |
|||
return callback(null, undefined, undefined); |
|||
} |
|||
|
|||
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); |
|||
|
|||
callback(null, data.ctx, data.appState); |
|||
} catch (error) { |
|||
callback(error as Error); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Clean up expired states |
|||
*/ |
|||
private cleanup() { |
|||
const now = Date.now(); |
|||
const expiredKeys: string[] = []; |
|||
|
|||
for (const [key, value] of this.stateMap.entries()) { |
|||
if (now - value.timestamp > this.STATE_EXPIRY_MS) { |
|||
expiredKeys.push(key); |
|||
} |
|||
} |
|||
|
|||
for (const key of expiredKeys) { |
|||
this.stateMap.delete(key); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Generate a cryptographically secure random handle |
|||
*/ |
|||
private generateHandle() { |
|||
return ( |
|||
Math.random().toString(36).substring(2, 15) + |
|||
Math.random().toString(36).substring(2, 15) + |
|||
Date.now().toString(36) |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,69 @@ |
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { PassportStrategy } from '@nestjs/passport'; |
|||
import { Provider } from '@prisma/client'; |
|||
import { Request } from 'express'; |
|||
import { Strategy, type StrategyOptions } from 'passport-openidconnect'; |
|||
|
|||
import { AuthService } from './auth.service'; |
|||
import { |
|||
OidcContext, |
|||
OidcIdToken, |
|||
OidcParams, |
|||
OidcProfile |
|||
} 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, |
|||
options: StrategyOptions |
|||
) { |
|||
super({ |
|||
...options, |
|||
passReqToCallback: true, |
|||
store: OidcStrategy.stateStore |
|||
}); |
|||
} |
|||
|
|||
public async validate( |
|||
_request: Request, |
|||
issuer: string, |
|||
profile: OidcProfile, |
|||
context: OidcContext, |
|||
idToken: OidcIdToken, |
|||
_accessToken: string, |
|||
_refreshToken: string, |
|||
params: OidcParams |
|||
) { |
|||
try { |
|||
const thirdPartyId = |
|||
profile?.id ?? |
|||
profile?.sub ?? |
|||
idToken?.sub ?? |
|||
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}`, |
|||
'OidcStrategy' |
|||
); |
|||
|
|||
throw new Error('Missing subject identifier in OIDC response'); |
|||
} |
|||
|
|||
return { jwt }; |
|||
} catch (error) { |
|||
Logger.error(error, 'OidcStrategy'); |
|||
throw error; |
|||
} |
|||
} |
|||
} |
|||
@ -1,6 +1,7 @@ |
|||
export interface LoginWithAccessTokenDialogParams { |
|||
accessToken: string; |
|||
hasPermissionToUseAuthGoogle: boolean; |
|||
hasPermissionToUseAuthOidc: boolean; |
|||
hasPermissionToUseAuthToken: boolean; |
|||
title: string; |
|||
} |
|||
|
|||
@ -0,0 +1,2 @@ |
|||
-- AlterEnum |
|||
ALTER TYPE "Provider" ADD VALUE 'OIDC'; |
|||
Loading…
Reference in new issue