From 737c4fa5d1dbc448a4064f47c85c28c7647f0763 Mon Sep 17 00:00:00 2001 From: Rayyan <60314224+RayyanHermanto@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:45:01 +0700 Subject: [PATCH] fix --- src/app.module.ts | 16 ++++--- src/modules/auth/auth.service.ts | 77 +++++++++++++++++++------------- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index d91512d..8e0b09d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,14 +15,20 @@ import { ApplicationModule } from './modules/application/application.module'; isGlobal: true, // Load .env files depending on NODE_ENV. Default to development .env envFilePath: process.env.NODE_ENV === 'production' ? '.env.production' : '.env', - // Basic validation: ensure expected frontend URLs are present + // Basic validation: ensure expected frontend URLs and OIDC settings are present validate: (env: Record) => { const errors: string[] = []; + const nodeEnv = env.NODE_ENV || process.env.NODE_ENV || 'development'; + if (!env.FRONTEND_URL) errors.push('FRONTEND_URL is not set'); - if (!env.PRODUCTION_FRONTEND_URL) { - // production frontend URL is recommended but not mandatory for local development - if (process.env.NODE_ENV === 'production') errors.push('PRODUCTION_FRONTEND_URL is not set'); - } + if (nodeEnv === 'production' && !env.PRODUCTION_FRONTEND_URL) errors.push('PRODUCTION_FRONTEND_URL is not set'); + + // OIDC required settings + if (!env.AUTHENTIK_ISSUER) errors.push('AUTHENTIK_ISSUER is not set'); + if (!env.AUTHENTIK_CLIENT_ID) errors.push('AUTHENTIK_CLIENT_ID is not set'); + if (!env.AUTHENTIK_CLIENT_SECRET) errors.push('AUTHENTIK_CLIENT_SECRET is not set'); + if (!env.AUTHENTIK_REDIRECT_URI) errors.push('AUTHENTIK_REDIRECT_URI is not set'); + if (errors.length > 0) throw new Error('Environment validation error: ' + errors.join('; ')); return env; }, diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 9a8c2b6..717d289 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, UnauthorizedException, Inject } from '@nestjs/common'; +import { Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { SyncIdentityHandler } from '../identity/application/handlers/user/sync-identity.handler'; @@ -6,8 +6,10 @@ import { IUser } from '../identity/domain/repositories/user.interface'; import { PrismaService } from '../../shared/prisma.service'; import { RedisPkceStore } from './pkce/redis-pkce.store'; import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store'; +import { Issuer, generators, Client, TokenSet } from 'openid-client'; +import crypto from 'crypto'; -const { Issuer, generators } = require('openid-client'); +const logger = new Logger('AuthService'); @Injectable() export class AuthService { @@ -21,8 +23,8 @@ export class AuthService { private readonly refreshStore: InMemoryRefreshStore, ) {} - private issuer: any = null; - private client: any = null; + private issuer: Issuer | null = null; + private client: Client | null = null; private async getIssuer() { if (this.issuer) return this.issuer; @@ -42,24 +44,20 @@ export class AuthService { return this.client; } - async createAuthorizationRedirect(returnTo?: string) { + async createAuthorizationRedirect(returnTo?: string): Promise { const client = await this.getClient(); - const redirectUri = - this.config.get('AUTHENTIK_REDIRECT_URI') ?? - this.config.get('AUTH_CALLBACK_URL') ?? - `${this.config.get('APP_URL')}/auth/callback`; + const redirectUri = this.config.get('AUTHENTIK_REDIRECT_URI'); if (!redirectUri) { - throw new Error( - 'No redirect URI configured. Set AUTHENTIK_REDIRECT_URI or AUTH_CALLBACK_URL.', - ); + throw new Error('AUTHENTIK_REDIRECT_URI is not configured'); } - - const state = require('crypto').randomUUID(); + + const state = crypto.randomUUID(); const code_verifier = generators.codeVerifier(); const code_challenge = await generators.codeChallenge(code_verifier); const nonce = generators.nonce(); + // save PKCE session keyed by state await this.pkceStore.save(state, { code_verifier, nonce, returnTo }, 300); const url = client.authorizationUrl({ @@ -77,34 +75,51 @@ export class AuthService { async handleCallback(code: string, state: string) { const client = await this.getClient(); + + // retrieve PKCE session using state provided by the IdP const pkce = await this.pkceStore.get(state); if (!pkce) throw new UnauthorizedException('Invalid or expired state'); - // remove one-time state + const redirectUri = this.config.get('AUTHENTIK_REDIRECT_URI'); + if (!redirectUri) throw new Error('AUTHENTIK_REDIRECT_URI is not configured'); + + // Exchange code for tokens. Provide explicit checks: state, nonce and code_verifier. + let tokenSet: TokenSet; + try { + tokenSet = await client.callback( + redirectUri, + { code, state }, + { state, nonce: pkce.nonce, code_verifier: pkce.code_verifier }, + ); + } catch (err) { + logger.debug('Authorization code exchange failed: ' + (err as Error).message); + // remove PKCE entry to avoid replay + await this.pkceStore.remove(state); + throw new UnauthorizedException('Authorization code exchange failed'); + } + + // remove PKCE entry after successful exchange await this.pkceStore.remove(state); - const redirectUri = this.config.get('AUTH_CALLBACK_URL') || `${this.config.get('APP_URL') || ''}/auth/callback`; - - // exchange code - const tokenSet: any = await client.callback(redirectUri, { code, state }, { code_verifier: pkce.code_verifier, nonce: pkce.nonce }); - // verify id_token and get claims const claims = tokenSet.claims(); // fetch userinfo if available - let userInfo = null; + let userInfo: Record | null = null; try { - if (tokenSet.access_token && client.userinfo) { - userInfo = await client.userinfo(tokenSet.access_token); + if ((tokenSet as any).access_token && typeof client.userinfo === 'function') { + userInfo = await client.userinfo((tokenSet as any).access_token); } } catch (e) { - // ignore + // ignore userinfo errors } const identity = { - sub: userInfo?.sub || claims.sub || null, - preferred_username: userInfo?.preferred_username || userInfo?.username || userInfo?.email || claims.preferred_username || claims.email, - email: userInfo?.email || claims.email, + sub: (userInfo && (userInfo as any).sub) || (claims as any).sub || null, + preferred_username: + (userInfo && ((userInfo as any).preferred_username || (userInfo as any).username || (userInfo as any).email)) || + (claims as any).preferred_username || (claims as any).email, + email: (userInfo && (userInfo as any).email) || (claims as any).email, raw: { tokenSet, userInfo, claims }, } as any; @@ -120,13 +135,13 @@ export class AuthService { sub: domainUser.id, preferred_username: domainUser.username, email: domainUser.email, - } as any; + }; const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600); const access = this.jwtService.sign(jwtPayload, { expiresIn }); // create internal refresh token - const refreshToken = require('crypto').randomUUID(); + const refreshToken = crypto.randomUUID(); const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); // default 30 days await this.refreshStore.set(refreshToken, { userId: domainUser.id }, refreshTtl); @@ -159,7 +174,7 @@ export class AuthService { // rotate refresh token await this.refreshStore.del(refreshToken); - const newRefresh = require('crypto').randomUUID(); + const newRefresh = crypto.randomUUID(); const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); await this.refreshStore.set(newRefresh, { userId }, refreshTtl); @@ -176,7 +191,7 @@ export class AuthService { const issuer = await this.getIssuer(); const endSession = issuer.metadata.end_session_endpoint; - const postLogout = this.config.get('APP_URL') || '/'; + const postLogout = this.config.get('AUTHENTIK_POST_LOGOUT_REDIRECT') || '/'; if (endSession) { // Redirect to identity provider logout