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}`)
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user