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}`)
}
// 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 {