+11
-5
@@ -15,14 +15,20 @@ import { ApplicationModule } from './modules/application/application.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
// Load .env files depending on NODE_ENV. Default to development .env
|
// Load .env files depending on NODE_ENV. Default to development .env
|
||||||
envFilePath: process.env.NODE_ENV === 'production' ? '.env.production' : '.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<string, any>) => {
|
validate: (env: Record<string, any>) => {
|
||||||
const errors: string[] = [];
|
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.FRONTEND_URL) errors.push('FRONTEND_URL is not set');
|
||||||
if (!env.PRODUCTION_FRONTEND_URL) {
|
if (nodeEnv === 'production' && !env.PRODUCTION_FRONTEND_URL) errors.push('PRODUCTION_FRONTEND_URL is not set');
|
||||||
// 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');
|
// 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('; '));
|
if (errors.length > 0) throw new Error('Environment validation error: ' + errors.join('; '));
|
||||||
return env;
|
return env;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { SyncIdentityHandler } from '../identity/application/handlers/user/sync-identity.handler';
|
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 { PrismaService } from '../../shared/prisma.service';
|
||||||
import { RedisPkceStore } from './pkce/redis-pkce.store';
|
import { RedisPkceStore } from './pkce/redis-pkce.store';
|
||||||
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -21,8 +23,8 @@ export class AuthService {
|
|||||||
private readonly refreshStore: InMemoryRefreshStore,
|
private readonly refreshStore: InMemoryRefreshStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private issuer: any = null;
|
private issuer: Issuer<Client> | null = null;
|
||||||
private client: any = null;
|
private client: Client | null = null;
|
||||||
|
|
||||||
private async getIssuer() {
|
private async getIssuer() {
|
||||||
if (this.issuer) return this.issuer;
|
if (this.issuer) return this.issuer;
|
||||||
@@ -42,24 +44,20 @@ export class AuthService {
|
|||||||
return this.client;
|
return this.client;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAuthorizationRedirect(returnTo?: string) {
|
async createAuthorizationRedirect(returnTo?: string): Promise<string> {
|
||||||
const client = await this.getClient();
|
const client = await this.getClient();
|
||||||
const redirectUri =
|
const redirectUri = this.config.get<string>('AUTHENTIK_REDIRECT_URI');
|
||||||
this.config.get<string>('AUTHENTIK_REDIRECT_URI') ??
|
|
||||||
this.config.get<string>('AUTH_CALLBACK_URL') ??
|
|
||||||
`${this.config.get<string>('APP_URL')}/auth/callback`;
|
|
||||||
|
|
||||||
if (!redirectUri) {
|
if (!redirectUri) {
|
||||||
throw new Error(
|
throw new Error('AUTHENTIK_REDIRECT_URI is not configured');
|
||||||
'No redirect URI configured. Set AUTHENTIK_REDIRECT_URI or AUTH_CALLBACK_URL.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = require('crypto').randomUUID();
|
const state = crypto.randomUUID();
|
||||||
const code_verifier = generators.codeVerifier();
|
const code_verifier = generators.codeVerifier();
|
||||||
const code_challenge = await generators.codeChallenge(code_verifier);
|
const code_challenge = await generators.codeChallenge(code_verifier);
|
||||||
const nonce = generators.nonce();
|
const nonce = generators.nonce();
|
||||||
|
|
||||||
|
// save PKCE session keyed by state
|
||||||
await this.pkceStore.save(state, { code_verifier, nonce, returnTo }, 300);
|
await this.pkceStore.save(state, { code_verifier, nonce, returnTo }, 300);
|
||||||
|
|
||||||
const url = client.authorizationUrl({
|
const url = client.authorizationUrl({
|
||||||
@@ -77,34 +75,51 @@ export class AuthService {
|
|||||||
|
|
||||||
async handleCallback(code: string, state: string) {
|
async handleCallback(code: string, state: string) {
|
||||||
const client = await this.getClient();
|
const client = await this.getClient();
|
||||||
|
|
||||||
|
// retrieve PKCE session using state provided by the IdP
|
||||||
const pkce = await this.pkceStore.get(state);
|
const pkce = await this.pkceStore.get(state);
|
||||||
if (!pkce) throw new UnauthorizedException('Invalid or expired state');
|
if (!pkce) throw new UnauthorizedException('Invalid or expired state');
|
||||||
|
|
||||||
// remove one-time state
|
const redirectUri = this.config.get<string>('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);
|
await this.pkceStore.remove(state);
|
||||||
|
throw new UnauthorizedException('Authorization code exchange failed');
|
||||||
|
}
|
||||||
|
|
||||||
const redirectUri = this.config.get<string>('AUTH_CALLBACK_URL') || `${this.config.get('APP_URL') || ''}/auth/callback`;
|
// remove PKCE entry after successful exchange
|
||||||
|
await this.pkceStore.remove(state);
|
||||||
// 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
|
// verify id_token and get claims
|
||||||
const claims = tokenSet.claims();
|
const claims = tokenSet.claims();
|
||||||
|
|
||||||
// fetch userinfo if available
|
// fetch userinfo if available
|
||||||
let userInfo = null;
|
let userInfo: Record<string, any> | null = null;
|
||||||
try {
|
try {
|
||||||
if (tokenSet.access_token && client.userinfo) {
|
if ((tokenSet as any).access_token && typeof client.userinfo === 'function') {
|
||||||
userInfo = await client.userinfo(tokenSet.access_token);
|
userInfo = await client.userinfo((tokenSet as any).access_token);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore userinfo errors
|
||||||
}
|
}
|
||||||
|
|
||||||
const identity = {
|
const identity = {
|
||||||
sub: userInfo?.sub || claims.sub || null,
|
sub: (userInfo && (userInfo as any).sub) || (claims as any).sub || null,
|
||||||
preferred_username: userInfo?.preferred_username || userInfo?.username || userInfo?.email || claims.preferred_username || claims.email,
|
preferred_username:
|
||||||
email: userInfo?.email || claims.email,
|
(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 },
|
raw: { tokenSet, userInfo, claims },
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
@@ -120,13 +135,13 @@ export class AuthService {
|
|||||||
sub: domainUser.id,
|
sub: domainUser.id,
|
||||||
preferred_username: domainUser.username,
|
preferred_username: domainUser.username,
|
||||||
email: domainUser.email,
|
email: domainUser.email,
|
||||||
} as any;
|
};
|
||||||
|
|
||||||
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
|
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
|
||||||
const access = this.jwtService.sign(jwtPayload, { expiresIn });
|
const access = this.jwtService.sign(jwtPayload, { expiresIn });
|
||||||
|
|
||||||
// create internal refresh token
|
// 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
|
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);
|
await this.refreshStore.set(refreshToken, { userId: domainUser.id }, refreshTtl);
|
||||||
@@ -159,7 +174,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// rotate refresh token
|
// rotate refresh token
|
||||||
await this.refreshStore.del(refreshToken);
|
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);
|
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600);
|
||||||
await this.refreshStore.set(newRefresh, { userId }, refreshTtl);
|
await this.refreshStore.set(newRefresh, { userId }, refreshTtl);
|
||||||
|
|
||||||
@@ -176,7 +191,7 @@ export class AuthService {
|
|||||||
|
|
||||||
const issuer = await this.getIssuer();
|
const issuer = await this.getIssuer();
|
||||||
const endSession = issuer.metadata.end_session_endpoint;
|
const endSession = issuer.metadata.end_session_endpoint;
|
||||||
const postLogout = this.config.get<string>('APP_URL') || '/';
|
const postLogout = this.config.get<string>('AUTHENTIK_POST_LOGOUT_REDIRECT') || '/';
|
||||||
|
|
||||||
if (endSession) {
|
if (endSession) {
|
||||||
// Redirect to identity provider logout
|
// Redirect to identity provider logout
|
||||||
|
|||||||
Reference in New Issue
Block a user