Implementing SSO, OIDC (All auth) #42

Merged
YousifShkara merged 11 commits from feature-sso into main 2026-05-22 08:04:38 +02:00
Owner

This will allow the mokosh-server to act as the backend for authentication for all PSA related products

This will allow the mokosh-server to act as the backend for authentication for all PSA related products
Wires the v1-cut Sessions section on the user-detail page (bunyip-settings 04-ui.md section 4). Two endpoints:

- GET /v1/auth/users/:user_id/sessions
- POST /v1/auth/users/:user_id/sessions/:session_id/revoke

Both gated by admin role + tenant scope (memberships.find on the target in admin.tenant_id; 404 on miss). The revoke endpoint also kills bound refresh families and writes an `user_session_revoked` AdminAction audit row, mirroring the self-service revoke.

`is_current` is always false on the admin variant (the admin's sid doesn't apply to another user's session list).
Extends /v1/auth/audit-logs with:
- date_from / date_to (RFC 3339, inclusive bounds)
- free-text search across metadata::text via ILIKE
- severity filter (info / warning / critical; invalid values ignored)
- actor_email LEFT JOINed off mokosh_auth.users so the SPA can render names instead of raw UUIDs (None for system actions or deleted-user actors)

New endpoint:
- GET /v1/auth/audit-logs.csv - same filter shape, streams matched rows as text/csv with columns: created_at, severity, event_kind, actor_email, actor_id, ip, metadata. Capped at 10,000 rows; if no explicit limit, defaults to the cap so a bare CSV download returns a meaningful dump. Hand-rolled CSV escaping to avoid pulling in another dep.

Core changes:
- AuditListFilter struct in mokosh-auth-core
- AuditLogger trait gains list_filtered(filter); legacy list_recent becomes a thin wrapper
- AuditEntry gains actor_email: Option<String>

The list endpoint clamps at 200; the CSV endpoint clamps at 10k. Pre-existing /v1/auth/audit-logs (no .csv suffix) shape stays backwards compatible - just gains optional query params and the actor_email response field.
Three user-visible symptoms after a tenant switch traced back to the new access token deliberately omitting mokosh_op_session_id:

1. Sessions page no longer marks any row as 'Current session' (the is_current check matches against the sid claim).
2. Revoking the 'current' device on /settings/sessions can't kill the right refresh family because no family was bound to a session.
3. Refresh-on-tenant-switch loses the session linkage for logout-everywhere.

Re-bind the new family + access token to the caller's BearerSessionId (when present; None when the caller's own token had no sid, e.g. nested tenant-switched grant - same fallback as before). The OP cookie + browser device identity doesn't change across a tenant switch; only the active_tenant scope does. Pre-existing comment said 'doesn't currently re-bind' - this commit reverses that decision.
Adds an Owner gate to the user-management surface (/v1/auth/users). Owner is the founder of a tenant - personal-tenant signups become Owner of their own namespace (already wired in signup), org creators become Owner of their org (already wired in /v1/orgs). After this commit, Owner rows are immovable from this surface even by users who hold UserRole::Admin in the same tenant:

- change_role: 403 'cannot be modified by other admins' on Owner target.
- set_status (suspend / reactivate): same 403.
- delete_user: same 403.
- available_role_transitions: returns empty Vec for Owner targets (SPA renders no role dropdown).

Non-destructive helper endpoints are unchanged - an admin assisting an Owner is fine:
- get_one: still readable, returns is_owner so the SPA can render an Owner badge.
- resend_verify, admin_trigger_password_reset: still callable.

resolve_target now returns a ResolvedTarget struct (user + is_owner). list_filtered handler does one extra cheap query for owner_ids in the tenant and stamps is_owner on each UserView.

