From 0299328175c6bd4f05a9f559136a4169e1fcd3c2 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 24 May 2026 21:46:35 +0200 Subject: [PATCH] feat(authentik): auto-wire recovery flow on bootstrap + expire fallback temp passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes that together close the "no recovery flow" gap behind the invite-operator feature. 1. SeedService now provisions an Authentik recovery flow on every boot. Without this, /core/users/{pk}/recovery/ returns 400 "No recovery flow set." and our invite endpoint silently falls back to setting a plaintext temp password — operationally fine in dev but not appropriate for prod. ensureRecoveryFlow() (in seed.service.ts): - Check if a flow with designation='recovery' already exists → no-op - Otherwise create one with slug='default-dezky-recovery' (designation='recovery', authentication='none' so the link token is the only auth needed) - Bind three default Authentik stages to it in order: 10: default-authentication-identification (auto-skipped when the recovery token already pins a user; lets the flow also work for self-service "forgot password" entry) 20: default-password-change-prompt 30: default-password-change-write - PATCH the default brand's flow_recovery to point at the new flow - Wrapped in .catch(warn) so an Authentik blip during boot doesn't crash platform-api — next restart retries. AuthentikClient additions: - findRecoveryFlow(), getDefaultBrand(), findStageByName(), createFlow(), bindStageToFlow(), setBrandRecoveryFlow(). IntegrationsModule pulled into SeedModule so SeedService can use AuthentikClient. 2. Temp-password fallback path now marks the password expired so Authentik forces a change on next login. Closes the window where an operator's plaintext share could outlive the new user's first session. AuthentikClient.markPasswordExpired(userPk): - GET user → merge attributes.passwordExpired=true + passwordExpiredAt=now → PATCH back - Read-modify-write because Authentik PATCH replaces nested objects and we don't want to clobber other attributes UsersService.inviteOperator() calls it on the fallback branch only — the recovery-link path doesn't need it (clicking the link sets a fresh password through the flow anyway). Verified end-to-end: - Boot → recovery flow auto-provisioned with three correctly-ordered stage bindings, default brand patched to flow_recovery=. - Re-invite test user → modal now shows a single recovery link starting with https://auth.dezky.local/if/flow/default-dezky- recovery/?flow_token=... (no temp password fallback). - Operator-team list still updates to include the new user immediately via the pre-created local User doc. Known follow-ups: - Enforce MFA enrollment in the recovery flow (add an authenticator stage). Deferred — locks users out if they lose the second factor on day one. Better to fire MFA from a separate "MFA required" stage on subsequent logins for platform admins. - Outbound SMTP (Phase 5/6) so Authentik emails the recovery link directly and the modal hides it. --- .../src/integrations/authentik.client.ts | 115 ++++++++++++++++++ services/platform-api/src/seed/seed.module.ts | 2 + .../platform-api/src/seed/seed.service.ts | 65 ++++++++++ .../platform-api/src/users/users.service.ts | 5 +- 4 files changed, 186 insertions(+), 1 deletion(-) diff --git a/services/platform-api/src/integrations/authentik.client.ts b/services/platform-api/src/integrations/authentik.client.ts index db5565e..ea1f0cd 100644 --- a/services/platform-api/src/integrations/authentik.client.ts +++ b/services/platform-api/src/integrations/authentik.client.ts @@ -179,6 +179,121 @@ export class AuthentikClient { } 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 { diff --git a/services/platform-api/src/seed/seed.module.ts b/services/platform-api/src/seed/seed.module.ts index c3f0f76..06befa0 100644 --- a/services/platform-api/src/seed/seed.module.ts +++ b/services/platform-api/src/seed/seed.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common' import { MongooseModule } from '@nestjs/mongoose' +import { IntegrationsModule } from '../integrations/integrations.module.js' import { Flag, FlagSchema } from '../schemas/flag.schema.js' import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js' import { Tenant, TenantSchema } from '../schemas/tenant.schema.js' @@ -8,6 +9,7 @@ import { SeedService } from './seed.service.js' @Module({ imports: [ + IntegrationsModule, MongooseModule.forFeature([ { name: Tenant.name, schema: TenantSchema }, { name: User.name, schema: UserSchema }, diff --git a/services/platform-api/src/seed/seed.service.ts b/services/platform-api/src/seed/seed.service.ts index 6d791be..ea54216 100644 --- a/services/platform-api/src/seed/seed.service.ts +++ b/services/platform-api/src/seed/seed.service.ts @@ -2,11 +2,22 @@ import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' +import { AuthentikClient } from '../integrations/authentik.client.js' import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js' import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import { User, UserDocument } from '../schemas/user.schema.js' +// Stages we wire into the recovery flow. All three ship with Authentik's +// default install — see `ak api /api/v3/stages/all/`. If any is missing +// (custom Authentik build), the bootstrap step logs a warning and skips. +const RECOVERY_FLOW_SLUG = 'default-dezky-recovery' +const RECOVERY_STAGES = [ + { name: 'default-authentication-identification', order: 10 }, + { name: 'default-password-change-prompt', order: 20 }, + { name: 'default-password-change-write', order: 30 }, +] as const + interface SeedFlag { key: string description: string @@ -49,6 +60,7 @@ export class SeedService implements OnApplicationBootstrap { @InjectModel(User.name) private readonly userModel: Model, @InjectModel(Subscription.name) private readonly subModel: Model, @InjectModel(Flag.name) private readonly flagModel: Model, + private readonly authentik: AuthentikClient, private readonly config: ConfigService, ) {} @@ -131,5 +143,58 @@ export class SeedService implements OnApplicationBootstrap { if (createdFlags > 0) { this.logger.log(`Seeded ${createdFlags} new flag(s) (of ${FLAG_SEEDS.length})`) } + + // Recovery flow — production-grade onboarding path. Without this, + // UsersService.inviteOperator falls back to setting a temp password + // (which is fine but means an operator briefly handles plaintext). Once + // wired, recovery links work and the temp-password path goes dormant. + await this.ensureRecoveryFlow().catch((err) => { + this.logger.warn( + `Could not wire Authentik recovery flow (will retry on next boot): ${ + err instanceof Error ? err.message : String(err) + }`, + ) + }) + } + + // Idempotent. Creates a recovery flow (designation='recovery') that reuses + // Authentik's default identification + password-change + user-write stages, + // then points the default brand's flow_recovery at it. Safe to run on every + // bootstrap — the existence checks short-circuit no-op work. + private async ensureRecoveryFlow(): Promise { + let flow = await this.authentik.findRecoveryFlow() + if (!flow) { + flow = await this.authentik.createFlow({ + name: 'Dezky · Account Recovery', + slug: RECOVERY_FLOW_SLUG, + title: 'Reset your Dezky password', + designation: 'recovery', + }) + this.logger.log(`Created Authentik recovery flow ${flow.slug} (pk=${flow.pk})`) + + // Bind the stages. Order matters — Authentik plays them in ascending + // order. The identification stage auto-skips when the user is already + // pinned by the recovery token, so the link-click path goes straight + // to the password prompt. + for (const { name, order } of RECOVERY_STAGES) { + const stage = await this.authentik.findStageByName(name) + if (!stage) { + this.logger.warn(`Recovery flow setup: stage "${name}" missing in Authentik, skipping`) + continue + } + await this.authentik.bindStageToFlow({ target: flow.pk, stage: stage.pk, order }) + } + this.logger.log(`Bound ${RECOVERY_STAGES.length} stages to recovery flow`) + } + + const brand = await this.authentik.getDefaultBrand() + if (!brand) { + this.logger.warn('No default Authentik brand found — recovery flow not bound') + return + } + if (brand.flow_recovery === flow.pk) { + return // already wired + } + await this.authentik.setBrandRecoveryFlow(brand.brand_uuid, flow.pk) } } diff --git a/services/platform-api/src/users/users.service.ts b/services/platform-api/src/users/users.service.ts index f49bc92..d476878 100644 --- a/services/platform-api/src/users/users.service.ts +++ b/services/platform-api/src/users/users.service.ts @@ -187,13 +187,16 @@ export class UsersService { // Try the preferred recovery-link path first. If Authentik has no // recovery flow configured (returns undefined), fall back to setting a - // generated temp password. + // generated temp password — and mark it as expired so Authentik forces + // a change on the user's first login (defense-in-depth against the + // operator's plaintext-handling window). let link: string | undefined let tempPassword: string | undefined link = await this.authentik.recoveryLink(created.pk) if (!link) { tempPassword = generateTempPassword() await this.authentik.setInitialPassword(created.pk, tempPassword) + await this.authentik.markPasswordExpired(created.pk) } void this.audit.record(