Plan: Multi-Backend Identity Integration Tests

On this page

Revision 2026-05-12 (post-!266). Dex was stripped from this plan after !266 merged. Plan E (service identity) made the client_credentials grant load-bearing for CRAIG, and Dex 2.45.1 does not implement that grant — its server-level allSupportedGrants map is compiled in and excludes client_credentials; no configuration can enable it. The dex render adapter, devstack profile entry, pre-rendered config, integration test, and docs references all came out as part of the Dex-strip MR. A successor sandbox-validation + integration effort against a Rust-aligned backend (Kanidm primary, ZITADEL fallback) is planned. The Authentik portion of this plan (the subject of !266) remains the canonical reference for what "verified" means in this plan’s terms.

Status

Step Description Status

1

File canonical .adoc plan + link in nav.adoc + create GitLab issue.

Done (2026-05-12) — MR !266

2

Renderer extension in xtask/src/cmd/identity/render/authentik.rs — emits redirect_uris: [] + 5 !Find references + sub_mode per provider. Inline unit tests.

Done (2026-05-12) — MR !266

3

Devstack compose + ports + DB — redis / authentik-server / authentik-worker services under identity-multibackend profile. xtask docker::PORT_MAPPINGS extended. Authentik DB added to postgres init.

Done (2026-05-12) — MR !266 (; Dex entries stripped in successor MR)

4

xtask dev multibackend-up / multibackend-down subcommands so users don’t run docker compose directly (per coding-conventions.md).

Done (2026-05-12) — MR !266

5

Pre-rendered configs committed at devstack/authentik/ (full file, not "render + manually merge"). README.

Done (2026-05-12) — MR !266 (; devstack/dex/ deleted in successor MR)

6

identity_multibackend_available() helper in craig-test-lib with retry-with-timeout semantics matching the slow Authentik first-boot path.

Done (2026-05-12) — MR !266

7

Authentik integration test at crates/craig-test-lib/tests/identity_multibackend.rs — full client_credentials grant.

Done (2026-05-12) — MR !266 (; Dex test removed in successor MR)

8

Docs flip in idp-integration.adoc + local-dev.md.

Done (2026-05-12) — MR !266 (; reframed "any OIDC backend with `client_credentials`" in successor MR)

9

CHANGELOG entry + 8-Q pre-commit walkthrough + push + open MR + force-merge per Plan E session pattern.

Done (2026-05-12) — MR !266

10

Dex strip — delete xtask/src/cmd/identity/render/dex.rs + RenderBackend::Dex + devstack/dex/ + docker-compose.yml dex service + PORT_MAPPINGS dex tuple + dex integration test + dex references in idp-integration.adoc. CHANGELOG entry. Reason: post-merge audit established that CRAIG cannot host on Dex because Plan E’s client_credentials requirement is incompatible with Dex 2.45.1’s compiled-in grant allowlist.

Done (2026-05-12) — MR !267

11

Phase 0 sandbox validation — Kanidm (Rust-aligned, primary) and ZITADEL (fallback) tested in throwaway compose. Verify client_credentials grant + capture canonical config shape + URL patterns.

