Phase 9 — Forge OAuth login + dual-auth (D-18) #10

Open
opened 2026-05-21 18:36:37 +00:00 by mik-tf · 0 comments
Owner

Parent: hero_onboarding#1 (paid-tier overlay milestone)
Decision lock: decisions/D-18-dual-auth-model.md
Status: implemented + smoke-green; squash-merge pending boss OK.

Scope

Add Forge OAuth as the default login mechanism on hero_onboarding, alongside the D-12 mycelium proof-of-control flow. Per-User row carries two optional identity slots (forge_id? + mycelium_address?); at least one populated. Snapshot forge_id onto VmAllocation rows at allocate time so cockpit's future Forge-OAuth SSO bridge can gate "is this VM yours?" without an OSIS lookup.

What landed

  1. SchemaUser.mycelium_address becomes optional; User.forge_id? gets @index annotation. VmAllocation gains forge_id?: str. Docstring rewritten for dual-auth.
  2. New module crates/hero_onboarding_server/src/forge_oauth.rs (~360 LOC + 11 unit tests). Forgejo OAuth2 v1 wire: PKCE-S256 + HMAC-SHA256-signed state token (5-min TTL, bundles PKCE verifier — cookie-free). Userinfo via server-side GET /api/v1/user. Access tokens single-use, never persisted.
  3. AppState extensionforge_oauth: Option<Arc<ForgeOAuthConfig>>. Registration-aware: server starts cleanly if CLIENT_ID/SECRET missing; routes return 503; login page hides Forge button.
  4. 5 new routes: GET /login/forge/start, GET /login/forge/callback, GET /account, POST /account/link-forge, POST /account/unlink-forge (defensive 409 if it would orphan the user row).
  5. Session cookie refactor — value is now user.sid (was mycelium_address). All cookie-read sites go through resolve_user_from_cookie(osis, value) which accepts three formats: bare sid (canonical), mycelium address with auto-create (smoke compat), fid:<forge_id> prefix (smoke convenience).
  6. VmAllocation forge_id snapshotvm_allocate_post reads user.forge_id and writes onto the new allocation row post-provisioner.allocate(). Snapshot, not foreign-key.
  7. Login UI dual-card + Dashboard identity-aware "Logged in as" + auth banner (Forge / mycelium / dual-linked).
  8. CLI FORWARDED_ENV +5 keys.
  9. Admin users page gains forge_id column.

Q lock summary

# Q Locked
Q#1 Redirect URI? Local-dev only for s2-011: http://127.0.0.1:9920/login/forge/callback. Prod redirect at s2-012.
Q#2 One app for dev+prod, or one each? One per environment — separate Forge OAuth apps for dev/prod.
Q#3 Scope minimum? read:user.
Q#4 OAuth2 vs OIDC? OAuth2 + PKCE-S256 + HMAC state — smaller attack surface than OIDC; equivalent identity guarantees. OIDC layers on later if stateless cross-service verification becomes a need.

