Projects / recap
Aros/Session recap/
Sprint 1.5

Sprint 1.5 — Auth Foundation + Frontend Shell

Two phases in one sprint: closed the SSO loop end-to-end through Authentik + Entra, then shipped a typed React shell behind the same gate. Five silent failures resolved along the way.

Two days, multi-session
1.5a: c2b1721 · 1.5b: f8e4a193
authentik
oauth2-proxy
traefik
forward-auth
oidc
entra
vite
react
tanstack-router
shadcn
fastapi
alembic
ansible
the hero

The SSO handshake, end to end

Five swim lanes, nine messages. The browser hits Traefik, gets bounced through oauth2-proxy and Authentik, lands at Entra for the federated identity, comes back with an id_token, exchanges it for a session cookie, and the original request finally proceeds. Phase A was getting this loop to close without the browser ever seeing a raw 401 — four of the sprint’s silent failures live somewhere along this path.

browser user Traefik aros-edge-1 :80 oauth2-proxy forward-auth :4180 Authentik aros-edge-1 :9443 Microsoft Entra login.microsoftonline.com GET aros-dev.ryanit.com forwardAuth (X-Forwarded-*) 302 → /oauth2/start GET /authorize?... OIDC redirect (federation) id_token + email_verified 302 → oauth2-proxy/callback callback + code Set-Cookie _oauth2_proxy
response / cookie browser request service-to-service
What landed

From headless backend to logged-in browser app

Sprint 1 left the platform with a working /health/db but no concept of identity and no UI. Sprint 1.5 closed both gaps in two phases — auth in Phase A, the application shell in Phase B — over two days of focused work.

New VMs
7

1 edge + 3 cache + 3 frontend; estate now 20

ADRs accepted
4

0009 frontend · 0012 Authentik · 0013 edge · 0014 Redis

Silent failures resolved
5

four in Phase A, one in Phase B

Federated IdPs linked
1

Microsoft Entra → akadmin (bonus)

The narrative

Day 1 evening through Day 2 close

