diff --git a/.changeset/auth-remediation-overlay.md b/.changeset/auth-remediation-overlay.md new file mode 100644 index 000000000..46300ff73 --- /dev/null +++ b/.changeset/auth-remediation-overlay.md @@ -0,0 +1,11 @@ +--- +'@object-ui/auth': minor +'@object-ui/app-shell': minor +--- + +Auth: remediation overlay for the ADR-0069 session gate (enforced MFA / password expiry) + +The ObjectStack backend now blocks logged-in users from protected resources with `403 { error: { code: 'MFA_REQUIRED' | 'PASSWORD_EXPIRED' } }`. The Console now detects this on every API response and raises a full-screen, guided remediation flow instead of leaving the user on failing requests. + +- `@object-ui/auth`: the authenticated fetch wrapper detects the gate and broadcasts it via a tiny module-level emitter; `AuthProvider` exposes `remediationRequired` + `setRemediationRequired`; the `twoFactorClient` plugin is enabled and `enrollTotp` / `verifyTotp` are added to the auth client (`changePassword` already existed). +- `@object-ui/app-shell`: a `RemediationOverlay` (mounted in `ConsoleShell`) renders the guided flow — change an expired password, or enrol an authenticator (password confirm → QR + backup codes → verify TOTP) — then reloads so the app re-fetches cleanly. Auth + metadata + `me/*` reads stay reachable (server allow-list), so the overlay renders above a normally-loading shell. diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index 36a593670..e35506e66 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -28,6 +28,7 @@ import { useAttachUserStateAdapters, } from '../context/UserStateAdapters'; import { ThemeProvider } from '../chrome/ThemeProvider'; +import { RemediationOverlay } from './RemediationOverlay'; export function LoadingFallback() { return ( @@ -59,6 +60,8 @@ export function ConsoleShell({ children }: { children: ReactNode }) { }>{children} + {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} + diff --git a/packages/app-shell/src/console/RemediationOverlay.tsx b/packages/app-shell/src/console/RemediationOverlay.tsx new file mode 100644 index 000000000..9df59319a --- /dev/null +++ b/packages/app-shell/src/console/RemediationOverlay.tsx @@ -0,0 +1,211 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useState } from 'react'; +import { useAuth } from '@object-ui/auth'; +import { Button, Input, Label } from '@object-ui/components'; +import { toCanvas } from 'qrcode'; + +/** + * ADR-0069 — full-screen remediation overlay. + * + * When the backend blocks a logged-in user with `403 PASSWORD_EXPIRED` / + * `MFA_REQUIRED`, the API fetch interceptor raises `remediationRequired` on the + * auth context. This overlay guides the user through the fix (change the + * expired password, or enrol an authenticator) instead of leaving them staring + * at failing requests. On success it reloads so the app re-fetches cleanly. + */ +export function RemediationOverlay() { + const { remediationRequired } = useAuth(); + if (!remediationRequired) return null; + return ( +
+
+ {remediationRequired.code === 'PASSWORD_EXPIRED' ? ( + + ) : ( + + )} +
+
+ ); +} + +function SignOutLink() { + const { signOut } = useAuth(); + return ( + + ); +} + +function ExpiredPasswordForm({ message }: { message: string }) { + const { changePassword, setRemediationRequired } = useAuth(); + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (next !== confirm) { setError('New passwords do not match.'); return; } + setBusy(true); + try { + await changePassword(current, next); + setRemediationRequired(null); + window.location.reload(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not change password.'); + setBusy(false); + } + }; + + return ( +
+
+

Your password has expired

+

+ {message || 'Please set a new password to continue.'} +

+
+
+ + setCurrent(e.target.value)} required /> +
+
+ + setNext(e.target.value)} required /> +
+
+ + setConfirm(e.target.value)} required /> +
+ {error ?

{error}

: null} + + + + ); +} + +function TotpQr({ uri }: { uri: string }) { + const [error, setError] = useState(null); + const ref = (node: HTMLCanvasElement | null) => { + if (!node || !uri) return; + toCanvas(node, uri, { width: 184, margin: 1 }, (err) => { if (err) setError(err.message); }); + }; + return ( +
+ {error ? {error} : } +
+ ); +} + +function MfaEnrollForm({ message }: { message: string }) { + const { enrollTotp, verifyTotp, setRemediationRequired } = useAuth(); + const [step, setStep] = useState<'password' | 'verify'>('password'); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [totpUri, setTotpUri] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const start = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); setBusy(true); + try { + const { totpURI, backupCodes: codes } = await enrollTotp(password); + setTotpUri(totpURI); + setBackupCodes(codes ?? []); + setStep('verify'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not start enrollment.'); + } finally { + setBusy(false); + } + }; + + const verify = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); setBusy(true); + try { + await verifyTotp(code.trim()); + setRemediationRequired(null); + window.location.reload(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid code. Try again.'); + setBusy(false); + } + }; + + if (step === 'password') { + return ( +
+
+

Set up two-factor authentication

+

+ {message || 'Your organization requires an authenticator app to continue.'} +

+
+
+ + setPassword(e.target.value)} required /> +
+ {error ?

{error}

: null} + + + + ); + } + + return ( +
+
+

Scan with your authenticator

+

+ Scan this QR code with Google Authenticator, 1Password, Authy, etc., then enter the 6-digit code. +

+
+ {totpUri ? : null} + {backupCodes.length > 0 ? ( +
+ Save your backup codes +

Store these somewhere safe — each can be used once if you lose your device.

+
+ {backupCodes.map((c) => {c})} +
+
+ ) : null} +
+ + setCode(e.target.value)} required /> +
+ {error ?

{error}

: null} + + + + ); +} diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 81c9e6a3a..6ca6a66d4 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -266,3 +266,4 @@ export type { AssistantEditorContext, AssistantEditorField, } from './assistant/assistantBus'; +export { RemediationOverlay } from './console/RemediationOverlay'; diff --git a/packages/auth/src/AuthContext.ts b/packages/auth/src/AuthContext.ts index 052d911bd..e048fe58f 100644 --- a/packages/auth/src/AuthContext.ts +++ b/packages/auth/src/AuthContext.ts @@ -53,6 +53,18 @@ export interface AuthContextValue { * password. Use this when `hasLocalPassword()` returns true. */ changePassword: (currentPassword: string, newPassword: string, options?: { revokeOtherSessions?: boolean }) => Promise; + /** + * ADR-0069 — an authentication-policy gate the user must clear before + * continuing (e.g. expired password, required MFA enrollment), or null. Set + * by the API fetch interceptor; consumed by the remediation overlay. + */ + remediationRequired: { code: string; message: string } | null; + /** Clear (or set) the remediation gate — overlay calls this on success. */ + setRemediationRequired: (gate: { code: string; message: string } | null) => void; + /** ADR-0069 — start TOTP enrollment; returns the otpauth URI + backup codes. */ + enrollTotp: (password: string) => Promise<{ totpURI: string; backupCodes: string[] }>; + /** ADR-0069 — verify the first TOTP code to activate enrollment. */ + verifyTotp: (code: string) => Promise; /** * Set an INITIAL local password for an SSO-onboarded user that has no * credential account yet. Refuses if a password already exists — call diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index 2ba192403..09816e764 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -7,6 +7,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { authGateEvents } from './auth-gate-events'; import type { AuthUser, AuthClient, AuthProviderConfig, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions } from './types'; import { AuthCtx, type AuthContextValue } from './AuthContext'; import { createAuthClient } from './createAuthClient'; @@ -72,6 +73,7 @@ export function AuthProvider({ const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [remediationRequired, setRemediationRequired] = useState<{ code: string; message: string } | null>(null); // Organization / workspace state const [organizations, setOrganizations] = useState([]); @@ -279,6 +281,17 @@ export function AuthProvider({ [client], ); + // ADR-0069 — the API fetch interceptor emits an auth-policy gate; raise the + // remediation overlay. Don't override an already-shown gate. + useEffect(() => { + return authGateEvents.subscribe((gate) => { + setRemediationRequired((prev) => prev ?? gate); + }); + }, []); + + const enrollTotp = useCallback((password: string) => client.enrollTotp(password), [client]); + const verifyTotp = useCallback((code: string) => client.verifyTotp(code), [client]); + const changePassword = useCallback( async (currentPassword: string, newPassword: string, options?: { revokeOtherSessions?: boolean }) => { setError(null); @@ -551,6 +564,10 @@ export function AuthProvider({ sendVerificationEmail, resetPassword, changePassword, + remediationRequired, + setRemediationRequired, + enrollTotp, + verifyTotp, setInitialPassword, hasLocalPassword, getAuthConfig, @@ -579,6 +596,7 @@ export function AuthProvider({ [ user, session, isAuthenticated, isAuthEnabled, isLoading, error, isPreviewMode, previewMode, signIn, signUp, signOut, updateUser, forgotPassword, sendVerificationEmail, resetPassword, changePassword, setInitialPassword, hasLocalPassword, getAuthConfig, signInWithProvider, + remediationRequired, enrollTotp, verifyTotp, organizations, activeOrganization, activeMember, isOrganizationsLoading, switchOrganization, createOrganization, refreshOrganizations, updateOrganization, deleteOrganization, leaveOrganization, getMembers, inviteMember, removeMember, updateMemberRole, diff --git a/packages/auth/src/auth-gate-events.ts b/packages/auth/src/auth-gate-events.ts new file mode 100644 index 000000000..7babfe7f5 --- /dev/null +++ b/packages/auth/src/auth-gate-events.ts @@ -0,0 +1,59 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * ADR-0069 — client side of the authentication-policy session gate. + * + * The ObjectStack backend returns `403 { error: { code: 'MFA_REQUIRED' | + * 'PASSWORD_EXPIRED', message } }` from protected endpoints when a logged-in + * user must remediate. The authenticated fetch wrapper detects this on EVERY + * API response and emits it here; `AuthProvider` subscribes and raises a + * full-screen remediation overlay. Decoupling via a tiny module-level emitter + * keeps the plain fetch wrapper free of React. + */ + +/** A triggered auth-policy gate the user must clear before continuing. */ +export interface AuthGateInfo { + /** Stable machine code, e.g. `MFA_REQUIRED` / `PASSWORD_EXPIRED`. */ + code: 'MFA_REQUIRED' | 'PASSWORD_EXPIRED' | string; + /** Human-facing message from the server (may be empty). */ + message: string; +} + +type Listener = (gate: AuthGateInfo) => void; +const listeners = new Set(); + +export const authGateEvents = { + /** Broadcast a gate to all subscribers (the active AuthProvider). */ + emit(gate: AuthGateInfo): void { + for (const l of Array.from(listeners)) { + try { l(gate); } catch { /* a listener throwing must not break others */ } + } + }, + /** Subscribe; returns an unsubscribe fn. */ + subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, +}; + +/** + * Returns the gate when `status`/`body` is a recognised auth-policy block, + * else null. Tolerant of non-gate 403s (permission denials etc.). + */ +export function detectAuthGate(status: number, body: unknown): AuthGateInfo | null { + if (status !== 403 || !body || typeof body !== 'object') return null; + const err = (body as Record).error; + if (!err || typeof err !== 'object') return null; + const code = (err as Record).code; + if (code === 'MFA_REQUIRED' || code === 'PASSWORD_EXPIRED') { + const message = (err as Record).message; + return { code, message: typeof message === 'string' ? message : '' }; + } + return null; +} diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index 18413d323..cca02476a 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -7,7 +7,7 @@ */ import { createAuthClient as createBetterAuthClient } from 'better-auth/client'; -import { organizationClient } from 'better-auth/client/plugins'; +import { organizationClient, twoFactorClient } from 'better-auth/client/plugins'; import type { AuthClient, AuthClientConfig, AuthUser, AuthSession, SignInCredentials, SignUpData, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions, @@ -190,7 +190,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { basePath, disableDefaultFetchPlugins: true, fetchOptions: { customFetchImpl: bearerFetch }, - plugins: [organizationClient()], + plugins: [organizationClient(), twoFactorClient()], }); // The better-auth client exposes methods whose TS return types are narrower @@ -388,6 +388,33 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { } }, + // ADR-0069 — enforced-MFA enrollment. Returns the otpauth:// URI (for a QR) + // and one-time backup codes. Runtime methods come from the twoFactorClient + // plugin; cast through unknown to bridge loose better-auth client types. + async enrollTotp(password: string) { + type EnableFn = (opts: { password: string }) => + Promise<{ data?: { totpURI?: string; backupCodes?: string[] } | null; error?: { message?: string; status?: number } | null }>; + const tf = (betterAuth as unknown as { twoFactor?: { enable?: EnableFn } }).twoFactor; + if (!tf || typeof tf.enable !== 'function') { + throw new Error('two-factor enrollment is not available on this auth backend'); + } + const { data, error } = await tf.enable({ password }); + if (error) throw new Error(error.message ?? `Two-factor enable failed (${error.status ?? '?'})`); + return { totpURI: data?.totpURI ?? '', backupCodes: data?.backupCodes ?? [] }; + }, + + // ADR-0069 — verify the first TOTP code to activate enrollment. + async verifyTotp(code: string) { + type VerifyFn = (opts: { code: string }) => + Promise<{ error?: { message?: string; status?: number } | null }>; + const tf = (betterAuth as unknown as { twoFactor?: { verifyTotp?: VerifyFn } }).twoFactor; + if (!tf || typeof tf.verifyTotp !== 'function') { + throw new Error('two-factor verification is not available on this auth backend'); + } + const { error } = await tf.verifyTotp({ code }); + if (error) throw new Error(error.message ?? `Two-factor verification failed (${error.status ?? '?'})`); + }, + async setInitialPassword(newPassword: string) { // Custom route registered by AuthPlugin (framework). Used by users // who came in via SSO and have no credential account yet — server diff --git a/packages/auth/src/createAuthenticatedFetch.ts b/packages/auth/src/createAuthenticatedFetch.ts index ab7178e63..6a8157813 100644 --- a/packages/auth/src/createAuthenticatedFetch.ts +++ b/packages/auth/src/createAuthenticatedFetch.ts @@ -7,6 +7,7 @@ */ import { TokenStorage } from './createAuthClient'; +import { authGateEvents, detectAuthGate } from './auth-gate-events'; /** * Options for creating an authenticated adapter. @@ -101,6 +102,15 @@ export function createAuthenticatedFetch(): (input: RequestInfo | URL, init?: Re headers.set('Accept-Language', lang); } } - return fetch(input, { ...init, headers }); + const response = await fetch(input, { ...init, headers }); + // ADR-0069 — surface an auth-policy gate (expired password / required MFA) + // to the remediation overlay. Clone so the caller still reads the body. + if (isApiCall && response.status === 403) { + try { + const gate = detectAuthGate(response.status, await response.clone().json()); + if (gate) authGateEvents.emit(gate); + } catch { /* not a JSON gate body — leave the response untouched */ } + } + return response; }; } diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 69333f70e..0983d8216 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -174,6 +174,10 @@ export interface AuthClient { resetPassword: (token: string, newPassword: string) => Promise; /** Change password (requires current password). For users who already have a local password. */ changePassword: (currentPassword: string, newPassword: string, options?: { revokeOtherSessions?: boolean }) => Promise; + /** ADR-0069 — start TOTP enrollment; returns the otpauth URI + backup codes. */ + enrollTotp: (password: string) => Promise<{ totpURI: string; backupCodes: string[] }>; + /** ADR-0069 — verify the first TOTP code to activate enrollment. */ + verifyTotp: (code: string) => Promise; /** Set an INITIAL local password for SSO-onboarded users with no credential account. */ setInitialPassword: (newPassword: string) => Promise; /** Whether the current user has a local credential password configured. */ diff --git a/packages/auth/src/useAuth.ts b/packages/auth/src/useAuth.ts index c375f8ea7..50632074d 100644 --- a/packages/auth/src/useAuth.ts +++ b/packages/auth/src/useAuth.ts @@ -44,6 +44,10 @@ export function useAuth(): AuthContextValue { sendVerificationEmail: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, resetPassword: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, changePassword: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + remediationRequired: null, + setRemediationRequired: () => { /* no-op outside a provider */ }, + enrollTotp: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + verifyTotp: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, setInitialPassword: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, hasLocalPassword: async () => false, getAuthConfig: async () => ({}),