infra/stacks/authentik/tripit-flows.tf
Viktor Barzin e4512f3566
All checks were successful
ci/woodpecker/push/default Pipeline was successful
fix(authentik): deliver tripit email-verify stages via blueprint (provider token_expiry too old)
Pipeline 214 failed: the pinned goauthentik 2024.x provider models EmailStage.token_expiry as an integer, but the live 2026.2.x server requires a duration string ('hours=24') and 400s any number (even the provider default 30). Bumping the provider is a global terragrunt.hcl change re-applying every platform stack + breaking 3 other authentik-using stacks' lockfiles — disproportionate. Instead the two email-verification stages + their flow bindings move into an Authentik blueprint (tripit-email-stages.yaml) applied server-side via authentik_blueprint; the server parses token_expiry natively. Validated on the live server + terraform validate. Restores the ADR-0020 email-verification security gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 07:30:05 +00:00

168 lines
7.2 KiB
HCL

# =============================================================================
# TripIt external-user self-service flows (tripit ADR-0020).
#
# Public, passwordless self-signup for external users (people Viktor shares
# trips with). Three concerns:
#
# * tripit-enrollment — open registration with email + passkey. Creates an
# INACTIVE external user in "TripIt External", then an email-verification
# stage ACTIVATES it. Email verification is the SECURITY BOUNDARY: tripit's
# backend trusts the X-authentik-email header (AUTH_MODE=hybrid), so a user
# must not be able to enroll under an address they don't control. Creating
# the user inactive and activating ONLY on a clicked verification link
# enforces that — an attacker who enters someone else's address produces an
# inactive account that never activates and can never log in.
#
# * passwordless login — already provided by the built-in `webauthn` flow,
# wired as passwordless_flow on the default login page's identification
# stage. No new flow needed; external users with a passkey log in there.
#
# * tripit-recovery — email-anchored: prove inbox ownership, then register a
# NEW passkey (the "lost my device" path; multi-passkey per ADR-0020). NOT
# wired into the brand/global login flow (that would change ADMIN recovery
# behaviour) — reached via its own /if/flow/tripit-recovery/ URL.
#
# Fence: the "TripIt External" group (tripit-external.tf) + the prepended branch
# in admin-services-restriction.tf admit these users to tripit.viktorbarzin.me
# ONLY and deny every other *.viktorbarzin.me forward-auth host. Email is sent
# via the global SMTP settings wired in modules/authentik/values.yaml
# (noreply@viktorbarzin.me through mail.viktorbarzin.me).
# =============================================================================
# ---- Shared stages (used by both enrollment and recovery) -------------------
# Discoverable (resident) credential => usernameless passwordless login via the
# built-in `webauthn` flow, which has no identification stage and so REQUIRES a
# discoverable credential to resolve the user. "required" guarantees that;
# every modern passkey authenticator supports it.
resource "authentik_stage_authenticator_webauthn" "tripit_passkey" {
name = "tripit-passkey-setup"
resident_key_requirement = "required"
user_verification = "preferred"
}
resource "authentik_stage_user_login" "tripit_login" {
name = "tripit-login"
session_duration = "weeks=4"
}
# ---- Enrollment -------------------------------------------------------------
resource "authentik_stage_prompt_field" "tripit_enroll_email" {
name = "tripit-enroll-email"
field_key = "email"
label = "Email"
type = "email"
required = true
order = 0
}
resource "authentik_stage_prompt_field" "tripit_enroll_name" {
name = "tripit-enroll-name"
field_key = "name"
label = "Full name"
type = "text"
required = true
order = 1
}
resource "authentik_stage_prompt" "tripit_enroll_prompt" {
name = "tripit-enrollment-prompt"
fields = [
authentik_stage_prompt_field.tripit_enroll_email.id,
authentik_stage_prompt_field.tripit_enroll_name.id,
]
}
resource "authentik_stage_user_write" "tripit_enroll_write" {
name = "tripit-enrollment-write"
# Created INACTIVE: only the email-verification stage (below) activates it.
create_users_as_inactive = true
# Land in the fenced group (tripit-only via admin-services-restriction).
create_users_group = authentik_group.tripit_external.id
user_type = "external"
# Open registration: ALWAYS create a fresh user; never attach to / mutate an
# existing one. There is no identification stage before this, so there is no
# pending user to hijack — this is belt-and-suspenders against account takeover.
user_creation_mode = "always_create"
}
# NOTE: the two email-verification stages (enrollment + recovery) AND their flow
# bindings are deliberately NOT defined here — they live in an Authentik
# BLUEPRINT (tripit-email-blueprint.tf), applied server-side. Reason: the
# globally-pinned provider (goauthentik 2024.x, terragrunt.hcl) models
# EmailStage.token_expiry as an INTEGER, but the live server (2026.2.x) requires
# a duration STRING ("hours=24") and 400s any number — the provider cannot send
# a valid value (confirmed: even the unset default `30` is rejected). The
# blueprint is parsed by the server, which accepts the string. Bumping the
# provider would be a global terragrunt.hcl change that re-applies every platform
# stack and breaks 3 other authentik-using app stacks' lockfiles — out of all
# proportion to two stages. See tripit ADR-0020.
resource "authentik_flow" "tripit_enrollment" {
name = "Sign up for TripIt"
title = "Create your TripIt account"
slug = "tripit-enrollment"
designation = "enrollment"
authentication = "require_unauthenticated"
}
# prompt -> write(inactive) -> verify(activate) -> passkey -> login
resource "authentik_flow_stage_binding" "tripit_enroll_10_prompt" {
target = authentik_flow.tripit_enrollment.uuid
stage = authentik_stage_prompt.tripit_enroll_prompt.id
order = 10
}
resource "authentik_flow_stage_binding" "tripit_enroll_20_write" {
target = authentik_flow.tripit_enrollment.uuid
stage = authentik_stage_user_write.tripit_enroll_write.id
order = 20
}
# order 30 (email-verification binding) is in tripit-email-blueprint.tf — see note above
resource "authentik_flow_stage_binding" "tripit_enroll_40_passkey" {
target = authentik_flow.tripit_enrollment.uuid
stage = authentik_stage_authenticator_webauthn.tripit_passkey.id
order = 40
}
resource "authentik_flow_stage_binding" "tripit_enroll_50_login" {
target = authentik_flow.tripit_enrollment.uuid
stage = authentik_stage_user_login.tripit_login.id
order = 50
}
# ---- Recovery (email-anchored, passwordless) --------------------------------
resource "authentik_stage_identification" "tripit_recover_ident" {
name = "tripit-recovery-identification"
user_fields = ["email"]
# Anti-enumeration: proceed even for an unknown address (no "user not found").
pretend_user_exists = true
}
# (recovery email-verification stage is in tripit-email-blueprint.tf — see note above)
resource "authentik_flow" "tripit_recovery" {
name = "Recover TripIt access"
title = "Recover your TripIt account"
slug = "tripit-recovery"
designation = "recovery"
authentication = "require_unauthenticated"
}
# identify(email) -> email(prove ownership) -> new passkey -> login
resource "authentik_flow_stage_binding" "tripit_recover_10_ident" {
target = authentik_flow.tripit_recovery.uuid
stage = authentik_stage_identification.tripit_recover_ident.id
order = 10
}
# order 20 (email-verification binding) is in tripit-email-blueprint.tf — see note above
resource "authentik_flow_stage_binding" "tripit_recover_30_passkey" {
target = authentik_flow.tripit_recovery.uuid
stage = authentik_stage_authenticator_webauthn.tripit_passkey.id
order = 30
}
resource "authentik_flow_stage_binding" "tripit_recover_40_login" {
target = authentik_flow.tripit_recovery.uuid
stage = authentik_stage_user_login.tripit_login.id
order = 40
}