fix(authentik): deliver tripit email-verify stages via blueprint (provider token_expiry too old)
All checks were successful
ci/woodpecker/push/default Pipeline was successful

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>
This commit is contained in:
Viktor Barzin 2026-06-17 07:30:05 +00:00
parent 89eb090be3
commit e4512f3566
3 changed files with 94 additions and 28 deletions

View file

@ -0,0 +1,22 @@
# Delivers the TripIt enrollment/recovery email-verification stages + their flow
# bindings (tripit-email-stages.yaml) as a server-applied Authentik blueprint.
#
# Why a blueprint and not authentik_stage_email resources: the globally-pinned
# provider (goauthentik 2024.x in terragrunt.hcl) models EmailStage.token_expiry
# as an integer, but the live server (2026.2.x) requires a duration string and
# 400s any number. The blueprint is parsed by the server, which accepts the
# string. Bumping the provider would mean a global terragrunt.hcl change that
# re-applies every platform stack disproportionate. See tripit-flows.tf.
#
# depends_on the flows so they exist before Authentik resolves the blueprint's
# !Find [..., slug, tripit-enrollment|tripit-recovery] references.
resource "authentik_blueprint" "tripit_email_stages" {
name = "tripit-email-stages"
content = file("${path.module}/tripit-email-stages.yaml")
enabled = true
depends_on = [
authentik_flow.tripit_enrollment,
authentik_flow.tripit_recovery,
]
}

View file

@ -0,0 +1,58 @@
# TripIt enrollment + recovery email-verification stages (tripit ADR-0020).
#
# Delivered as an Authentik blueprint (applied server-side by Authentik) instead
# of via the Terraform provider, because the globally-pinned provider
# (goauthentik 2024.x) models EmailStage.token_expiry as an integer while the
# live server (2026.2.x) requires a duration string ("hours=24") and rejects any
# number. See tripit-flows.tf for the rest of the flow and tripit-email-blueprint.tf
# for the delivery resource.
#
# These two stages + their flow bindings are the SECURITY BOUNDARY of ADR-0020:
# enrollment creates the user inactive; only clicking the link from
# tripit-enrollment-verify (activate_user_on_success) makes the account usable.
# Without this, anyone could self-enroll under an address they don't control and
# tripit (which trusts X-authentik-email) would treat them as that identity.
version: 1
metadata:
name: tripit-email-stages
entries:
# Enrollment: verify the email and ACTIVATE the (initially inactive) user.
- model: authentik_stages_email.emailstage
state: present
identifiers:
name: tripit-enrollment-verify
attrs:
use_global_settings: true # noreply@viktorbarzin.me via mail.viktorbarzin.me
activate_user_on_success: true
subject: Confirm your TripIt account
template: email/account_confirmation.html
token_expiry: hours=24
# Recovery: prove inbox ownership before letting the user register a new passkey.
- model: authentik_stages_email.emailstage
state: present
identifiers:
name: tripit-recovery-email
attrs:
use_global_settings: true
activate_user_on_success: false
subject: Recover your TripIt access
template: email/account_confirmation.html
token_expiry: hours=1
# Bind enrollment-verify into tripit-enrollment at order 30
# (prompt 10 -> write 20 -> VERIFY 30 -> passkey 40 -> login 50).
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, tripit-enrollment]]
stage: !Find [authentik_stages_email.emailstage, [name, tripit-enrollment-verify]]
attrs:
order: 30
# Bind recovery-email into tripit-recovery at order 20
# (identify 10 -> EMAIL 20 -> new passkey 30 -> login 40).
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !Find [authentik_flows.flow, [slug, tripit-recovery]]
stage: !Find [authentik_stages_email.emailstage, [name, tripit-recovery-email]]
attrs:
order: 20

View file

@ -87,17 +87,17 @@ resource "authentik_stage_user_write" "tripit_enroll_write" {
user_creation_mode = "always_create"
}
resource "authentik_stage_email" "tripit_enroll_verify" {
name = "tripit-enrollment-verify"
# Use AUTHENTIK_EMAIL__* (noreply@viktorbarzin.me via mail.viktorbarzin.me).
use_global_settings = true
# THE security gate: a user becomes active (and thus loginable / trusted by
# tripit's X-authentik-email) only after clicking the link sent to their inbox.
activate_user_on_success = true
subject = "Confirm your TripIt account"
template = "email/account_confirmation.html"
token_expiry = 1440 # minutes = 24h
}
# 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"
@ -118,11 +118,7 @@ resource "authentik_flow_stage_binding" "tripit_enroll_20_write" {
stage = authentik_stage_user_write.tripit_enroll_write.id
order = 20
}
resource "authentik_flow_stage_binding" "tripit_enroll_30_verify" {
target = authentik_flow.tripit_enrollment.uuid
stage = authentik_stage_email.tripit_enroll_verify.id
order = 30
}
# 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
@ -143,13 +139,7 @@ resource "authentik_stage_identification" "tripit_recover_ident" {
pretend_user_exists = true
}
resource "authentik_stage_email" "tripit_recover_email" {
name = "tripit-recovery-email"
use_global_settings = true
subject = "Recover your TripIt access"
template = "email/account_confirmation.html"
token_expiry = 60 # minutes = 1h
}
# (recovery email-verification stage is in tripit-email-blueprint.tf see note above)
resource "authentik_flow" "tripit_recovery" {
name = "Recover TripIt access"
@ -165,11 +155,7 @@ resource "authentik_flow_stage_binding" "tripit_recover_10_ident" {
stage = authentik_stage_identification.tripit_recover_ident.id
order = 10
}
resource "authentik_flow_stage_binding" "tripit_recover_20_email" {
target = authentik_flow.tripit_recovery.uuid
stage = authentik_stage_email.tripit_recover_email.id
order = 20
}
# 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