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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user