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'; 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'; // dynamic require to avoid TypeScript typing issues with installed openid-client // eslint-disable-next-line @typescript-eslint/no-var-requires const OpenIDClient = require('openid-client'); import crypto from 'crypto'; const logger = new Logger('AuthService'); @Injectable() export class AuthService { constructor( private readonly config: ConfigService, private readonly jwtService: JwtService, private readonly prisma: PrismaService, @Inject(IUser) private readonly userRepository: IUser, private readonly syncIdentityHandler: SyncIdentityHandler, private readonly pkceStore: RedisPkceStore, private readonly refreshStore: InMemoryRefreshStore, ) {} private issuer: any | null = null; private client: any | null = null; private async getIssuer() { if (this.issuer) return this.issuer; const issuerUrl = this.config.get('AUTHENTIK_ISSUER'); if (!issuerUrl) throw new Error('AUTHENTIK_ISSUER not configured'); this.issuer = await OpenIDClient.Issuer.discover(issuerUrl); return this.issuer; } private async getClient() { if (this.client) return this.client; const issuer = await this.getIssuer(); const clientId = this.config.get('AUTHENTIK_CLIENT_ID'); const clientSecret = this.config.get('AUTHENTIK_CLIENT_SECRET'); if (!clientId) throw new Error('AUTHENTIK_CLIENT_ID not configured'); this.client = new issuer.Client({ client_id: clientId, client_secret: clientSecret }); return this.client; } async createAuthorizationRedirect(returnTo?: string): Promise { const client = await this.getClient(); const redirectUri = this.config.get('AUTHENTIK_REDIRECT_URI'); if (!redirectUri) { throw new Error('AUTHENTIK_REDIRECT_URI is not configured'); } const state = crypto.randomUUID(); const code_verifier = OpenIDClient.generators.codeVerifier(); const code_challenge = await OpenIDClient.generators.codeChallenge(code_verifier); const nonce = OpenIDClient.generators.nonce(); // save PKCE session keyed by state await this.pkceStore.save(state, { code_verifier, nonce, returnTo }, 300); const url = client.authorizationUrl({ redirect_uri: redirectUri, scope: this.config.get('AUTHENTIK_DEFAULT_SCOPE') || 'openid email profile', response_type: 'code', code_challenge, code_challenge_method: 'S256', state, nonce, }); return url; } 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'); 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: any; 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); // verify id_token and get claims const claims = tokenSet.claims(); // fetch userinfo if available let userInfo: Record | null = null; try { if ((tokenSet as any).access_token && typeof client.userinfo === 'function') { userInfo = await client.userinfo((tokenSet as any).access_token); } } catch (e) { // ignore userinfo errors } const identity = { 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; // Sync identity to local user (create if needed) const domainUser = await this.syncIdentityHandler.execute(identity as any); // ensure active/not deleted if (!domainUser.isActive) throw new UnauthorizedException('User is not active'); if (domainUser.deletedAt) throw new UnauthorizedException('User is deleted'); // create internal JWT const jwtPayload = { sub: domainUser.id, preferred_username: domainUser.username, email: domainUser.email, }; const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600); const access = this.jwtService.sign(jwtPayload, { expiresIn }); logger.debug(`Created internal access token for user=${domainUser.id} expiresIn=${expiresIn}`); // create internal refresh token 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); logger.debug(`Stored refresh token for user=${domainUser.id} ttl=${refreshTtl}`); return { accessToken: access, refreshToken, expiresIn, refreshTtl, user: { id: domainUser.id, username: domainUser.username, email: domainUser.email, roles: domainUser.roles || [], }, returnTo: pkce.returnTo, }; } async refresh(refreshToken?: string) { if (!refreshToken) { logger.debug('Refresh called without refresh token'); throw new UnauthorizedException('Missing refresh token'); } const data = await this.refreshStore.get(refreshToken); if (!data) { logger.debug(`Refresh token not found or expired: ${refreshToken}`); throw new UnauthorizedException('Invalid refresh token'); } logger.debug(`Refresh token validated for userId=${data.userId}`); const userId = data.userId; // load user const domainUser = await this.userRepository.getById(userId); if (!domainUser) throw new UnauthorizedException('User not found'); if (!domainUser.isActive) throw new UnauthorizedException('User is not active'); // rotate refresh token await this.refreshStore.del(refreshToken); const newRefresh = crypto.randomUUID(); const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); await this.refreshStore.set(newRefresh, { userId }, refreshTtl); logger.debug(`Rotated refresh token for userId=${userId} newRefresh=${newRefresh} ttl=${refreshTtl}`); const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600); const access = this.jwtService.sign({ sub: domainUser.id, preferred_username: domainUser.username, email: domainUser.email }, { expiresIn }); logger.debug(`Issued new access token for user=${userId} expiresIn=${expiresIn}`); return { accessToken: access, refreshToken: newRefresh, expiresIn, refreshTtl }; } async logout(refreshToken?: string) { if (refreshToken) { await this.refreshStore.del(refreshToken); } const issuer = await this.getIssuer(); const endSession = issuer.metadata.end_session_endpoint; const postLogout = this.config.get('AUTHENTIK_POST_LOGOUT_REDIRECT') || '/'; if (endSession) { // Redirect to identity provider logout const url = new URL(endSession); if (postLogout) url.searchParams.set('post_logout_redirect_uri', postLogout); return url.toString(); } return postLogout; } async me(token?: string) { if (!token) { logger.debug('Me called without token'); throw new UnauthorizedException('Missing token'); } try { const payload: any = this.jwtService.verify(token); logger.debug(`Token verified successfully. payload.sub=${payload.sub}`); const user = await this.userRepository.getById(payload.sub); if (!user) throw new UnauthorizedException('User not found'); return { id: user.id, username: user.username, email: user.email, roles: user.roles || [] }; } catch (e) { logger.debug(`Token verification failed: ${(e as Error).message}`); throw new UnauthorizedException('Invalid token'); } } }