diff --git a/stacks/authentik/tripit-email-blueprint.tf b/stacks/authentik/tripit-email-blueprint.tf new file mode 100644 index 00000000..1e6795e2 --- /dev/null +++ b/stacks/authentik/tripit-email-blueprint.tf @@ -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, + ] +} diff --git a/stacks/authentik/tripit-email-stages.yaml b/stacks/authentik/tripit-email-stages.yaml new file mode 100644 index 00000000..9d9b148e --- /dev/null +++ b/stacks/authentik/tripit-email-stages.yaml @@ -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 diff --git a/stacks/authentik/tripit-flows.tf b/stacks/authentik/tripit-flows.tf index 8155e65e..534a4d4d 100644 --- a/stacks/authentik/tripit-flows.tf +++ b/stacks/authentik/tripit-flows.tf @@ -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