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 { |
export interface LoginWithAccessTokenDialogParams { |
||||
accessToken: string; |
accessToken: string; |
||||
hasPermissionToUseAuthGoogle: boolean; |
hasPermissionToUseAuthGoogle: boolean; |
||||
|
hasPermissionToUseAuthOidc: boolean; |
||||
hasPermissionToUseAuthToken: boolean; |
hasPermissionToUseAuthToken: boolean; |
||||
title: string; |
title: string; |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,2 @@ |
|||||
|
-- AlterEnum |
||||
|
ALTER TYPE "Provider" ADD VALUE 'OIDC'; |
||||
Loading…
Reference in new issue