Plan: Multi-Backend Identity Integration Tests
On this page
- Status
- Context
- Scope
- Design
- Steps
- Step 1: File the canonical plan + GitLab issue + nav entry
- Step 2: Authentik renderer extension
- Step 3: Devstack compose + ports + Postgres DB
- Step 4: xtask dev multibackend-up / multibackend-down subcommands
- Step 5: Pre-rendered configs
- Step 6: Test-lib helper
- Step 7: Integration tests
- Step 8: Docs
- Step 9: CHANGELOG + commit + push + MR
- Files Touched
- Verification
- Documentation Updates
- Risks
- After this MR lands
|
Revision 2026-05-12 (post-!266). Dex was stripped from this plan after !266 merged. Plan E (service identity) made the |
Status
| Step | Description | Status |
|---|---|---|
1 |
File canonical |
Done (2026-05-12) — MR !266 |
2 |
Renderer extension in |
Done (2026-05-12) — MR !266 |
3 |
Devstack compose + ports + DB — |
Done (2026-05-12) — MR !266 (; Dex entries stripped in successor MR) |
4 |
|
Done (2026-05-12) — MR !266 |
5 |
Pre-rendered configs committed at |
Done (2026-05-12) — MR !266 (; |
6 |
|
Done (2026-05-12) — MR !266 |
7 |
Authentik integration test at |
Done (2026-05-12) — MR !266 (; Dex test removed in successor MR) |
8 |
Docs flip in |
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 |
Done (2026-05-12) — MR !267 |
11 |
Phase 0 sandbox validation — Kanidm (Rust-aligned, primary) and ZITADEL (fallback) tested in throwaway compose. Verify |
Done (pre-ADR-030). Findings: Kanidm |
12 |
MR-B: ZITADEL backend — |
In progress (this MR) |
13 |
Plan F filed — |
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:
-
OIDC discovery + JWKS validation being vendor-neutral by spec
-
Rendered IaC having the shape each vendor’s spec describes
-
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!Findreferences (3 flow refs + signing key + scope mappings) +sub_mode: hashed_user_id. Per sandbox validation these are required forclient_credentialsto work on Authentik 2026.2. -
Devstack
identity-multibackendDocker Compose profile with 4 services (redis + authentik-server + authentik-worker + dex). Opt-in only; defaultcargo xtask dev startunchanged. -
cargo xtask dev multibackend-up/multibackend-downsubcommands — convention-mandated wrapper so users don’t invokedocker composedirectly. -
Pre-rendered devstack configs at
devstack/authentik/blueprints/craig.yamlanddevstack/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_credentialsflow on each backend. -
Documentation:
idp-integration.adocclaim flip;local-dev.mddocumentscargo xtask dev multibackend-up. -
CHANGELOG.adocentry.
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:
-
Authentik 2026.2 requires
redirect_uris: []onoauth2providereven forclient_credentials-only flows. Bare provider import failed withSerializer errors {'redirect_uris': [ErrorDetail(string='This field is required.', code='required')]}. The error only surfaces viaImporter.from_string(…).validate()— the worker’sapply_blueprinttask reports "Task finished" successfully but the entries are silently skipped. -
5
!Findreferences required to wire authentication + authorization + invalidation flows + signing key + scope mappings (the renderer’s current output is missing all 5). -
No
type: service_accountuser needed — provider’sclient_secretalone authenticates theclient_credentialsgrant. -
All fixture names verified working in Authentik 2026.2.2 (table below).
-
Discovery URL is per-application; token endpoint is shared.
-
Grant request requires
scope=openid.
| Authentik fixture | !Find key |
Verified value (2026.2.2) |
|---|---|---|
Authentication flow |
slug |
|
Authorization flow (for client_credentials) |
slug |
|
Invalidation flow |
slug |
|
Signing keypair |
name |
|
Scope mapping (openid) |
scope_name |
|
Scope mapping (email) |
scope_name |
|
Scope mapping (profile) |
scope_name |
|
URLs (Authentik 2026.2.2):
-
Discovery (per-application):
http://<host>:<port>/application/o/<slug>/.well-known/openid-configuration -
Token endpoint (shared across providers):
http://<host>:<port>/application/o/token/ -
JWKS URI: read from the discovery doc’s
jwks_urifield -
Issuer: from discovery doc’s
issuerfield (typicallyhttp://<host>:<port>/application/o/<slug>/with trailing slash)
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 foridentity_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-660observability profile —profiles: [identity-multibackend]mimics this structure -
xtask/src/cmd/dev.rs::DevCommand::RestartService— convention pattern for "subcommand that wraps adocker composeoperation".MultibackendUp/MultibackendDownfollow this exactly -
xtask/src/docker.rs::PORT_MAPPINGSconstant — 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 viacargo run -p xtask --quiet — identity render --backend authentik --out devstack/authentik/blueprints/craig.yamlafter 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 → mintcraig-casestoken at the rendered placeholder secret → assert non-emptyaccess_token+token_typematchesBearer(case-insensitive) -
authentik_full_client_credentials_grant— per-application discovery athttp://localhost:9000/application/o/craig-cases/.well-known/openid-configuration→ JWKS → mint token atdisc["token_endpoint"](shared/application/o/token/) withscope=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 |
|---|---|
|
NEW — canonical plan in AsciiDoc per |
|
EDIT — add Active xref to the new plan |
|
EDIT — extend per-service provider emission; 4 new inline unit tests |
|
EDIT — add 4 services under |
|
EDIT — |
|
EDIT — |
|
EDIT — |
|
NEW — generated by extended renderer post-Step-2 |
|
NEW — bootstrap + regeneration + verified-behavior + version pin |
|
NEW — full pre-built file (overlay + staticClients) |
|
NEW — bootstrap + regeneration |
|
EDIT — |
|
NEW — 2 integration tests |
|
EDIT — claim flip |
|
EDIT — multi-backend section |
|
EDIT — entry under |
Verification
-
cargo build --workspace --all-features— no compile breakage -
cargo clippy --workspace --all-targets — -D warnings— clean -
cargo fmt --all --check— clean -
cargo xtask check-docs— Tier 1 docs untouched; new plan + nav.adoc render -
cargo nextest run -p xtask --bin xtask authentik— extended renderer tests pass -
cargo nextest run --workspace(profile NOT running) — existing 1954 tests still pass; the 2 new integration tests skip cleanly within ~30s each -
cargo xtask dev multibackend-up— Redis + Authentik (server + worker) + Dex come up healthy (allow ~90s for Authentik first-boot) -
Smoke:
curl http://localhost:5556/.well-known/openid-configurationreturns valid OIDC discovery -
Smoke:
curl http://localhost:9000/application/o/craig-cases/.well-known/openid-configurationreturns valid OIDC discovery -
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 -
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 -
cargo nextest run -p craig-test-lib --test identity_multibackend— both tests pass -
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 & Compliancewith the MR number; update plan Status table → all Complete.
Risks
| Risk | Mitigation |
|---|---|
Authentik blueprint apply takes 60-90s on first boot |
|
Authentik fixture names change in a future image version |
Pin to 2026.2.2 in compose; |
Dex |
Both sides use |
Adding Redis collides with future "real" Redis cache work |
Profile-gated — |
Authentik image is ~600MB Debian-based, not Alpine |
|
Authentik worker fails before postgres ready |
|
The renderer’s |
Pin Authentik image; document fixture names in README; bump test matrix when upgrading |
|
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 ( |
After this MR lands
-
IdP-neutral claim backed by 3 backends actually tested end-to-end (Keycloak + Authentik + Dex)
-
idp-integration.adoctext 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