Done (pre-ADR-030). Findings: Kanidm client_credentials works but issues JWE-encrypted (not JWS) access tokens, incompatible with `craig-auth’s local JWS validation path; ZITADEL passes structural validation (grant advertised in discovery, JWS-only JWKS, grant dispatched at token endpoint). Findings migrated into the durable Plan F body — see Plan F § Phase 0 Findings.

12

MR-B: ZITADEL backendxtask/src/cmd/identity/render/zitadel.rs (Terraform HCL renderer); devstack profile entry (zitadel + Postgres zitadel DB); cargo xtask dev multibackend-up/down includes zitadel; devstack/zitadel/{craig.tf,README.md} pre-rendered; integration test asserting discovery + JWKS + grant advertisement + dispatch; docs sweep.

In progress (this MR)

13

Plan F filedplans/introspection-validation-mode.adoc stub + nav entry, capturing the planned introspection-mode validation path that would unlock Kanidm and broaden CRAIG’s compatibility to JWS-AND-introspection backends. ADR-029 draft to follow when plan picked up.

In progress (this MR)

Epic: none (single-MR initiative; attaches to recently-archived Plan E epic &28 as a "validation followup")
Issue: filed at implementation start (referenced in MR body)
Branch: feat/idp-multibackend-integration-tests
Successor of: Plan E (archived !265). Backs the "IdP-neutral" claim ADR-026 + ADR-028 make with actual round-trip tests.

Context

Plan E (archived 2026-05-11, !265) shipped the operator toolkit for IdP-neutral service identity: cargo xtask identity verify (conformance gate) + cargo xtask identity render --backend keycloak|authentik|dex (reference IaC). The renderer’s tests verify structural assertions on rendered output ("the YAML contains `client_id: craig-cases`", "9 service clients emitted") — they do NOT prove the rendered output actually loads into a running Authentik or Dex instance.

The only IdP exercised end-to-end today is Keycloak (devstack default + 203/203 E2E). The IdP-neutral claim in ADR-026 + idp-integration.adoc rests on:

  1. OIDC discovery + JWKS validation being vendor-neutral by spec

  2. Rendered IaC having the shape each vendor’s spec describes

  3. No IdP-specific code paths in the runtime

That’s an architectural baseline, not verification. A user deploying on Authentik or Dex tomorrow could hit a real bug we’d never see.

This MR closes the gap: opt-in identity-multibackend devstack profile, full client_credentials round-trip on both Authentik 2026.2.2 and Dex 2.45.1. A pre-implementation sandbox validation (2026-05-12) verified the exact blueprint shape + fixture names + URL patterns needed; those findings anchor every blueprint and URL claim below.

Scope

In scope:

  • Renderer extension: Authentik provider emission gains redirect_uris: [] + 5 !Find references (3 flow refs + signing key + scope mappings) + sub_mode: hashed_user_id. Per sandbox validation these are required for client_credentials to work on Authentik 2026.2.

  • Devstack identity-multibackend Docker Compose profile with 4 services (redis + authentik-server + authentik-worker + dex). Opt-in only; default cargo xtask dev start unchanged.

  • cargo xtask dev multibackend-up / multibackend-down subcommands — convention-mandated wrapper so users don’t invoke docker compose directly.

  • Pre-rendered devstack configs at devstack/authentik/blueprints/craig.yaml and devstack/dex/config.yaml. Full files committed (not "render + manual merge"), with READMEs documenting regeneration.

  • identity_multibackend_available() helper in test-lib with 30s retry-with-timeout (Authentik first-boot is slow).

  • Two integration tests exercising the full discovery → JWKS → client_credentials flow on each backend.

  • Documentation: idp-integration.adoc claim flip; local-dev.md documents cargo xtask dev multibackend-up.

  • CHANGELOG.adoc entry.

Out of scope:

  • Auto-activating the profile in cargo xtask dev start — would balloon dev iteration time + container memory; profile stays opt-in.

  • CI matrix job for the profile — devstack-gated tests don’t run in CI today; pre-push covers the round-trip locally.

  • Bringing Authentik/Dex behind the BFF login flow for E2E Playwright (separate, larger effort).

  • Production deployment guides for Authentik/Dex (operator-level docs; the IaC generator + this MR’s verified working blueprint serve as the reference).

  • Backports to canopy.

  • CRAIG services consuming Authentik or Dex as their actual identity provider — devstack still uses Keycloak as the user-facing IdP; the profile only adds parallel backends for verification.

Design

Sandbox-validated findings (anchor every blueprint/URL/grant claim below)

Pre-implementation sandbox spun up an isolated Authentik 2026.2.2 + worker + Redis + Postgres stack. Manually iterated until client_credentials worked end-to-end. Findings:

  1. Authentik 2026.2 requires redirect_uris: [] on oauth2provider even for client_credentials-only flows. Bare provider import failed with Serializer errors {'redirect_uris': [ErrorDetail(string='This field is required.', code='required')]}. The error only surfaces via Importer.from_string(…​).validate() — the worker’s apply_blueprint task reports "Task finished" successfully but the entries are silently skipped.

  2. 5 !Find references required to wire authentication + authorization + invalidation flows + signing key + scope mappings (the renderer’s current output is missing all 5).

  3. No type: service_account user needed — provider’s client_secret alone authenticates the client_credentials grant.

  4. All fixture names verified working in Authentik 2026.2.2 (table below).

  5. Discovery URL is per-application; token endpoint is shared.

  6. Grant request requires scope=openid.

Authentik fixture !Find key Verified value (2026.2.2)

Authentication flow

slug

default-source-authentication

Authorization flow (for client_credentials)

slug

default-provider-authorization-implicit-consent

Invalidation flow

slug

default-provider-invalidation-flow

Signing keypair

name

authentik Self-signed Certificate (quote in YAML)

Scope mapping (openid)

scope_name

openid

Scope mapping (email)

scope_name

email

Scope mapping (profile)

scope_name

profile

URLs (Authentik 2026.2.2):

Grant request shape verified by the sandbox:

POST /application/o/token/
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=craig-cases&client_secret=REPLACE_ME_AFTER_INSTALL&scope=openid

Response: RS256-signed JWT (~1.3KB access_token), token_type: Bearer, expires_in: 3600, includes id_token.

Authentik blueprint shape (canonical per-service emission)

The renderer’s extended output per service:

- model: authentik_providers_oauth2.oauth2provider
  id: provider-<svc>
  identifiers:
    name: <svc>
  attrs:
    client_type: confidential
    client_id: <svc>
    client_secret: <placeholder_service_secret>
    redirect_uris: []
    authentication_flow: !Find [authentik_flows.flow, [slug, default-source-authentication]]
    authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
    invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
    signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
    property_mappings:
      - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
      - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
      - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
    include_claims_in_id_token: true
    sub_mode: hashed_user_id

- model: authentik_core.application
  identifiers:
    slug: <svc>
  attrs:
    name: <svc>
    provider: !KeyOf provider-<svc>
    policy_engine_mode: any

Reuse from existing patterns

  • crates/craig-test-lib/src/lib.rs::devstack_available — copy structure for identity_multibackend_available(), extending with retry-with-timeout

  • xtask/src/cmd/identity/verify.rs::{fetch_discovery, fetch_jwks, request_client_credentials_token} — public functions; integration tests can either call them directly or use raw reqwest matching existing test style

  • docker-compose.yml:612-660 observability profile — profiles: [identity-multibackend] mimics this structure

  • xtask/src/cmd/dev.rs::DevCommand::RestartService — convention pattern for "subcommand that wraps a docker compose operation". MultibackendUp / MultibackendDown follow this exactly

  • xtask/src/docker.rs::PORT_MAPPINGS constant — 4 new tuples

Steps

Step 1: File the canonical plan + GitLab issue + nav entry

Files: docs/modules/ROOT/pages/plans/idp-multibackend-tests.adoc (NEW — this file), docs/modules/ROOT/nav.adoc (EDIT — add Active xref).

Zero code changes. Files-only commit.

nav.adoc line under * Plans / ** Active:

*** xref:plans/idp-multibackend-tests.adoc[Authentik + Dex Multi-Backend Identity Integration Tests]

GitLab: glab issue create --title "feat: Authentik + Dex multi-backend identity integration tests" --label "type:feature,scope:devstack,scope:xtask,scope:craig-test-lib" --description "Backs ADR-026 + Plan E IdP-neutral claim with end-to-end round-trip tests against Authentik 2026.2.2 + Dex 2.45.1. See docs/modules/ROOT/pages/plans/idp-multibackend-tests.adoc." — capture the issue number for the MR body.

Verify: cargo xtask check-docs (Tier 1 docs untouched); visual diff of nav.adoc shows the new xref under Active.

Step 2: Authentik renderer extension

Files: xtask/src/cmd/identity/render/authentik.rs (EDIT)

In the for svc in CRAIG_SERVICES loop currently emitting oauth2provider (lines ~68-105), the per-service oauth2provider attrs: block needs to emit (in order):

client_type: confidential
client_id: <svc>
client_secret: <model.placeholder_service_secret>
redirect_uris: []
authentication_flow: !Find [authentik_flows.flow, [slug, default-source-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, "authentik Self-signed Certificate"]]
property_mappings:
  - !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
  - !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
  - !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
include_claims_in_id_token: true
sub_mode: hashed_user_id

Remove the existing inline comment # Audience-mapper handled via property_mapping on the application; operator merges in…​ since the renderer now emits the property_mappings list itself.

Add 4 inline unit tests in the same file’s #[cfg(test)] mod tests block:

#[test]
fn each_provider_has_redirect_uris_empty_array() {
    let s = render(&IdentityModel::devstack_defaults()).unwrap();
    let occurrences = s.matches("redirect_uris: []").count();
    assert_eq!(occurrences, CRAIG_SERVICES.len(),
        "expected `redirect_uris: []` once per service, got {occurrences}");
}

#[test]
fn each_provider_references_default_flows() {
    let s = render(&IdentityModel::devstack_defaults()).unwrap();
    for slug in &[
        "default-source-authentication",
        "default-provider-authorization-implicit-consent",
        "default-provider-invalidation-flow",
    ] {
        let needle = format!("!Find [authentik_flows.flow, [slug, {slug}]]");
        let occurrences = s.matches(&needle).count();
        assert_eq!(occurrences, CRAIG_SERVICES.len(),
            "expected {slug} reference once per service, got {occurrences}");
    }
}

#[test]
fn each_provider_references_signing_key_and_scope_mappings() {
    let s = render(&IdentityModel::devstack_defaults()).unwrap();
    let cert = "!Find [authentik_crypto.certificatekeypair, [name, \"authentik Self-signed Certificate\"]]";
    assert_eq!(s.matches(cert).count(), CRAIG_SERVICES.len());
    for scope in &["openid", "email", "profile"] {
        let needle = format!("!Find [authentik_providers_oauth2.scopemapping, [scope_name, {scope}]]");
        assert_eq!(s.matches(&needle).count(), CRAIG_SERVICES.len());
    }
}

#[test]
fn each_provider_has_sub_mode_hashed_user_id() {
    let s = render(&IdentityModel::devstack_defaults()).unwrap();
    assert_eq!(s.matches("sub_mode: hashed_user_id").count(), CRAIG_SERVICES.len());
}

Verify: cargo nextest run -p xtask --bin xtask authentik — new + existing render tests pass. Smoke: cargo run -p xtask --quiet — identity render --backend authentik | head -100.

Step 3: Devstack compose + ports + Postgres DB

Files: docker-compose.yml (EDIT), devstack/postgres/init.sql (EDIT), xtask/src/docker.rs (EDIT).

(3a) Append 4 services to docker-compose.yml under profiles: [identity-multibackend]:

  # ── Multi-backend identity validation (Plan E followup) ────────────
  # Opt-in profile bringing up Authentik + Dex for IdP-neutral integration
  # tests. Activated via `cargo xtask dev multibackend-up` (not docker
  # compose directly — per coding-conventions.md).
  #
  # Validated images (verified at registry 2026-05-12):
  # - ghcr.io/goauthentik/server:2026.2.2
  # - ghcr.io/dexidp/dex:v2.45.1
  # Authentik image is Debian-based (no Alpine variant upstream); accepted
  # as controlled exception to coding-conventions.md § Container Runtime
  # since we don't build the image.

  redis:
    image: redis:7-alpine
    ports:
      - "${CRAIG_PORT_REDIS_6379:-6379}:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
    profiles:
      - identity-multibackend

  authentik-server:
    image: ghcr.io/goauthentik/server:2026.2.2
    command: server
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: craig
      AUTHENTIK_POSTGRESQL__PASSWORD: craig
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_SECRET_KEY: devstack-only-secret-not-for-production
      AUTHENTIK_BOOTSTRAP_PASSWORD: password
      AUTHENTIK_BOOTSTRAP_EMAIL: admin@craig.example.com
      AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
      AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
      AUTHENTIK_AVATARS: none
    volumes:
      - ./devstack/authentik/blueprints:/blueprints/custom:ro
    ports:
      - "${CRAIG_PORT_AUTHENTIK_9000:-9000}:9000"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "ak", "healthcheck"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 90s
    profiles:
      - identity-multibackend

  authentik-worker:
    image: ghcr.io/goauthentik/server:2026.2.2
    command: worker
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: craig
      AUTHENTIK_POSTGRESQL__PASSWORD: craig
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_SECRET_KEY: devstack-only-secret-not-for-production
      AUTHENTIK_BOOTSTRAP_PASSWORD: password
      AUTHENTIK_BOOTSTRAP_EMAIL: admin@craig.example.com
      AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
      AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
      AUTHENTIK_AVATARS: none
    volumes:
      - ./devstack/authentik/blueprints:/blueprints/custom:ro
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "ak", "healthcheck"]
      interval: 10s
      timeout: 5s
      retries: 30
      start_period: 90s
    profiles:
      - identity-multibackend

  dex:
    image: ghcr.io/dexidp/dex:v2.45.1
    command: ["dex", "serve", "/etc/dex/config.yaml"]
    volumes:
      - ./devstack/dex/config.yaml:/etc/dex/config.yaml:ro
    ports:
      - "${CRAIG_PORT_DEX_5556:-5556}:5556"
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:5556/healthz"]
      interval: 5s
      timeout: 3s
      retries: 10
    profiles:
      - identity-multibackend

(3b) Append CREATE DATABASE authentik; to devstack/postgres/init.sql.

(3c) Append 4 tuples to xtask/src/docker.rs::PORT_MAPPINGS:

("redis", 6379, 6379),
("authentik", 9000, 9000),
("authentik", 9443, 9443),
("dex", 5556, 5556),

Verify: cargo build -p xtask; docker compose config (after Step 5 lands the mounted files, otherwise compose will warn on the volumes).

Step 4: xtask dev multibackend-up / multibackend-down subcommands

Files: xtask/src/cmd/dev.rs (EDIT)

Add two variants to DevCommand enum after RestartService:

/// Bring up the `identity-multibackend` profile (Authentik + Dex + Redis dep).
/// Opt-in IdP integration backends — see plans/idp-multibackend-tests.adoc.
MultibackendUp,
/// Tear down the `identity-multibackend` profile + delete its volumes.
MultibackendDown,

Dispatch arms in pub fn run(args: DevArgs):

DevCommand::MultibackendUp => multibackend_up(),
DevCommand::MultibackendDown => multibackend_down(),

Handler fns at the bottom of the file:

fn multibackend_up() -> Result<()> {
    println!("==> Bringing up identity-multibackend profile (Authentik + Dex + Redis)...");
    println!("    Authentik first boot takes ~90s while it applies blueprints.");
    docker::compose(&["--profile", "identity-multibackend", "up", "-d"])?;
    println!("==> Multibackend profile up. Verify with:");
    println!("    cargo nextest run -p craig-test-lib --test identity_multibackend");
    Ok(())
}

fn multibackend_down() -> Result<()> {
    println!("==> Tearing down identity-multibackend profile + its volumes...");
    docker::compose(&["--profile", "identity-multibackend", "down", "-v"])?;
    Ok(())
}

Verify: cargo build -p xtask; cargo xtask dev --help lists the new subcommands.

Step 5: Pre-rendered configs

Files:

  • devstack/authentik/blueprints/craig.yaml (NEW) — generate via cargo run -p xtask --quiet — identity render --backend authentik --out devstack/authentik/blueprints/craig.yaml after Step 2 lands. Commit verbatim.

  • devstack/dex/config.yaml (NEW) — full pre-built file (content below)

  • devstack/authentik/README.md (NEW)

  • devstack/dex/README.md (NEW)

devstack/dex/config.yaml content:

# CRAIG identity bundle for Dex — devstack-only, NOT for production.
issuer: http://localhost:5556
storage:
  type: memory
web:
  http: 0.0.0.0:5556
logger:
  level: info
oauth2:
  skipApprovalScreen: true

staticClients:
  - id: craig-rules
    name: "CRAIG service: craig-rules"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-cases
    name: "CRAIG service: craig-cases"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-placement
    name: "CRAIG service: craig-placement"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-exchange
    name: "CRAIG service: craig-exchange"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-financial
    name: "CRAIG service: craig-financial"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-reporting
    name: "CRAIG service: craig-reporting"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-security
    name: "CRAIG service: craig-security"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-intake
    name: "CRAIG service: craig-intake"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-web
    name: "CRAIG service: craig-web"
    public: false
    secret: REPLACE_ME_AFTER_INSTALL
    redirectURIs: []
  - id: craig-ui
    name: "CRAIG Web UI"
    public: true
    redirectURIs:
      - http://localhost:8080/auth/callback
  - id: craig-api
    name: "CRAIG CLI / integration tests (ROPC)"
    public: true
    redirectURIs: []

enablePasswordDB: false

READMEs document: bootstrap command (cargo xtask dev multibackend-up), regeneration command, what’s verified, version pin.

Verify: docker compose config parses both YAML files cleanly. cargo xtask dev multibackend-up brings containers up healthy.

Step 6: Test-lib helper

Files: crates/craig-test-lib/src/lib.rs (EDIT)

Add pub async fn identity_multibackend_available() → bool parallel to existing devstack_available(). Probes Dex discovery + Authentik per-application discovery URL. Retry-with-timeout: 30s total, 2s interval, 3s per-probe HTTP timeout. Returns true as soon as both succeed; false if 30s elapses with either still failing.

Verify: cargo build -p craig-test-lib; cargo nextest run -p craig-test-lib --lib.

Step 7: Integration tests

Files: crates/craig-test-lib/tests/identity_multibackend.rs (NEW)

Two tests, both gated on identity_multibackend_available():

  • dex_full_client_credentials_grant — discovery → JWKS → mint craig-cases token at the rendered placeholder secret → assert non-empty access_token + token_type matches Bearer (case-insensitive)

  • authentik_full_client_credentials_grant — per-application discovery at http://localhost:9000/application/o/craig-cases/.well-known/openid-configuration → JWKS → mint token at disc["token_endpoint"] (shared /application/o/token/) with scope=openid → same shape asserts

Local dev workflow:

cargo xtask dev multibackend-up
cargo nextest run -p craig-test-lib --test identity_multibackend
cargo xtask dev multibackend-down

Verify: Both tests pass with profile up; both skip in <30s with profile down.

Step 8: Docs

Files: docs/modules/ROOT/pages/idp-integration.adoc (EDIT), .claude/docs/local-dev.md (EDIT)

idp-integration.adoc — replace the two "IaC generator shipped … community-validated" bullets with:

* *authentik* — IaC generator shipped (`cargo xtask identity render --backend authentik`). Verified end-to-end against Authentik 2026.2.2: rendered blueprint imports cleanly, OIDC discovery + JWKS + `client_credentials` grant for service-account principals all pass. Activate via `cargo xtask dev multibackend-up`; run `cargo nextest run -p craig-test-lib --test identity_multibackend`.
* *dex* — IaC generator shipped (`cargo xtask identity render --backend dex`). Verified end-to-end against Dex 2.45.1 via the same integration test.

.claude/docs/local-dev.md — add a "Multi-backend identity stack" section documenting the cargo xtask dev multibackend-up workflow + the coding-conventions.md reference for why operators activate through xtask rather than docker compose.

Verify: cargo xtask check-docs.

Step 9: CHANGELOG + commit + push + MR

Files: CHANGELOG.adoc (EDIT)

Entry under == Unreleased (DATE = merge date):

=== feat(xtask, devstack, craig-test-lib): Authentik + Dex multi-backend identity integration tests (DATE)

Plan E follow-up. Closes the gap between Plan E's "IdP-neutral" architectural claim and what was actually verified. Three backends now exercised end-to-end:

* Keycloak (existing devstack default + 203/203 E2E)
* Authentik (new — full client_credentials grant via the rendered blueprint)
* Dex (new — full client_credentials grant via staticClients)

Devstack gains opt-in `identity-multibackend` profile bringing up Authentik (server + worker + Redis dep) and Dex. Pre-rendered configs at `devstack/{authentik,dex}/` are committed. Profile activation via the new `cargo xtask dev multibackend-up` / `multibackend-down` subcommands.

`cargo xtask identity render --backend authentik` extended: each `oauth2provider` now references Authentik's pre-shipped flows + signing-key + scope-mappings via `!Find` lookups + carries `redirect_uris: []` + `sub_mode: hashed_user_id`. Closes the renderer limitation Plan E Step 13 documented. 4 new inline renderer unit tests pin each new attribute.

Two integration tests at `crates/craig-test-lib/tests/identity_multibackend.rs` verify discovery + JWKS + `client_credentials` grant per backend. Tests skip cleanly when the profile isn't running.

Default `cargo xtask dev start` is unchanged — profile stays opt-in.

`docs/modules/ROOT/pages/idp-integration.adoc` claim flipped: "community-validated" → "verified end-to-end".

Then 8-Q pre-commit walkthrough; cargo fmt --all && cargo clippy --workspace --all-targets — -D warnings; cargo nextest run --workspace; cargo xtask check-docs; commit + push (pre-push runs validate); open MR via glab mr create; force-merge via glab api projects/…​/merge_requests/<id>/merge -X PUT per Plan E session pattern.

Verify: MR merges; main fast-forwards.

Files Touched

File Change

docs/modules/ROOT/pages/plans/idp-multibackend-tests.adoc

NEW — canonical plan in AsciiDoc per _template.adoc

docs/modules/ROOT/nav.adoc

EDIT — add Active xref to the new plan

xtask/src/cmd/identity/render/authentik.rs

EDIT — extend per-service provider emission; 4 new inline unit tests

docker-compose.yml

EDIT — add 4 services under profiles: [identity-multibackend]

devstack/postgres/init.sql

EDIT — CREATE DATABASE authentik; appended

xtask/src/docker.rs

EDIT — PORT_MAPPINGS extended with 4 tuples

xtask/src/cmd/dev.rs

EDIT — MultibackendUp / MultibackendDown subcommands

devstack/authentik/blueprints/craig.yaml

NEW — generated by extended renderer post-Step-2

devstack/authentik/README.md

NEW — bootstrap + regeneration + verified-behavior + version pin

devstack/dex/config.yaml

NEW — full pre-built file (overlay + staticClients)

devstack/dex/README.md

NEW — bootstrap + regeneration

crates/craig-test-lib/src/lib.rs

EDIT — identity_multibackend_available() helper

crates/craig-test-lib/tests/identity_multibackend.rs

NEW — 2 integration tests

docs/modules/ROOT/pages/idp-integration.adoc

EDIT — claim flip

.claude/docs/local-dev.md

EDIT — multi-backend section

CHANGELOG.adoc

EDIT — entry under == Unreleased

Verification

  1. cargo build --workspace --all-features — no compile breakage

  2. cargo clippy --workspace --all-targets — -D warnings — clean

  3. cargo fmt --all --check — clean

  4. cargo xtask check-docs — Tier 1 docs untouched; new plan + nav.adoc render

  5. cargo nextest run -p xtask --bin xtask authentik — extended renderer tests pass

  6. cargo nextest run --workspace (profile NOT running) — existing 1954 tests still pass; the 2 new integration tests skip cleanly within ~30s each

  7. cargo xtask dev multibackend-up — Redis + Authentik (server + worker) + Dex come up healthy (allow ~90s for Authentik first-boot)

  8. Smoke: curl http://localhost:5556/.well-known/openid-configuration returns valid OIDC discovery

  9. Smoke: curl http://localhost:9000/application/o/craig-cases/.well-known/openid-configuration returns valid OIDC discovery

  10. Smoke: curl -X POST http://localhost:5556/token -d 'grant_type=client_credentials&client_id=craig-cases&client_secret=REPLACE_ME_AFTER_INSTALL' mints a Dex token

  11. Smoke: curl -X POST http://localhost:9000/application/o/token/ -d 'grant_type=client_credentials&client_id=craig-cases&client_secret=REPLACE_ME_AFTER_INSTALL&scope=openid' mints an Authentik token

  12. cargo nextest run -p craig-test-lib --test identity_multibackend — both tests pass

  13. cargo xtask dev multibackend-down — clean teardown including volumes

Documentation Updates

  • docs/modules/ROOT/pages/plans/idp-multibackend-tests.adoc (NEW canonical plan; created in Step 1)

  • docs/modules/ROOT/nav.adoc (NEW xref in Active section; Step 1)

  • docs/modules/ROOT/pages/idp-integration.adoc (claim flip; Step 8)

  • .claude/docs/local-dev.md (multi-backend section; Step 8)

  • devstack/authentik/README.md (NEW; Step 5)

  • devstack/dex/README.md (NEW; Step 5)

  • CHANGELOG.adoc (entry under == Unreleased; Step 9)

  • Plan-archive on completion — when MR merges: move nav.adoc xref from Active to Archive; add row to docs/modules/ROOT/pages/plans/archive.adoc § Security & Compliance with the MR number; update plan Status table → all Complete.

Risks

Risk Mitigation

Authentik blueprint apply takes 60-90s on first boot

start_period: 90s on the healthcheck; identity_multibackend_available() does 30s retry-with-timeout from the test side; README documents the delay

Authentik fixture names change in a future image version

Pin to 2026.2.2 in compose; devstack/authentik/README.md § Version pin lists every fixture name we reference + bump procedure

Dex client_credentials against the rendered placeholder secret doesn’t match what the test sends

Both sides use REPLACE_ME_AFTER_INSTALL (IdentityModel::devstack_defaults()::placeholder_service_secret in xtask/src/cmd/identity/model.rs); test secret + rendered secret are the same constant by construction

Adding Redis collides with future "real" Redis cache work

Profile-gated — redis only runs when identity-multibackend profile is up. A future general-purpose Redis MR moves it out of the profile or scopes naming

Authentik image is ~600MB Debian-based, not Alpine

coding-conventions.md § Container Runtime mandates Alpine for CRAIG-authored images. Third-party images we consume are governed by upstream. Documented in the compose comment block + this Risks row

Authentik worker fails before postgres ready

depends_on: postgres: { condition: service_healthy }

The renderer’s !Find references break on a future Authentik version that renames fixtures

Pin Authentik image; document fixture names in README; bump test matrix when upgrading

Importer.apply() returning silent-success on validation failure

Sandbox found this gotcha. Integration tests + healthcheck retries catch any post-deployment regression: if the blueprint import silently fails again, the discovery URL probe will 404 and the helper returns false

Dex 2.45.1 schema differs from our hand-built config

Hand-built file uses stable Dex 2.x top-level keys (issuer/storage/web/oauth2/staticClients). Verification step 8 catches a real mismatch

After this MR lands

  • IdP-neutral claim backed by 3 backends actually tested end-to-end (Keycloak + Authentik + Dex)

  • idp-integration.adoc text describes verified behavior, not aspirations

  • Future renderer changes get caught earlier — if a renderer edit breaks Authentik or Dex’s expected input shape, the integration tests fail in pre-push (when run locally with profile up)

  • Devstack baseline stays the same size for everyone not running the profile

  • Plan moves to archive per coding-conventions.md § Plan Lifecycle

Edit this page · latest