feat(authentik): auto-wire recovery flow on bootstrap + expire fallback temp passwords

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=<new pk>.
  - 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.
This commit is contained in:
Ronni Baslund
2026-05-24 21:46:35 +02:00
parent 9a97945565
commit 0299328175
4 changed files with 186 additions and 1 deletions
@@ -179,6 +179,121 @@ export class AuthentikClient {
} }
this.logger.log(`Set initial password for Authentik user ${userPk}`) 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<void> {
// 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<AuthentikUser & { attributes?: Record<string, unknown> }>(
`/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<AuthentikFlow | undefined> {
const res = await this.request<{ results: AuthentikFlow[] }>(
`/flows/instances/?designation=recovery`,
)
return res.results[0]
}
async getDefaultBrand(): Promise<AuthentikBrand | undefined> {
const res = await this.request<{ results: AuthentikBrand[] }>('/core/brands/')
return res.results.find((b) => b._default) ?? res.results[0]
}
async findStageByName(name: string): Promise<AuthentikStage | undefined> {
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<AuthentikFlow> {
return this.request<AuthentikFlow>('/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<void> {
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<void> {
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 { export interface AuthentikUser {
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose' import { MongooseModule } from '@nestjs/mongoose'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Flag, FlagSchema } from '../schemas/flag.schema.js' import { Flag, FlagSchema } from '../schemas/flag.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js' import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js' import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
@@ -8,6 +9,7 @@ import { SeedService } from './seed.service.js'
@Module({ @Module({
imports: [ imports: [
IntegrationsModule,
MongooseModule.forFeature([ MongooseModule.forFeature([
{ name: Tenant.name, schema: TenantSchema }, { name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema }, { name: User.name, schema: UserSchema },
@@ -2,11 +2,22 @@ import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose' import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose' import { Model } from 'mongoose'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js' import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js' import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.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 { interface SeedFlag {
key: string key: string
description: string description: string
@@ -49,6 +60,7 @@ export class SeedService implements OnApplicationBootstrap {
@InjectModel(User.name) private readonly userModel: Model<UserDocument>, @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>, @InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>, @InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
private readonly authentik: AuthentikClient,
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
@@ -131,5 +143,58 @@ export class SeedService implements OnApplicationBootstrap {
if (createdFlags > 0) { if (createdFlags > 0) {
this.logger.log(`Seeded ${createdFlags} new flag(s) (of ${FLAG_SEEDS.length})`) 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<void> {
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)
} }
} }
@@ -187,13 +187,16 @@ export class UsersService {
// Try the preferred recovery-link path first. If Authentik has no // Try the preferred recovery-link path first. If Authentik has no
// recovery flow configured (returns undefined), fall back to setting a // 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 link: string | undefined
let tempPassword: string | undefined let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk) link = await this.authentik.recoveryLink(created.pk)
if (!link) { if (!link) {
tempPassword = generateTempPassword() tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword) await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
} }
void this.audit.record( void this.audit.record(