The Owner -> UserRole::Admin invariant from the original signup / org-create flows means founders also retain Admin-tier role on top, so they can keep doing admin actions. Demoting them to Member-tier role from this surface is what's now blocked.
Sibling of register-bunyip-client / register-client. Registers lets-chat as a CONFIDENTIAL OIDC client (vs. bunyip's public client) with:
- client_type=confidential, auth_method=client_secret_basic
- redirect_uri=https://${USER}-chat.a8n.run/auth/sso/callback (matches the chat-host pattern decided per docs/lets-chat/sso/IMPLEMENTATION-PHASES.md)
- scopes=openid email profile (no offline_access; lets-chat doesn't call mokosh APIs on the user's behalf)
- grant_types=authorization_code only
- icon + description so the row shows up cleanly in bunyip's app launcher

Prints client_id + client_secret on success; operator captures both into lets-chat/.env. Idempotent on (name), per the bootstrap binary's existing semantics.

Part of the lets-chat SSO series. M1 (this) is independent and can run before any lets-chat code.
lets-chat's SSO callback URL changed from /auth/sso/callback to
/auth/sso/{provider_id}/callback in its L9 doc-10 pivot. The seeded
provider lands under the slug "default", so the redirect_uri lets-chat
sends at /auth/sso/start time is /auth/sso/default/callback.

Without this fix mokosh-server rejects the authorize request with
"invalid redirect_uri". One-off existing rows can be patched in place
via:

    UPDATE mokosh_auth.oauth_clients
    SET redirect_uris = ARRAY['https://<user>-chat.a8n.run/auth/sso/default/callback']
    WHERE name = 'lets-chat';
HttpError::into_response now tracing::error!s on any 5xx response with the underlying AuthError captured via ?-debug. The response body still omits the detail (so internals don't leak to clients) but operators get the missing breadcrumb. Used today to diagnose a Could not load apps: server_error symptom; stays in for future 5xx debugging.

compose.dev-sso.yml: MOKOSH_AUTH_LOGIN_URL switched from http:// to https:// so browsers don't refuse the mixed-scheme redirect during the dev login flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
OIDC Back-Channel Logout 1.0 §2.5 requires the OP to notify every RP that has a backchannel_logout_uri registered when an OP session is terminated. Until now mokosh-server revoked the session + refresh-token families locally but never told the RPs, so a user who signed out at Bunyip stayed signed in at drillmark / lets-chat / mokosh-clients until their access token expired (≤1h).

After revoking the session, /v1/auth/logout now spawns a background task that:
  1. Lists every OAuth client visible to the (user, tenant) via OAuthClientRepository::list_for_user.
  2. For each row with backchannel_logout_uri set, mints a signed logout_token (sub=user.id, sid=op_session.sid, aud=client.client_id, events={backchannel-logout}).
  3. POSTs `logout_token=<jwt>` as application/x-www-form-urlencoded to the registered URL, 5s timeout, fire-and-forget.

Each RP independently does whatever its own webhook handler does. Drillmark's handler stamps users.last_logout_at = NOW(); BearerUser then rejects any access token with iat < last_logout_at, so the revocation lands on the very next API call from the now-signed-out tab.

Failures (RP unreachable, RP returned non-2xx, mint error) are logged at WARN and do not block the user's /v1/auth/logout response. The spec mandates a 200 from the RP "within a reasonable time" but doesn't require us to surface RP failure to the human.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Access tokens carry a mokosh_op_session_id claim, but BearerUser was not checking it. An access token therefore stayed valid until its natural exp (≤1 h) even after /v1/auth/logout revoked the originating session, which meant a Bunyip sign-out did not propagate to any RP that calls back to mokosh-server with a bearer (mokosh-clients hitting /v1/auth/me, the SPA's membership loader, the audit log writer).

BearerUser now looks up the op_sessions row by the claim's session_id and rejects the token if the row is missing, revoked_at is set, or expires_at is in the past (.is_active checks both). The check is gated on the claim being present so legacy tokens without the field (none in practice, but a clean default) still validate.

Combined with the back-channel logout fan-out in 0b99014, a Bunyip logout now:
  1. Revokes the op_session at mokosh
  2. Fans out logout_token to every RP with backchannel_logout_uri
  3. RPs without a back-channel receiver (mokosh-clients, anyone else calling mokosh APIs with bearer) get a 401 on their very next mokosh call instead of waiting for refresh

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/v1/auth/logout already emits a Back-Channel Logout 1.0 token to every RP with a registered backchannel_logout_uri, but the OIDC RP-initiated logout endpoint (/oauth2/logout) only revoked the matching OP session and stopped. The asymmetry meant a sign-out triggered from drillmark or lets-chat propagated nowhere, while a Bunyip-driven sign-out propagated to everyone.

handle_logout now surfaces the revoked (user_id, tenant_id, sid) via a new RevokedSession field on LogoutOutcome::LoggedOut. The HTTP handler hands those to emit_backchannel_logouts (moved to pub(crate) so the OIDC handler can reach it) for the same fire-and-forget fan-out /v1/auth/logout uses.

Also revokes refresh-token families for the killed session in the OIDC path, matching /v1/auth/logout. Without that, a refresh token issued from the revoked session could mint a fresh access token after the user already signed out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into feature-sso
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 8s
Create release / Create release from merged PR (pull_request) Has been skipped
09751b3d3e
YousifShkara deleted branch feature-sso 2026-05-22 08:04:38 +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-server!42
No description provided.