237 lines
9.0 KiB
TypeScript
237 lines
9.0 KiB
TypeScript
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<string>('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<string>('AUTHENTIK_CLIENT_ID');
|
|
const clientSecret = this.config.get<string>('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<string> {
|
|
const client = await this.getClient();
|
|
const redirectUri = this.config.get<string>('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<string>('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<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: 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<string, any> | 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<string>('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');
|
|
}
|
|
}
|
|
}
|