Files
dezky/services/platform-api/src/integrations/authentik.client.ts
T
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00

330 lines
12 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
interface AuthentikGroup {
pk: string
name: string
attributes?: Record<string, unknown>
}
// Thin wrapper around the Authentik API for the operations the provisioning
// service needs. We never expose raw Authentik errors to API callers — they
// surface as provisioningErrors.authentik strings.
@Injectable()
export class AuthentikClient {
private readonly logger = new Logger(AuthentikClient.name)
private readonly base: string
private readonly token: string
constructor(config: ConfigService) {
this.base = config.getOrThrow<string>('AUTHENTIK_API_URL')
this.token = config.getOrThrow<string>('AUTHENTIK_API_TOKEN')
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
...init,
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
...(init.headers ?? {}),
},
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik ${init.method ?? 'GET'} ${path}${res.status}: ${body.slice(0, 200)}`)
}
// 204 No Content (and other empty-body successes) crash res.json().
// Endpoints like /core/groups/:id/add_user/ return 204; callers with a
// void return type don't care about the payload, so hand back undefined.
if (res.status === 204 || res.headers.get('content-length') === '0') {
return undefined as T
}
return (await res.json()) as T
}
// Idempotent: returns existing group if name is taken, creates otherwise.
async ensureGroup(slug: string, attributes: Record<string, unknown> = {}): Promise<AuthentikGroup> {
const search = await this.request<{ results: AuthentikGroup[] }>(
`/core/groups/?name=${encodeURIComponent(slug)}`,
)
if (search.results.length > 0) {
this.logger.log(`Authentik group "${slug}" already exists (pk=${search.results[0].pk})`)
return search.results[0]
}
const created = await this.request<AuthentikGroup>('/core/groups/', {
method: 'POST',
body: JSON.stringify({
name: slug,
attributes: { role: 'tenant', slug, ...attributes },
}),
})
this.logger.log(`Created Authentik group "${slug}" (pk=${created.pk})`)
return created
}
async deleteGroup(groupId: string): Promise<void> {
const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE group ${groupId}${res.status}: ${body.slice(0, 200)}`)
}
this.logger.log(`Deleted Authentik group ${groupId}`)
}
// Pull a window of Authentik events. Used by the audit ingest worker.
// `since` filters by created timestamp (strict greater-than); pagination is
// forward-only via `page`. Authentik's default page size is 100.
async listEvents(
since?: Date,
page = 1,
pageSize = 100,
): Promise<AuthentikEventPage> {
const params = new URLSearchParams({
ordering: 'created',
page: String(page),
page_size: String(pageSize),
})
if (since) params.set('created__gt', since.toISOString())
return this.request<AuthentikEventPage>(`/events/events/?${params}`)
}
// Look up a user by email. Returns undefined if not found. Used by the
// invite flow so we can give a friendly conflict error instead of letting
// Authentik 400.
async findUserByEmail(email: string): Promise<AuthentikUser | undefined> {
const res = await this.request<{ results: AuthentikUser[] }>(
`/core/users/?email=${encodeURIComponent(email)}`,
)
return res.results[0]
}
// Create a user. Authentik's `uid` field becomes the JWT `sub` claim once
// they log in — this is the same value our User.authentikSubjectId is keyed
// on. We set type='internal' (real human user, not service account) and
// is_active=true so the recovery link they receive lets them set a password.
async createUser(input: {
username: string
email: string
name: string
attributes?: Record<string, unknown>
groupPks?: string[]
}): Promise<AuthentikUser> {
const created = await this.request<AuthentikUser>('/core/users/', {
method: 'POST',
body: JSON.stringify({
username: input.username,
email: input.email,
name: input.name,
type: 'internal',
is_active: true,
path: 'users',
groups: input.groupPks ?? [],
attributes: input.attributes ?? {},
}),
})
this.logger.log(`Created Authentik user ${input.email} (pk=${created.pk}, uid=${created.uid})`)
return created
}
// Add an existing user to a group by ID. Idempotent — adding twice is a
// no-op on Authentik's side.
async addUserToGroup(userPk: number, groupId: string): Promise<void> {
await this.request(`/core/groups/${groupId}/add_user/`, {
method: 'POST',
body: JSON.stringify({ pk: userPk }),
})
this.logger.log(`Added user ${userPk} to Authentik group ${groupId}`)
}
// Generate a single-use recovery link the new user clicks to set their
// password + enroll MFA. Requires a "recovery flow" configured on the
// Authentik brand — if not set, returns undefined so callers can fall
// back to setInitialPassword.
async recoveryLink(userPk: number): Promise<string | undefined> {
try {
const res = await this.request<{ link: string }>(
`/core/users/${userPk}/recovery/`,
{ method: 'POST' },
)
return res.link
} catch (err) {
// Authentik returns 400 with "No recovery flow set." when the brand has
// no recovery flow wired. Treat as soft-fail; caller fallback path
// sets an initial password instead.
if (err instanceof Error && err.message.includes('recovery flow')) {
this.logger.warn('Authentik recovery link unavailable — no recovery flow configured')
return undefined
}
throw err
}
}
// Direct set_password — used when no recovery flow is configured. The
// operator hands the temp password to the new user out-of-band; the user
// changes it after first login via Authentik's password-change flow.
// Authentik returns 204 No Content (empty body) on success, so we skip
// request<T>()'s JSON parser and call fetch directly.
async setInitialPassword(userPk: number, password: string): Promise<void> {
const res = await fetch(`${this.base}/core/users/${userPk}/set_password/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ password }),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik set_password ${userPk}${res.status}: ${body.slice(0, 200)}`)
}
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 {
pk: number
uid: string // becomes JWT `sub` on first login
username: string
email: string
name?: string
is_active: boolean
groups?: string[]
}
// Shape returned by /events/events/. Only the fields we read; Authentik
// includes a number of others (tenant, brand) we don't need.
export interface AuthentikEvent {
pk: string
action: string
app?: string
user?: { pk?: number; username?: string; name?: string; email?: string }
context?: Record<string, unknown>
client_ip?: string
created: string
}
export interface AuthentikEventPage {
pagination: { next: number; previous: number; count: number; current: number; total_pages: number; start_index: number; end_index: number }
results: AuthentikEvent[]
}