feat(auth): standalone username/password login when no bunyip is configured (MAPPS-368) #426

Merged
nrupard merged 2 commits from feat/MAPPS-368-standalone-login into main 2026-07-14 23:17:15 +02:00
Owner

Summary

When a deployment has no bunyip/OIDC issuer configured, the SPA now presents a standalone username/password login form (against mokosh-server's existing POST /api/v1/auth/login) instead of redirecting to a dead /oauth2/authorize. Per MAPPS-368. Deployments that DO configure an issuer are unchanged. mokosh-server needs no code changes.

Root cause

start_login (src/modules/oidc/flow.rs:57) builds format!("{issuer}/oauth2/authorize"), and both Login (lib.rs) and AuthGuard navigate there. With no bunyip, OidcConfig fell back to the compile-time default issuer http://localhost:8080, so login hit a relative-ish /oauth2/authorize that nothing serves, rendering the SPA's own 404.

Change (SPA-only)

  • Detection: OidcConfig::from_env's issuer default is now empty; OidcConfig::has_issuer() reports whether a real issuer resolved (runtime-injected MOKOSH_OIDC_ISSUER, or an msp.<tld> host that derives one); oidc::is_standalone() wraps it. Only a truly-unconfigured deploy flips to standalone, so the compile default no longer masks "no bunyip".
  • Form (pages/login.rs, mirrors the onboarding form): posts via a new unauthed api::post_typed; maps 401 to "invalid email or password", 429 to a rate-limit message, and mfa_required to "sign in through your identity provider". On success it seeds the access token + AuthContext (like the OIDC callback) and persists a StandaloneSession.
  • Triggers: AuthGuard routes an unauthenticated user to /login when standalone; Login renders the form when standalone, else fires start_login unchanged.
  • Persistence: StandaloneSession is stored under its own key, kept separate from the id-token-keyed StoredTokens, so the two rehydrate paths never collide. initial_auth_context tries the OIDC rehydrate first, then standalone (additive). clear_auth now also drops the standalone session so logout is complete.

Deferred (follow-ups)

  • Silent token refresh via /api/v1/auth/refresh - standalone sessions currently last the access-token TTL (~1h). Deliberately not touching the shared OIDC refresh hook.
  • The MFA second factor - two-factor accounts are directed to their IdP.

First-user note

A fresh self-hosted DB has no users until ADMIN_EMAIL / ADMIN_PASSWORD seed a super_admin at first boot (mokosh-server's maybe_bootstrap_admin). Set those for the login form to be usable out of the box.

Verified

just pre-commit green: fmt + clippy (-D warnings) + cargo check --target wasm32-unknown-unknown + cargo test --lib (214 passed). Configured-OIDC deployments are unaffected (the OIDC rehydrate + trigger paths are unchanged; standalone only activates when no issuer resolves).

#MAPPS-368

🤖 Generated with Claude Code

## Summary When a deployment has no bunyip/OIDC issuer configured, the SPA now presents a standalone username/password login form (against mokosh-server's existing `POST /api/v1/auth/login`) instead of redirecting to a dead `/oauth2/authorize`. Per MAPPS-368. Deployments that DO configure an issuer are unchanged. mokosh-server needs no code changes. ## Root cause `start_login` (`src/modules/oidc/flow.rs:57`) builds `format!("{issuer}/oauth2/authorize")`, and both `Login` (`lib.rs`) and `AuthGuard` navigate there. With no bunyip, `OidcConfig` fell back to the compile-time default issuer `http://localhost:8080`, so login hit a relative-ish `/oauth2/authorize` that nothing serves, rendering the SPA's own 404. ## Change (SPA-only) - **Detection**: `OidcConfig::from_env`'s issuer default is now empty; `OidcConfig::has_issuer()` reports whether a real issuer resolved (runtime-injected `MOKOSH_OIDC_ISSUER`, or an `msp.<tld>` host that derives one); `oidc::is_standalone()` wraps it. Only a truly-unconfigured deploy flips to standalone, so the compile default no longer masks "no bunyip". - **Form** (`pages/login.rs`, mirrors the onboarding form): posts via a new unauthed `api::post_typed`; maps 401 to "invalid email or password", 429 to a rate-limit message, and `mfa_required` to "sign in through your identity provider". On success it seeds the access token + `AuthContext` (like the OIDC callback) and persists a `StandaloneSession`. - **Triggers**: `AuthGuard` routes an unauthenticated user to `/login` when standalone; `Login` renders the form when standalone, else fires `start_login` unchanged. - **Persistence**: `StandaloneSession` is stored under its own key, kept separate from the id-token-keyed `StoredTokens`, so the two rehydrate paths never collide. `initial_auth_context` tries the OIDC rehydrate first, then standalone (additive). `clear_auth` now also drops the standalone session so logout is complete. ## Deferred (follow-ups) - Silent token refresh via `/api/v1/auth/refresh` - standalone sessions currently last the access-token TTL (~1h). Deliberately not touching the shared OIDC refresh hook. - The MFA second factor - two-factor accounts are directed to their IdP. ## First-user note A fresh self-hosted DB has no users until `ADMIN_EMAIL` / `ADMIN_PASSWORD` seed a super_admin at first boot (mokosh-server's `maybe_bootstrap_admin`). Set those for the login form to be usable out of the box. ## Verified `just pre-commit` green: fmt + clippy (`-D warnings`) + `cargo check --target wasm32-unknown-unknown` + `cargo test --lib` (214 passed). Configured-OIDC deployments are unaffected (the OIDC rehydrate + trigger paths are unchanged; standalone only activates when no issuer resolves). #MAPPS-368 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): standalone username/password login when no bunyip/OIDC is configured (MAPPS-368)
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 1m56s
d0648bc6ae
The SPA authenticated only through bunyip's OIDC OP: `start_login` built `{issuer}/oauth2/authorize` and both the `Login` component and the `AuthGuard` route guard navigated there. A self-hosted deployment with no bunyip fell back to the compile-time default issuer `http://localhost:8080`, so login went to a dead `/oauth2/authorize` and rendered the SPA's own 404. mokosh-server's legacy `POST /api/v1/auth/login` (username/password, JSON token response) was still live but had no form.

When no OIDC issuer resolves, the SPA now presents a standalone username/password form that authenticates against mokosh-server's `/api/v1/auth/login`. Deployments that configure an issuer (runtime-injected `MOKOSH_OIDC_ISSUER`, or an `msp.<tld>` host that derives one) are unchanged and still use the bunyip OIDC redirect. mokosh-server needs no changes.

Detection: `OidcConfig::from_env`'s issuer default becomes empty, and `OidcConfig::has_issuer()` reports whether a real issuer resolved; `oidc::is_standalone()` wraps it for the two trigger sites. Only a truly-unconfigured deploy (no injection, non-msp host) flips to standalone, so the compile default no longer masks the "no bunyip" case.

The form (`pages/login.rs`, mirroring the onboarding form) posts via a new unauthed `api::post_typed`, mapping 401 to "invalid email or password", 429 to a rate-limit message, and `mfa_required` to "sign in through your identity provider". On success it seeds the access token + `AuthContext` (like the OIDC callback) and persists a standalone session (a new `StandaloneSession` under its own storage key, kept separate from the id-token-keyed `StoredTokens` so the two rehydrate paths never collide). Reload rehydration is additive: `initial_auth_context` tries the OIDC rehydrate first, then the standalone one. `clear_auth` now also drops the standalone session so logout is complete.

Deferred (follow-ups): silent token refresh via `/api/v1/auth/refresh` (standalone sessions currently last the access-token TTL, ~1h), and the MFA second factor (two-factor accounts are directed to their IdP).

Note: a fresh self-hosted DB has no users until `ADMIN_EMAIL` / `ADMIN_PASSWORD` seed a super_admin at first boot (mokosh-server's `maybe_bootstrap_admin`); set those for the login form to be usable out of the box.

#MAPPS-368

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(auth): recover expired standalone session, accept MFA on standalone login
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 1m24s
Create release / Create release from merged PR (pull_request) Has been skipped
c7efeec07d
Two review fixes on the standalone (non-OIDC) login path.

Expired standalone session no longer stays stuck in a broken authenticated state. `refresh_user_from_me` switched from `get_authed` (which flattens the error to a String and cannot tell 401 apart from a transient failure) to `get_authed_typed`. On a 401 with no refreshable token (`tokens.is_none()`, i.e. a standalone session, since the OIDC path keeps `tokens = Some` and renews via its own refresh hook), it now clears the stored session, drops the in-memory bearer, and resets `AuthContext` to default so `AuthGuard` routes back to the login form. Previously any `/auth/me` error kept the cached user, so once the ~1h token expired the app was stuck "logged in" with a dead token that 401'd every request and offered no route to re-authenticate.

MFA accounts can now sign in through the standalone form instead of hitting a dead-end message. `LoginBody` gained an optional `mfa_code`; a first attempt that returns `mfa_required` reveals an authentication-code field and prompts for the 6-digit code, and the next submit resends with the code. Replaces the previous "Two-factor accounts must sign in through your identity provider" text, which was wrong for a standalone deploy where there is no separate IdP.

#MAPPS-368

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-14 23:16:26 +02:00
nrupard deleted branch feat/MAPPS-368-standalone-login 2026-07-14 23:17:15 +02:00
Sign in to join this conversation.
No reviewers
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
psa-systems/mokosh-apps!426
No description provided.