Phase A took most of two days because every failure mode in the auth stack arrives as a successful HTTP response doing the wrong thing — empty bodies, missing claims, mis-quoted templates. Phase B took one focused session, four CI iterations, and a post-deploy backend fix.

  1. Day 1 · evening

    Sprint 1 closed; Sprint 1.5 plan drafted

    Tag sprint-1-uat-green at 570596d. ai-context/08-sprint-1.5-plan.md splits Phase A (auth + infra) from Phase B (frontend).
  2. Day 1 · night

    7 new VMs provisioned + base-hardened

    Four commits across aros-infrastructure (meta/dev/test/uat stacks), one per env for clean plan reviews. Ansible base playbook applied verbatim — SSH keys, ufw, Docker, qemu-guest-agent.
  3. Day 2 · morning · A

    aros-edge.yml — Authentik install

    New playbook spins up PostgreSQL 16 + Redis 7 (loopback only) + Authentik server/worker via Docker Compose with network_mode: host.
  4. Day 2 · morning · A

    First Authentik visit returns empty body

    http://192.168.1.241:9000/ returns 200 with no content. Curl confirms empty body. Authentik's HTTP listener is unusable behind a reverse proxy.
  5. Day 2 · morning · A

    Pivoted to HTTPS 9443 + insecureSkipVerify

    mvm Traefik route points at https://192.168.1.241:9443 with insecureSkipVerify=true (bundled self-signed cert). authentik.ryanit.com loads. Admin UI usable.
  6. Day 2 · midday · A

    Web UI: flow, provider, MFA configured

    Default flow: identification → password → authenticator validation (TOTP + WebAuthn + Static). OAuth2 Confidential provider with redirect URIs for all three envs. Application slug "aros-backend".
  7. Day 2 · midday · A

    Microsoft Entra federation (bonus side-quest)

    OAuth/OIDC federation source: user_matching_mode=email_link, user_path_template=users. Entra credentials vaulted at secret/aros/authentik/entra. Federated identity links to akadmin on first login.
  8. Day 2 · afternoon · A

    aros-edge.yml extended — oauth2-proxy + Traefik

    /etc/aros-edge/oauth2-proxy.env (root:hlvm 0640 per Sprint 1 lesson). /opt/aros-edge/proxy/ compose stack with both containers on a shared bridge. Traefik file-provider config wires per-env routers + forward-auth middleware.
  9. Day 2 · afternoon · A

    Cookie secret bug — 44 chars vs 32 bytes

    oauth2-proxy refuses to start. openssl rand -base64 32 → 44 ASCII chars; oauth2-proxy reads it as RAW bytes, not base64. Regenerated with openssl rand -hex 16 → 32 bytes valid.
  10. Day 2 · afternoon · A

    email_verified missing — SSO loop never closes

    Browser auth completes, oauth2-proxy 403s with "email not verified". Authentik omits the claim entirely. Added OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true.
  11. Day 2 · afternoon · A

    Traefik errors template — the "Found." page

    Browser hits /api/v1/me, sees blank page with just "Found." Traefik errors middleware only substitutes status and url placeholders; scheme/host pass through as literals and break the redirect. Shortened to /oauth2/start?rd=<url>.
  12. Day 2 · late · A

    aros-backend Step 6 — auth middleware + identity

    Commit 65bf1ca. middleware/auth.py reads X-Auth-Request-* (plan said X-Forwarded-* but that's only oauth2-proxy proxy-mode). Identity module + migration 0002 (identity.user, gen_random_uuid() PK).
  13. Day 2 · late · A

    Per-env DB split discovered + fixed

    aros-backend in dev pointed at aros_dev; test/UAT had been doing the same. Created aros_backend_test/uat roles + aros_test/uat DBs; rewrote /etc/aros-backend/env root:hlvm 0640 on all 9 app VMs; force-recreated containers (docker restart does NOT re-read env_file).
  14. Day 2 · late · A

    mvm Traefik patched + hello-* routes pruned

    aros-dev/test/uat.ryanit.com forward to aros-edge-1:80 with X-Forwarded-Proto=https. Hello-world Traefik routes pruned. Hub-1 redeployed with the Sprint 1 recap as a bonus.
  15. Day 2 · close · A

    End-to-end browser auth + tag sprint-1.5a-uat-green

    Hit aros-dev.ryanit.com → Authentik → Entra or password+MFA → land on /api/v1/me with the upserted user. Three ADRs landed: 0012 Authentik, 0013 edge topology, 0014 per-env Redis. Tag at c2b1721.
  16. Day 2 · later · B

    Phase B kickoff: aros-frontend repo + scaffold

    Operator created Forgejo repo + cloned. CC scaffolded 46 files, 1689 lines in initial commit 343216f: Vite 5 + React 18 + TS strict + TanStack Router (scoped @tanstack/*) + TanStack Query + shadcn (Radix) + Tailwind + openapi-fetch + vitest.
  17. Day 2 · later · B

    CI iteration cycle — 4 passes to fully-green

    First pass: prettier reformat on 8 files. Second: react-refresh warnings on button.tsx + theme.tsx, plus typecheck cascade after routeTree.gen.ts regeneration (Sidebar narrowing, missing vite-env.d.ts). Third: file splits (button → button-variants, theme → use-theme). Fourth: clean. Installed Node 22 standalone in WSL to run prettier locally.
  18. Day 2 · later · B

    Deploy to dev (.175), then test + UAT

    Operator stopped native nginx on each FE VM (Phase A playbook installed it; container needs port 80; hlvm can't sudo non-interactively). Container deploys clean across all three.
  19. Day 2 · late · B

    /api/docs returned 404 — backend fix

    API Explorer page links work, but every link returns FastAPI's default 404 body. Cause: FastAPI defaults docs to /docs, but Traefik only routes /api/* to the backend. One-line backend fix (commit 2db1de2) — pass docs_url=/api/docs etc. to the FastAPI constructor.
  20. Day 2 · close · B

    ADR-0009 + tag sprint-1.5b-uat-green

    Frontend stack ADR landed. Tag at aros-frontend f8e4a19. Sprint 1.5 closed across both phases. Sprint 2 plan at ai-context/09-sprint-2-plan.md — audit, events, tenant foundation per Build Plan §4.
Challenges

Five silent failures, in the order they bit

Each of these was easy to miss because the failure mode is a successful HTTP response doing the wrong thing. Captured so the next operator doesn't repeat them.

Authentik's HTTP :9000 listener returns empty bodies behind a reverse proxy

authentiktraefiktls Moderate
Symptom

First browser visit to http://192.168.1.241:9000/: page loads, returns 200, body is empty. curl -i confirms Content-Length: 0. Same behaviour through mvm Traefik with HTTP upstream.

Root cause

Authentik’s HTTP listener checks Host / X-Forwarded-Host headers strictly and silently 200-empty-bodies any request that doesn’t match its trusted-hosts allow-list. The plain HTTP path is configured for direct LAN only and falls apart the moment a real proxy is in front. The HTTPS listener on :9443 doesn’t do this.

Resolution

Switched the mvm Traefik route for authentik.ryanit.com to forward to https://192.168.1.241:9443 with serverTransport.insecureSkipVerify=true. Browser loads. Tracked debt: replace self-signed with a cert from the same Cloudflare resolver as mvm.

oauth2-proxy cookie secret needs raw 16/24/32 bytes — not base64 of 32 bytes

oauth2-proxycrypto Major
Symptom

oauth2-proxy refused to start: cookie_secret must be 16, 24, or 32 bytes to create an AES cipher. The generated secret was openssl rand -base64 32 — 44 ASCII characters.

Root cause

oauth2-proxy reads OAUTH2_PROXY_COOKIE_SECRET as raw bytes from the env string — not as a base64 / hex / anything-decoded value. The byte length is the character length of the env var. A 44-character string is 44 bytes, which is none of the AES key sizes.

Resolution

Regenerated with openssl rand -hex 16 → 32 hex chars = 32 bytes (AES-256). Patched into Vault at secret/aros/authentik/oauth2-proxy:cookie-secret. Playbook header now spells out the encoding requirement.

Authentik OIDC tokens omit email_verified — oauth2-proxy rejects the callback

authentikoauth2-proxyoidc Major
Symptom

Browser authenticates with Entra (or password + MFA), redirects to /oauth2/callback?code=...&state=..., oauth2-proxy logs unverified email, returns 403, browser shows a generic “Forbidden” page. SSO loop never closes.

Root cause

Authentik does not emit the email_verified claim in OIDC tokens by default — the claim is absent, not false. oauth2-proxy’s default policy treats an absent email_verified as unverified, refuses the callback. Authentik considers email verification a property of the Authentication Flow (a separate Email Verification stage you opt into), not a baseline OIDC claim.

Resolution

Added OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true to the env file. SSO loop closes. The flag name is correctly scary — proper fix is to add an Email Verification stage to Authentik’s authentication flow. Tracked debt; flagged in ADR-0012.

Traefik errors middleware: only status and url substitute — surfaces as a "Found." page

traefikoauth2-proxyforward-auth Moderate
Symptom

After the cookie + email fixes, hitting /api/v1/me cookie-less should redirect to Authentik. Instead: blank page with the literal text “Found.” View-source confirms the body is just Found.

Root cause

The Traefik errors middleware’s query field uses {status} and {url} as its only template variables. The plan said /oauth2/start?rd={scheme}://{host}{url} — but {scheme} and {host} pass through as literals. oauth2-proxy received rd={scheme}://{host}/api/v1/me, treated the literal-brace string as a redirect target, issued a 302 with a malformed Location header. Browsers show the body of a 302 they can’t follow — most servers default that body to Found. (the HTTP 302 reason phrase).

Resolution

Shortened to /oauth2/start?rd={url}{url} is path + query, which is everything oauth2-proxy needs. oauth2-proxy reconstructs the absolute URL from X-Forwarded-*. SSO loop closed on the next attempt.

/api/docs returned 404 after the first successful frontend deploy

fastapitraefikrouting Moderate
Symptom

Application shell loaded fine in the browser. Sidebar nav worked. Dashboard fetched /api/v1/me and got a 200 with the OIDC user. But every link from the API Explorer page — “Open Swagger UI”, “Raw OpenAPI JSON”, “ReDoc” — opened a tab returning {"detail": "Not Found"} (FastAPI’s default 404 body).

Root cause

FastAPI’s defaults for those three surfaces are /docs, /openapi.json, /redoc — no /api prefix. aros-edge-1’s Traefik routes /api/* to the backend cluster and /* to the frontend. So the request for /api/docs reached the backend (good), but the backend had no route at /api/docs (bad) — FastAPI 404’d. The un-prefixed /docs would have hit the frontend’s catch-all router and returned the SPA’s index.html, also wrong.

Resolution

One-line fix in aros-backend’s main.py: pass docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json" to the FastAPI constructor (commit 2db1de2). Same auth gate applies — opening Swagger UI inherits the existing OIDC session via the oauth2-proxy cookie.

Stack

Everything that landed in Sprint 1.5

On top of Sprint 1's FastAPI + SQLAlchemy + Patroni + Woodpecker + mvm Traefik. The auth, edge, and frontend tiers are entirely new.

Identity
Authentik 2026.5OAuth2 / OIDC providerMicrosoft Entra (federation)TOTP / WebAuthn / Static codes
Edge auth
oauth2-proxy v7.6.0Traefik v3.2 (HTTP only)forwardAuth middlewareerrors-on-401 middlewareSET_XAUTHREQUEST headers
Edge data
PostgreSQL 16 (local, loopback)Redis 7 (local, loopback)
Backend
FastAPI middleware (auth)SQLAlchemy declarative + ORMAlembic migration 0002gen_random_uuid() (PG core)
Frontend
Vite 5React 18TypeScript 5 strict@tanstack/react-router v1@tanstack/react-query v5shadcn/ui (Radix variant)Tailwind 3.4openapi-fetch + openapi-typescriptlucide-reactVitest + Testing Library
Runtime
nginx:alpine (FE runtime)Docker Composenetwork_mode: host (Authentik)
Configuration
Ansible playbooks/aros-edge.ymlVault secret/aros/authentik/*mvm Traefik patch
Proof

SSO loop, then the shell, then the docs

One curl chain through the SSO redirect → /api/v1/me. Then the shell + /api/docs after the post-deploy backend fix.

SSO loop — cookie-less redirect chain → cookied /api/v1/me
bash
# Step 1: no cookie → forward-auth says 401 → errors-mw rewrites
$ curl -isS -H "Host: aros-dev.ryanit.com" \
  http://192.168.1.241/api/v1/me | head -3
HTTP/1.1 302 Found
Location: /oauth2/start?rd=/api/v1/me

# Step 2: /oauth2/start → 302 to Authentik authorize
$ curl -isS -H "Host: aros-dev.ryanit.com" \
  "http://192.168.1.241/oauth2/start?rd=/api/v1/me" | head -3
HTTP/1.1 302 Found
Location: https://authentik.ryanit.com/application/o/authorize/?...

# (Browser does the human dance: Entra OR password+MFA,
#  Authentik redirects to /oauth2/callback, oauth2-proxy
#  validates the code and sets the _oauth2_proxy cookie.)

# Step 3: with the post-login cookie, the protected path returns 200
$ curl -isS --cookie "_oauth2_proxy=<session>" \
  -H "Host: aros-dev.ryanit.com" \
  http://192.168.1.241/api/v1/me | head -8
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 01HZK7...
X-Auth-Request-User: akadmin
X-Auth-Request-Email: akadmin@ryanit.com
X-Auth-Request-Groups: authentik Admins
X-Auth-Request-Preferred-Username: Aros Admin

{"id":"<uuid>","username":"akadmin","email":"akadmin@ryanit.com",
"display_name":"Aros Admin","groups":["authentik Admins"],
"created_at":"2026-05-25T...","last_seen_at":"2026-05-25T..."}
shell + docs reach after the docs-URL fix (2db1de2)
bash
# Shell: SPA + assets served via /
$ curl -isS https://aros-dev.ryanit.com/ | head -3
HTTP/2 200
content-type: text/html
cache-control: no-store, must-revalidate

# /api/docs after the FastAPI docs_url fix
$ curl -isS --cookie "_oauth2_proxy=<session>" \
  https://aros-dev.ryanit.com/api/docs | head -2
HTTP/2 200
content-type: text/html; charset=utf-8

# /api/openapi.json reachable, schema visible
$ curl -sS --cookie "_oauth2_proxy=<session>" \
  https://aros-dev.ryanit.com/api/openapi.json \
  | jq -r '.info.title, .info.version'
Aros Backend
0.2.0

# /health/* bypasses the auth gate (priority 60 routers on aros-edge-1)
$ curl -sS https://aros-dev.ryanit.com/health/db | jq -r '.leader, .host'
192.168.1.231
aros-dev-2
What's next

Sprint 2 — Audit, Events, Tenant Foundation

With identity + UI in place, Sprint 2 builds the compliance + isolation substrate every later sprint leans on: hash-chained audit log, per-env Redis event bus, multi-tenant tenant_id + RLS.

Sprint 2 entry conditions
  • Identity available for the actor field — Sprint 1.5a /api/v1/me
  • Per-env Redis VMs provisioned + base-hardened — Sprint 1.5a §1
  • Frontend in place for the tenant context provider — Sprint 1.5b
  • Per-env Redis CONFIGURED — aros-cache.yml playbook still TODO (Sprint 2 pre-flight, blocks core.events)
  • MinIO deployed for audit external seal — operator placement decision (Sprint 2 pre-flight)
  • Sprint 2 plan written at ai-context/09-sprint-2-plan.md — four phases + pre-flight
Tracked debt added this sprint

What we chose not to fix tonight

Four of these are deliberate trade-offs called out in the ADRs (0009 / 0012 / 0013 / 0014). The rest are operational discoveries that need follow-up before Sprint 2 leans on them.

  • auth·insecureSkipVerify in two places — mvm Traefik → authentik.ryanit.com:9443, and oauth2-proxy → Authentik OIDC issuer. Replace with a cert from the same Cloudflare resolver, or an internal CA. Per ADR-0012 / ADR-0013.
  • auth·email_verified workaround — INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true on oauth2-proxy because Authentik doesn’t emit the claim. Proper fix is an Email Verification stage in the Authentik flow. Per ADR-0012.
  • auth·Single Authentik instance, no HA. Lose aros-edge-1 and the estate loses auth. Break-glass: recovery admin at secret/aros/authentik/bootstrap. Per ADR-0012.
  • auth·Authentik configured by hand — Terraform-Authentik-provider migration tracked. Per ADR-0012.
  • edge·aros-edge-1 single VM + plain-HTTP hop from mvm. Trust-the-LAN compromises waiting on VLAN segmentation + internal TLS. Per ADR-0013.
  • data·aros-cache-dev/test/uat-1 unconfigured — aros-cache.yml playbook + UFW per-env lockdown land in Sprint 2 pre-flight. Sprint 2’s core.events module is the first consumer.
  • data·1s AOF loss window per Redis. core.events must be designed around at-most-1s-loss; not exactly-once. Per ADR-0014.
  • ops·hlvm sudo policy. hlvm can run docker (group member) but lacks non-interactive sudo for everything else. Bit Sprint 1’s hello decommission AND Sprint 1.5b’s native-nginx removal. Candidate for a narrow NOPASSWD sudoers drop-in via Ansible base.
  • ops·X-Auth-Request-* vs X-Forwarded-*. The Sprint 1.5 plan said backend reads X-Forwarded-User; that’s only oauth2-proxy proxy-mode. In forward-auth mode (what we ship), the headers are X-Auth-Request-*. Plan text now stale; backend middleware reads the right family.
  • fe·aros-frontend operational items (per ADR-0009): no package-lock.json (CI on npm install); schema.d.ts hand-stubbed pending live regen; routeTree.gen.ts committed but auto-generated; no Playwright e2e; no bundle-size budget; native nginx on FE VMs stopped manually for the first deploy.