Files
dezky/services/platform-api/src/integrations/authentik.client.ts
T
Ronni Baslund 98e49bfe34
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled
feat(admin/users): editable member drawer + mailbox & ownership management
Rebuild the /admin/users detail drawer from a read-only profile into an
editable, Office 365-style panel with four sections:

- Username & mail: read-only primary for mailbox users; editable sign-in
  (Authentik-only) for mailbox-less identities; "Create mailbox" provisions
  a Stalwart inbox for an external-login admin
- Aliases: list/add/remove mailbox aliases (Stalwart), domain-scoped
- Role: member/admin toggle with a primary-account lock (owner, mailbox-less
  bootstrap admin, self) and a last-admin guard
- Contact information: display name, first/last name, phone, alternative
  email — mirrored best-effort to Authentik attributes + mailbox name

Ownership transfer: "Make owner" (row menu + drawer) plus an owner-side
"Transfer ownership" picker, gated to tenant admins / platform admins so a
departed owner can be replaced; promotes the target and demotes the prior
owner to admin.

Backend (platform-api): contact fields on User; AuthentikClient.updateUser;
StalwartClient.setMailboxName; UsersService updateTenantMember,
changeMemberPrimaryEmail, list/add/removeMemberAlias, createMailboxForMember,
transferOwnership; new DTOs and tenant-member routes. All mutations audited.

Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc.
2026-06-07 10:34:53 +02:00

