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') } // Public Authentik origin (no /api/v3) — for building user-facing OIDC URLs // like the per-app issuer / .well-known discovery document. get publicBase(): string { return this.base.replace(/\/api\/v3\/?$/, '') } 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 } // Fully delete a user from Authentik (used when a member is removed from their // last tenant). 404 is tolerated so a re-run after a partial removal is safe. async deleteUser(userPk: number): Promise { const res = await fetch(`${this.base}/core/users/${userPk}/`, { method: 'DELETE', headers: { Authorization: `Bearer ${this.token}` }, }) if (!res.ok && res.status !== 404) { const body = await res.text().catch(() => '') throw new Error(`Authentik DELETE user ${userPk} → ${res.status}: ${body.slice(0, 200)}`) } this.logger.log(`Deleted Authentik user ${userPk}`) } // Enable / disable a user. is_active=false blocks all sign-in (portal, SSO, // and OCIS-via-SSO) without deleting anything — the basis of suspend/resume. async setUserActive(userPk: number, active: boolean): Promise { await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify({ is_active: active }), }) this.logger.log(`Set Authentik user ${userPk} is_active=${active}`) } // Patch a user's identity / profile fields. `username` + `email` are kept // aligned by callers (our convention). `attributesMerge`, when given, is // read-modify-written so we don't clobber unrelated attributes — Authentik // PATCH replaces nested objects wholesale, so a naive `attributes: {...}` // would wipe e.g. the passwordExpired flag. No-op if nothing to change. async updateUser( userPk: number, patch: { username?: string; email?: string; name?: string; attributesMerge?: Record }, ): Promise { const body: Record = {} if (patch.username !== undefined) body.username = patch.username if (patch.email !== undefined) body.email = patch.email if (patch.name !== undefined) body.name = patch.name if (patch.attributesMerge && Object.keys(patch.attributesMerge).length > 0) { const user = await this.request }>( `/core/users/${userPk}/`, ) body.attributes = { ...(user.attributes ?? {}), ...patch.attributesMerge } } if (Object.keys(body).length === 0) return await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify(body) }) this.logger.log(`Updated Authentik user ${userPk} (${Object.keys(body).join(', ')})`) } // Force-logout: terminate the user's active sessions so they must sign in // again. Returns how many were terminated. We pass the `?user=` filter AND // re-filter client-side on the session's `user` pk — Authentik's endpoint // silently ignores an unknown query filter, which would otherwise return (and // delete) EVERY user's session. The client-side filter makes that impossible. async terminateSessions(userPk: number): Promise { const res = await this.request<{ results: Array<{ uuid: string; user: number }> }>( `/core/authenticated_sessions/?user=${userPk}`, ) const sessions = (res.results ?? []).filter((s) => s.user === userPk) await Promise.all( sessions.map((s) => fetch(`${this.base}/core/authenticated_sessions/${s.uuid}/`, { method: 'DELETE', headers: { Authorization: `Bearer ${this.token}` }, }).catch(() => {}), ), ) this.logger.log(`Terminated ${sessions.length} Authentik session(s) for user ${userPk}`) return sessions.length } 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}`) } // Remove a user from a group by ID. Authentik 204s even if the user wasn't // a member, so this is effectively idempotent. async removeUserFromGroup(userPk: number, groupId: string): Promise { await this.request(`/core/groups/${groupId}/remove_user/`, { method: 'POST', body: JSON.stringify({ pk: userPk }), }) this.logger.log(`Removed user ${userPk} from Authentik group ${groupId}`) } // Count a user's configured authenticators (TOTP / WebAuthn / static). Used // to surface an "MFA enrolled" badge on the partner team list — callers treat // a count > 0 as enrolled. Authentik has no single "all devices" admin route; // it exposes one per device type, so we query the common three and sum. Each // returns a paginated { results } envelope. async countAuthenticators(userPk: number): Promise { const types = ['totp', 'webauthn', 'static'] const counts = await Promise.all( types.map(async (t) => { try { const res = await this.request<{ results?: unknown[] }>( `/authenticators/admin/${t}/?user=${userPk}`, ) return Array.isArray(res?.results) ? res.results.length : 0 } catch { // A device type not enabled on this Authentik instance returns 404 — // don't let it zero out the types that do resolve. return 0 } }), ) return counts.reduce((a, b) => a + b, 0) } // 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}`) } // ── SSO apps: customer registers external apps using Dezky as the IdP ───── // We create an OAuth2/OIDC Provider + Application in Authentik and bind the // tenant's group to the application so only that workspace's members can use // it. Provider pk is a number; Application pk is a uuid (slug is the human id). // Find a flow pk by designation, preferring a slug substring (e.g. the // explicit-consent authorization flow) and falling back to the first match. async findFlowPk(designation: string, preferSlugIncludes?: string): Promise { const res = await this.request<{ results: AuthentikFlow[] }>( `/flows/instances/?designation=${encodeURIComponent(designation)}`, ) if (!res.results.length) return undefined if (preferSlugIncludes) { const pref = res.results.find((f) => f.slug.includes(preferSlugIncludes)) if (pref) return pref.pk } return res.results[0].pk } async findSigningKeyPk(): Promise { const res = await this.request<{ results: Array<{ pk: string; private_key_available?: boolean }> }>( `/crypto/certificatekeypairs/?has_key=true`, ) return res.results.find((k) => k.private_key_available)?.pk ?? res.results[0]?.pk } // The three standard OIDC scope mappings, resolved by Authentik's stable // `managed` identifiers (pks differ per instance). async findOidcScopeMappingPks(): Promise { const res = await this.request<{ results: Array<{ pk: string; managed?: string }> }>( `/propertymappings/provider/scope/`, ) const wanted = new Set([ 'goauthentik.io/providers/oauth2/scope-openid', 'goauthentik.io/providers/oauth2/scope-email', 'goauthentik.io/providers/oauth2/scope-profile', ]) return res.results.filter((m) => m.managed && wanted.has(m.managed)).map((m) => m.pk) } async createOAuth2Provider(input: { name: string redirectUris: string[] clientType?: 'confidential' | 'public' }): Promise<{ pk: number; clientId: string; clientSecret: string }> { const [authorizationFlow, invalidationFlow, signingKey, scopeMappings] = await Promise.all([ this.findFlowPk('authorization', 'explicit-consent'), this.findFlowPk('invalidation', 'provider-invalidation'), this.findSigningKeyPk(), this.findOidcScopeMappingPks(), ]) if (!authorizationFlow) throw new Error('No Authentik authorization flow available') const body: Record = { name: input.name, authorization_flow: authorizationFlow, client_type: input.clientType ?? 'confidential', redirect_uris: input.redirectUris.map((url) => ({ matching_mode: 'strict', url })), property_mappings: scopeMappings, sub_mode: 'hashed_user_id', } if (invalidationFlow) body.invalidation_flow = invalidationFlow if (signingKey) body.signing_key = signingKey const p = await this.request<{ pk: number; client_id: string; client_secret: string }>( '/providers/oauth2/', { method: 'POST', body: JSON.stringify(body) }, ) this.logger.log(`Created Authentik OAuth2 provider "${input.name}" (pk=${p.pk})`) return { pk: p.pk, clientId: p.client_id, clientSecret: p.client_secret } } async createApplication(input: { name: string slug: string providerPk: number group?: string }): Promise<{ pk: string; slug: string }> { const app = await this.request<{ pk: string; slug: string }>('/core/applications/', { method: 'POST', body: JSON.stringify({ name: input.name, slug: input.slug, provider: input.providerPk, group: input.group ?? '', }), }) this.logger.log(`Created Authentik application "${input.slug}" (pk=${app.pk})`) return { pk: app.pk, slug: app.slug } } // A binding with a group and no policy = allow that group. Scopes the app to // the tenant's workspace members. async bindGroupToApplication(appPk: string, groupPk: string): Promise { await this.request('/policies/bindings/', { method: 'POST', body: JSON.stringify({ target: appPk, group: groupPk, order: 0, enabled: true }), }) } async deleteApplication(slug: string): Promise { const res = await fetch(`${this.base}/core/applications/${slug}/`, { method: 'DELETE', headers: { Authorization: `Bearer ${this.token}` }, }) if (!res.ok && res.status !== 404) { const body = await res.text().catch(() => '') throw new Error(`Authentik DELETE application ${slug} → ${res.status}: ${body.slice(0, 200)}`) } } async deleteOAuth2Provider(pk: number): Promise { const res = await fetch(`${this.base}/providers/oauth2/${pk}/`, { method: 'DELETE', headers: { Authorization: `Bearer ${this.token}` }, }) if (!res.ok && res.status !== 404) { const body = await res.text().catch(() => '') throw new Error(`Authentik DELETE provider ${pk} → ${res.status}: ${body.slice(0, 200)}`) } } } 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[] }