Acceptance

  • cargo test workspace: 62/62 (53 server + 9 schema; +11 new forge_oauth unit tests).
  • lab build --release --install --workspace VICTORY 3/3 (24.6s build #12).
  • lab infocheck 3/3 clean / 0 findings.
  • cargo fmt --check + cargo clippy --workspace --all-targets -- -D warnings clean.
  • scripts/smoke_forge_oauth.sh 29/29 GREEN (new — Python mock Forge OAuth server).
  • scripts/smoke_dual_auth.sh 15/15 GREEN (new — end-to-end dual-identity + allocation snapshot).
  • All existing regression smokes green: vm_allocate 35/35, kyc 27/27, payments 26/26, aggregate 28/28, usage_push 24/24, pool_refresh 11/11, login 10/10 (one assertion updated to expect sid-form cookie).

Totals: 62 unit + 205 smoke checks across 9 scripts, all green.

Phase B findings (s2-011)

  • @index in oschema annotates the previous field, not the next. Discovered while indexing forge_id — initial placement before the field accidentally indexed kyc_verified_at. Confirmed via existing schemas (consumed_record_index.oschema, usage_record.oschema).
  • mycelium_address: strOption<String> is forward-compatible (existing rows keep their value, new Forge-only rows omit). Few user.mycelium_address call sites required .as_deref().
  • Smoke cookie compat: 3 existing smokes forged cookies with mycelium addresses; multi-format resolve_user_from_cookie preserves their working state without rewrite.

D-NN / L-NN

  • D-18 minted: decisions/D-18-dual-auth-model.md.
  • No L-NN minted. Slot stays at L-09.

Cross-track touchpoints (Track A)

  • Cockpit uses BYO Forge personal-access-tokens (D-16) for admin Forge-API calls — orthogonal to user-facing Forge OAuth. Cockpit-side Forge-OAuth login is a future Track A item (no current home#235 sub-issue).
  • VmAllocation.forge_id is ready and populated as of s2-011. When Track A wires cockpit-side OAuth, the SSO bridge gates on that field — no further hero_onboarding-side work needed.

What's deferred

  • /account/link-mycelium (mycelium-link to existing Forge user) — ~50 LOC, future phase.
  • Display-name editing — display_name is seeded from Forge login but not editable via UI.
  • OAuth token persistence — revisit if we ever need authenticated Forge API calls on the user's behalf.
  • OIDC migration — layered on top later if needed.

References

  • decisions/D-18-dual-auth-model.md
  • sessions/2-011-hero-onboarding-phase-9-forge-oauth.yml
  • Parent: hero_onboarding#1
  • Related: hero_login D-12 (mycelium proof-of-control)
**Parent:** hero_onboarding#1 (paid-tier overlay milestone) **Decision lock:** decisions/D-18-dual-auth-model.md **Status:** ✅ implemented + smoke-green; squash-merge pending boss OK. ## Scope Add Forge OAuth as the default login mechanism on hero_onboarding, alongside the D-12 mycelium proof-of-control flow. Per-User row carries two optional identity slots (`forge_id?` + `mycelium_address?`); at least one populated. Snapshot `forge_id` onto `VmAllocation` rows at allocate time so cockpit's future Forge-OAuth SSO bridge can gate "is this VM yours?" without an OSIS lookup. ## What landed 1. **Schema** — `User.mycelium_address` becomes optional; `User.forge_id?` gets `@index` annotation. `VmAllocation` gains `forge_id?: str`. Docstring rewritten for dual-auth. 2. **New module** `crates/hero_onboarding_server/src/forge_oauth.rs` (~360 LOC + 11 unit tests). Forgejo OAuth2 v1 wire: PKCE-S256 + HMAC-SHA256-signed state token (5-min TTL, bundles PKCE verifier — cookie-free). Userinfo via server-side `GET /api/v1/user`. Access tokens single-use, never persisted. 3. **AppState extension** — `forge_oauth: Option<Arc<ForgeOAuthConfig>>`. Registration-aware: server starts cleanly if CLIENT_ID/SECRET missing; routes return 503; login page hides Forge button. 4. **5 new routes**: `GET /login/forge/start`, `GET /login/forge/callback`, `GET /account`, `POST /account/link-forge`, `POST /account/unlink-forge` (defensive 409 if it would orphan the user row). 5. **Session cookie refactor** — value is now `user.sid` (was `mycelium_address`). All cookie-read sites go through `resolve_user_from_cookie(osis, value)` which accepts three formats: bare sid (canonical), mycelium address with auto-create (smoke compat), `fid:<forge_id>` prefix (smoke convenience). 6. **VmAllocation forge_id snapshot** — `vm_allocate_post` reads `user.forge_id` and writes onto the new allocation row post-`provisioner.allocate()`. Snapshot, not foreign-key. 7. **Login UI dual-card** + Dashboard identity-aware "Logged in as" + auth banner (Forge / mycelium / dual-linked). 8. **CLI FORWARDED_ENV +5** keys. 9. **Admin** users page gains `forge_id` column. ## Q lock summary | # | Q | Locked | |---|---|---| | Q#1 | Redirect URI? | Local-dev only for s2-011: `http://127.0.0.1:9920/login/forge/callback`. Prod redirect at s2-012. | | Q#2 | One app for dev+prod, or one each? | **One per environment** — separate Forge OAuth apps for dev/prod. | | Q#3 | Scope minimum? | `read:user`. | | Q#4 | OAuth2 vs OIDC? | **OAuth2 + PKCE-S256 + HMAC state** — smaller attack surface than OIDC; equivalent identity guarantees. OIDC layers on later if stateless cross-service verification becomes a need. | ## Acceptance - `cargo test` workspace: **62/62** (53 server + 9 schema; +11 new forge_oauth unit tests). - `lab build --release --install --workspace` VICTORY 3/3 (24.6s build #12). - `lab infocheck` 3/3 clean / 0 findings. - `cargo fmt --check` + `cargo clippy --workspace --all-targets -- -D warnings` clean. - `scripts/smoke_forge_oauth.sh` **29/29 GREEN** (new — Python mock Forge OAuth server). - `scripts/smoke_dual_auth.sh` **15/15 GREEN** (new — end-to-end dual-identity + allocation snapshot). - All existing regression smokes green: vm_allocate **35/35**, kyc **27/27**, payments **26/26**, aggregate **28/28**, usage_push **24/24**, pool_refresh **11/11**, login **10/10** (one assertion updated to expect sid-form cookie). Totals: **62 unit + 205 smoke checks across 9 scripts**, all green. ## Phase B findings (s2-011) - `@index` in oschema annotates the **previous** field, not the next. Discovered while indexing `forge_id` — initial placement before the field accidentally indexed `kyc_verified_at`. Confirmed via existing schemas (consumed_record_index.oschema, usage_record.oschema). - `mycelium_address: str` → `Option<String>` is forward-compatible (existing rows keep their value, new Forge-only rows omit). Few `user.mycelium_address` call sites required `.as_deref()`. - Smoke cookie compat: 3 existing smokes forged cookies with mycelium addresses; multi-format `resolve_user_from_cookie` preserves their working state without rewrite. ## D-NN / L-NN - **D-18 minted**: decisions/D-18-dual-auth-model.md. - No L-NN minted. Slot stays at L-09. ## Cross-track touchpoints (Track A) - Cockpit uses BYO Forge personal-access-tokens ([D-16](https://forge.ourworld.tf/lhumina_code/hero_cockpit)) for admin Forge-API calls — orthogonal to user-facing Forge OAuth. Cockpit-side Forge-OAuth login is a future Track A item (no current home#235 sub-issue). - `VmAllocation.forge_id` is **ready and populated** as of s2-011. When Track A wires cockpit-side OAuth, the SSO bridge gates on that field — no further hero_onboarding-side work needed. ## What's deferred - `/account/link-mycelium` (mycelium-link to existing Forge user) — ~50 LOC, future phase. - Display-name editing — `display_name` is seeded from Forge login but not editable via UI. - OAuth token persistence — revisit if we ever need authenticated Forge API calls on the user's behalf. - OIDC migration — layered on top later if needed. ## References - decisions/D-18-dual-auth-model.md - sessions/2-011-hero-onboarding-phase-9-forge-oauth.yml - Parent: hero_onboarding#1 - Related: hero_login D-12 (mycelium proof-of-control)
mik-tf changed title from test ping to Phase 9 — Forge OAuth login + dual-auth (D-18) 2026-05-21 18:37:16 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_onboarding#10
No description provided.