import { Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' interface AuthentikGroup { pk: string name: string attributes?: Record } // Thin wrapper around the Authentik API for the operations the provisioning // service needs. We never expose raw Authentik errors to API callers — they // surface as provisioningErrors.authentik strings. @Injectable() export class AuthentikClient { private readonly logger = new Logger(AuthentikClient.name) private readonly base: string private readonly token: string constructor(config: ConfigService) { this.base = config.getOrThrow('AUTHENTIK_API_URL') this.token = config.getOrThrow('AUTHENTIK_API_TOKEN') } private async request(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${this.base}${path}`, { ...init, headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json', Accept: 'application/json', ...(init.headers ?? {}), }, }) if (!res.ok) { const body = await res.text().catch(() => '') throw new Error(`Authentik ${init.method ?? 'GET'} ${path} → ${res.status}: ${body.slice(0, 200)}`) } // 204 No Content (and other empty-body successes) crash res.json(). // Endpoints like /core/groups/:id/add_user/ return 204; callers with a // void return type don't care about the payload, so hand back undefined. if (res.status === 204 || res.headers.get('content-length') === '0') { return undefined as T } return (await res.json()) as T } // Idempotent: returns existing group if name is taken, creates otherwise. async ensureGroup(slug: string, attributes: Record = {}): Promise { const search = await this.request<{ results: AuthentikGroup[] }>( `/core/groups/?name=${encodeURIComponent(slug)}`, ) if (search.results.length > 0) { this.logger.log(`Authentik group "${slug}" already exists (pk=${search.results[0].pk})`) return search.results[0] } const created = await this.request('/core/groups/', { method: 'POST', body: JSON.stringify({ name: slug, attributes: { role: 'tenant', slug, ...attributes }, }), }) this.logger.log(`Created Authentik group "${slug}" (pk=${created.pk})`) return created } async deleteGroup(groupId: string): Promise { const res = await fetch(`${this.base}/core/groups/${groupId}/`, { method: 'DELETE', headers: { Authorization: `Bearer ${this.token}` }, }) if (!res.ok && res.status !== 404) { const body = await res.text().catch(() => '') throw new Error(`Authentik DELETE group ${groupId} → ${res.status}: ${body.slice(0, 200)}`) } this.logger.log(`Deleted Authentik group ${groupId}`) } // Pull a window of Authentik events. Used by the audit ingest worker. // `since` filters by created timestamp (strict greater-than); pagination is // forward-only via `page`. Authentik's default page size is 100. async listEvents( since?: Date, page = 1, pageSize = 100, ): Promise { const params = new URLSearchParams({ ordering: 'created', page: String(page), page_size: String(pageSize), }) if (since) params.set('created__gt', since.toISOString()) return this.request(`/events/events/?${params}`) } // Look up a user by email. Returns undefined if not found. Used by the // invite flow so we can give a friendly conflict error instead of letting // Authentik 400. async findUserByEmail(email: string): Promise { const res = await this.request<{ results: AuthentikUser[] }>( `/core/users/?email=${encodeURIComponent(email)}`, ) return res.results[0] } // Create a user. Authentik's `uid` field becomes the JWT `sub` claim once // they log in — this is the same value our User.authentikSubjectId is keyed // on. We set type='internal' (real human user, not service account) and // is_active=true so the recovery link they receive lets them set a password. async createUser(input: { username: string email: string name: string attributes?: Record groupPks?: string[] }): Promise { const created = await this.request('/core/users/', { method: 'POST', body: JSON.stringify({ username: input.username, email: input.email, name: input.name, type: 'internal', is_active: true, path: 'users', groups: input.groupPks ?? [], attributes: input.attributes ?? {}, }), }) this.logger.log(`Created Authentik user ${input.email} (pk=${created.pk}, uid=${created.uid})`) return created } // Add an existing user to a group by ID. Idempotent — adding twice is a // no-op on Authentik's side. async addUserToGroup(userPk: number, groupId: string): Promise { await this.request(`/core/groups/${groupId}/add_user/`, { method: 'POST', body: JSON.stringify({ pk: userPk }), }) this.logger.log(`Added user ${userPk} to Authentik group ${groupId}`) } // Generate a single-use recovery link the new user clicks to set their // password + enroll MFA. Requires a "recovery flow" configured on the // Authentik brand — if not set, returns undefined so callers can fall // back to setInitialPassword. async recoveryLink(userPk: number): Promise { try { const res = await this.request<{ link: string }>( `/core/users/${userPk}/recovery/`, { method: 'POST' }, ) return res.link } catch (err) { // Authentik returns 400 with "No recovery flow set." when the brand has // no recovery flow wired. Treat as soft-fail; caller fallback path // sets an initial password instead. if (err instanceof Error && err.message.includes('recovery flow')) { this.logger.warn('Authentik recovery link unavailable — no recovery flow configured') return undefined } throw err } } // Direct set_password — used when no recovery flow is configured. The // operator hands the temp password to the new user out-of-band; the user // changes it after first login via Authentik's password-change flow. // Authentik returns 204 No Content (empty body) on success, so we skip // request()'s JSON parser and call fetch directly. async setInitialPassword(userPk: number, password: string): Promise { const res = await fetch(`${this.base}/core/users/${userPk}/set_password/`, { method: 'POST', headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ password }), }) if (!res.ok) { const body = await res.text().catch(() => '') throw new Error(`Authentik set_password ${userPk} → ${res.status}: ${body.slice(0, 200)}`) } this.logger.log(`Set initial password for Authentik user ${userPk}`) } // Mark the user's password as expired so Authentik forces a change at next // login. Used by the temp-password fallback path so a stolen temp password // can't outlive the first real session. The recovery-link path doesn't // need this — clicking the link runs through the recovery flow which sets // a fresh password anyway. async markPasswordExpired(userPk: number): Promise { // Authentik stores per-user policy state under `attributes`. PATCH merges // top-level keys but replaces nested objects, so we have to read-modify- // write to avoid clobbering other attributes. const user = await this.request }>( `/core/users/${userPk}/`, ) const attrs = { ...(user.attributes ?? {}), // Authentik's expiry check looks at `passwordExpired` (camelCase). passwordExpired: true, passwordExpiredAt: new Date().toISOString(), } await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify({ attributes: attrs }), }) this.logger.log(`Marked password as expired for Authentik user ${userPk}`) } // ── Recovery flow bootstrap ──────────────────────────────────────────── // Default Authentik installs don't ship a recovery flow — operators have to // either create one in the admin UI or have us provision it. The methods // below make the bootstrap idempotent: re-running on an already-configured // Authentik is a no-op. async findRecoveryFlow(): Promise { const res = await this.request<{ results: AuthentikFlow[] }>( `/flows/instances/?designation=recovery`, ) return res.results[0] } async getDefaultBrand(): Promise { const res = await this.request<{ results: AuthentikBrand[] }>('/core/brands/') return res.results.find((b) => b._default) ?? res.results[0] } async findStageByName(name: string): Promise { const res = await this.request<{ results: AuthentikStage[] }>( `/stages/all/?name=${encodeURIComponent(name)}`, ) return res.results.find((s) => s.name === name) } async createFlow(input: { name: string slug: string title: string designation: 'recovery' | 'authentication' | 'authorization' | 'enrollment' | 'invalidation' | 'stage_configuration' | 'unenrollment' }): Promise { return this.request('/flows/instances/', { method: 'POST', body: JSON.stringify({ name: input.name, slug: input.slug, title: input.title, designation: input.designation, // 'none' = no extra auth required to start the flow (the recovery // token in the link is the auth). 'require_authenticated' would // break the link-click path. authentication: 'none', // No background; the consent screen is up to the brand. background: '', }), }) } async bindStageToFlow(input: { target: string; stage: string; order: number }): Promise { await this.request('/flows/bindings/', { method: 'POST', body: JSON.stringify({ target: input.target, stage: input.stage, order: input.order, evaluate_on_plan: true, re_evaluate_policies: false, }), }) } async setBrandRecoveryFlow(brandUuid: string, flowUuid: string): Promise { await this.request(`/core/brands/${brandUuid}/`, { method: 'PATCH', body: JSON.stringify({ flow_recovery: flowUuid }), }) this.logger.log(`Set brand ${brandUuid} flow_recovery → ${flowUuid}`) } } export interface AuthentikFlow { pk: string slug: string name: string title: string designation: string } export interface AuthentikBrand { brand_uuid: string domain: string _default?: boolean flow_recovery?: string | null } export interface AuthentikStage { pk: string name: string component: string } export interface AuthentikUser { pk: number uid: string // becomes JWT `sub` on first login username: string email: string name?: string is_active: boolean groups?: string[] } // Shape returned by /events/events/. Only the fields we read; Authentik // includes a number of others (tenant, brand) we don't need. export interface AuthentikEvent { pk: string action: string app?: string user?: { pk?: number; username?: string; name?: string; email?: string } context?: Record client_ip?: string created: string } export interface AuthentikEventPage { pagination: { next: number; previous: number; count: number; current: number; total_pages: number; start_index: number; end_index: number } results: AuthentikEvent[] }