560 lines
22 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')
}
// Public Authentik origin (no /api/v3) — for building user-facing OIDC URLs
// like the per-app issuer / .well-known discovery document.
get publicBase(): string {
return this.base.replace(/\/api\/v3\/?$/, '')
}
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
}
// Fully delete a user from Authentik (used when a member is removed from their
// last tenant). 404 is tolerated so a re-run after a partial removal is safe.
async deleteUser(userPk: number): Promise<void> {
const res = await fetch(`${this.base}/core/users/${userPk}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE user ${userPk}${res.status}: ${body.slice(0, 200)}`)
}
this.logger.log(`Deleted Authentik user ${userPk}`)
}
// Enable / disable a user. is_active=false blocks all sign-in (portal, SSO,
// and OCIS-via-SSO) without deleting anything — the basis of suspend/resume.
async setUserActive(userPk: number, active: boolean): Promise<void> {
await this.request(`/core/users/${userPk}/`, {
method: 'PATCH',
body: JSON.stringify({ is_active: active }),
})
this.logger.log(`Set Authentik user ${userPk} is_active=${active}`)
}
// Patch a user's identity / profile fields. `username` + `email` are kept
// aligned by callers (our convention). `attributesMerge`, when given, is
// read-modify-written so we don't clobber unrelated attributes — Authentik
// PATCH replaces nested objects wholesale, so a naive `attributes: {...}`
// would wipe e.g. the passwordExpired flag. No-op if nothing to change.
async updateUser(
userPk: number,
patch: { username?: string; email?: string; name?: string; attributesMerge?: Record<string, unknown> },
): Promise<void> {
const body: Record<string, unknown> = {}
if (patch.username !== undefined) body.username = patch.username
if (patch.email !== undefined) body.email = patch.email
if (patch.name !== undefined) body.name = patch.name
if (patch.attributesMerge && Object.keys(patch.attributesMerge).length > 0) {
const user = await this.request<AuthentikUser & { attributes?: Record<string, unknown> }>(
`/core/users/${userPk}/`,
)
body.attributes = { ...(user.attributes ?? {}), ...patch.attributesMerge }
}
if (Object.keys(body).length === 0) return
await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify(body) })
this.logger.log(`Updated Authentik user ${userPk} (${Object.keys(body).join(', ')})`)
}
// Force-logout: terminate the user's active sessions so they must sign in
// again. Returns how many were terminated. We pass the `?user=` filter AND
// re-filter client-side on the session's `user` pk — Authentik's endpoint
// silently ignores an unknown query filter, which would otherwise return (and
// delete) EVERY user's session. The client-side filter makes that impossible.
async terminateSessions(userPk: number): Promise<number> {
const res = await this.request<{ results: Array<{ uuid: string; user: number }> }>(
`/core/authenticated_sessions/?user=${userPk}`,
)
const sessions = (res.results ?? []).filter((s) => s.user === userPk)
await Promise.all(
sessions.map((s) =>
fetch(`${this.base}/core/authenticated_sessions/${s.uuid}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
}).catch(() => {}),
),
)
this.logger.log(`Terminated ${sessions.length} Authentik session(s) for user ${userPk}`)
return sessions.length
}
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}`)
}
// Remove a user from a group by ID. Authentik 204s even if the user wasn't
// a member, so this is effectively idempotent.
async removeUserFromGroup(userPk: number, groupId: string): Promise<void> {
await this.request(`/core/groups/${groupId}/remove_user/`, {
method: 'POST',
body: JSON.stringify({ pk: userPk }),
})
this.logger.log(`Removed user ${userPk} from Authentik group ${groupId}`)
}
// Count a user's configured authenticators (TOTP / WebAuthn / static). Used
// to surface an "MFA enrolled" badge on the partner team list — callers treat
// a count > 0 as enrolled. Authentik has no single "all devices" admin route;
// it exposes one per device type, so we query the common three and sum. Each
// returns a paginated { results } envelope.
async countAuthenticators(userPk: number): Promise<number> {
const types = ['totp', 'webauthn', 'static']
const counts = await Promise.all(
types.map(async (t) => {
try {
const res = await this.request<{ results?: unknown[] }>(
`/authenticators/admin/${t}/?user=${userPk}`,
)
return Array.isArray(res?.results) ? res.results.length : 0
} catch {
// A device type not enabled on this Authentik instance returns 404 —
// don't let it zero out the types that do resolve.
return 0
}
}),
)
return counts.reduce((a, b) => a + b, 0)
}
// 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}`)
}
// ── SSO apps: customer registers external apps using Dezky as the IdP ─────
// We create an OAuth2/OIDC Provider + Application in Authentik and bind the
// tenant's group to the application so only that workspace's members can use
// it. Provider pk is a number; Application pk is a uuid (slug is the human id).
// Find a flow pk by designation, preferring a slug substring (e.g. the
// explicit-consent authorization flow) and falling back to the first match.
async findFlowPk(designation: string, preferSlugIncludes?: string): Promise<string | undefined> {
const res = await this.request<{ results: AuthentikFlow[] }>(
`/flows/instances/?designation=${encodeURIComponent(designation)}`,
)
if (!res.results.length) return undefined
if (preferSlugIncludes) {
const pref = res.results.find((f) => f.slug.includes(preferSlugIncludes))
if (pref) return pref.pk
}
return res.results[0].pk
}
async findSigningKeyPk(): Promise<string | undefined> {
const res = await this.request<{ results: Array<{ pk: string; private_key_available?: boolean }> }>(
`/crypto/certificatekeypairs/?has_key=true`,
)
return res.results.find((k) => k.private_key_available)?.pk ?? res.results[0]?.pk
}
// The three standard OIDC scope mappings, resolved by Authentik's stable
// `managed` identifiers (pks differ per instance).
async findOidcScopeMappingPks(): Promise<string[]> {
const res = await this.request<{ results: Array<{ pk: string; managed?: string }> }>(
`/propertymappings/provider/scope/`,
)
const wanted = new Set([
'goauthentik.io/providers/oauth2/scope-openid',
'goauthentik.io/providers/oauth2/scope-email',
'goauthentik.io/providers/oauth2/scope-profile',
])
return res.results.filter((m) => m.managed && wanted.has(m.managed)).map((m) => m.pk)
}
async createOAuth2Provider(input: {
name: string
redirectUris: string[]
clientType?: 'confidential' | 'public'
}): Promise<{ pk: number; clientId: string; clientSecret: string }> {
const [authorizationFlow, invalidationFlow, signingKey, scopeMappings] = await Promise.all([
this.findFlowPk('authorization', 'explicit-consent'),
this.findFlowPk('invalidation', 'provider-invalidation'),
this.findSigningKeyPk(),
this.findOidcScopeMappingPks(),
])
if (!authorizationFlow) throw new Error('No Authentik authorization flow available')
const body: Record<string, unknown> = {
name: input.name,
authorization_flow: authorizationFlow,
client_type: input.clientType ?? 'confidential',
redirect_uris: input.redirectUris.map((url) => ({ matching_mode: 'strict', url })),
property_mappings: scopeMappings,
sub_mode: 'hashed_user_id',
}
if (invalidationFlow) body.invalidation_flow = invalidationFlow
if (signingKey) body.signing_key = signingKey
const p = await this.request<{ pk: number; client_id: string; client_secret: string }>(
'/providers/oauth2/',
{ method: 'POST', body: JSON.stringify(body) },
)
this.logger.log(`Created Authentik OAuth2 provider "${input.name}" (pk=${p.pk})`)
return { pk: p.pk, clientId: p.client_id, clientSecret: p.client_secret }
}
async createApplication(input: {
name: string
slug: string
providerPk: number
group?: string
}): Promise<{ pk: string; slug: string }> {
const app = await this.request<{ pk: string; slug: string }>('/core/applications/', {
method: 'POST',
body: JSON.stringify({
name: input.name,
slug: input.slug,
provider: input.providerPk,
group: input.group ?? '',
}),
})
this.logger.log(`Created Authentik application "${input.slug}" (pk=${app.pk})`)
return { pk: app.pk, slug: app.slug }
}
// A binding with a group and no policy = allow that group. Scopes the app to
// the tenant's workspace members.
async bindGroupToApplication(appPk: string, groupPk: string): Promise<void> {
await this.request('/policies/bindings/', {
method: 'POST',
body: JSON.stringify({ target: appPk, group: groupPk, order: 0, enabled: true }),
})
}
async deleteApplication(slug: string): Promise<void> {
const res = await fetch(`${this.base}/core/applications/${slug}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE application ${slug}${res.status}: ${body.slice(0, 200)}`)
}
}
async deleteOAuth2Provider(pk: number): Promise<void> {
const res = await fetch(`${this.base}/providers/oauth2/${pk}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE provider ${pk}${res.status}: ${body.slice(0, 200)}`)
}
}
}
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[]
}