Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/auth-remediation-overlay.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useAttachUserStateAdapters,
} from '../context/UserStateAdapters';
import { ThemeProvider } from '../chrome/ThemeProvider';
import { RemediationOverlay } from './RemediationOverlay';

export function LoadingFallback() {
return (
Expand Down Expand Up @@ -59,6 +60,8 @@ export function ConsoleShell({ children }: { children: ReactNode }) {
<FavoritesProvider>
<RecentItemsProvider>
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
<RemediationOverlay />
</RecentItemsProvider>
</FavoritesProvider>
</UserStateAdaptersProvider>
Expand Down
211 changes: 211 additions & 0 deletions packages/app-shell/src/console/RemediationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-background/95 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-xl">
{remediationRequired.code === 'PASSWORD_EXPIRED' ? (
<ExpiredPasswordForm message={remediationRequired.message} />
) : (
<MfaEnrollForm message={remediationRequired.message} />
)}
</div>
</div>
);
}

function SignOutLink() {
const { signOut } = useAuth();
return (
<button
type="button"
onClick={() => { void signOut(); }}
className="mt-4 w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Sign out instead
</button>
);
}

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<string | null>(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 (
<form onSubmit={submit} className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Your password has expired</h2>
<p className="mt-1 text-sm text-muted-foreground">
{message || 'Please set a new password to continue.'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rem-cur">Current password</Label>
<Input id="rem-cur" type="password" autoComplete="current-password" value={current}
onChange={(e) => setCurrent(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="rem-new">New password</Label>
<Input id="rem-new" type="password" autoComplete="new-password" value={next}
onChange={(e) => setNext(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="rem-conf">Confirm new password</Label>
<Input id="rem-conf" type="password" autoComplete="new-password" value={confirm}
onChange={(e) => setConfirm(e.target.value)} required />
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" className="w-full" disabled={busy}>
{busy ? 'Updating…' : 'Change password & continue'}
</Button>
<SignOutLink />
</form>
);
}

function TotpQr({ uri }: { uri: string }) {
const [error, setError] = useState<string | null>(null);
const ref = (node: HTMLCanvasElement | null) => {
if (!node || !uri) return;
toCanvas(node, uri, { width: 184, margin: 1 }, (err) => { if (err) setError(err.message); });
};
return (
<div className="flex justify-center rounded-md border bg-white p-3">
{error ? <span className="text-sm text-destructive">{error}</span> : <canvas ref={ref} />}
</div>
);
}

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<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<form onSubmit={start} className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Set up two-factor authentication</h2>
<p className="mt-1 text-sm text-muted-foreground">
{message || 'Your organization requires an authenticator app to continue.'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rem-pw">Confirm your password</Label>
<Input id="rem-pw" type="password" autoComplete="current-password" value={password}
onChange={(e) => setPassword(e.target.value)} required />
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" className="w-full" disabled={busy}>
{busy ? 'Preparing…' : 'Continue'}
</Button>
<SignOutLink />
</form>
);
}

return (
<form onSubmit={verify} className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Scan with your authenticator</h2>
<p className="mt-1 text-sm text-muted-foreground">
Scan this QR code with Google Authenticator, 1Password, Authy, etc., then enter the 6-digit code.
</p>
</div>
{totpUri ? <TotpQr uri={totpUri} /> : null}
{backupCodes.length > 0 ? (
<details className="rounded-md border bg-muted/40 p-3 text-xs">
<summary className="cursor-pointer font-medium">Save your backup codes</summary>
<p className="mt-1 text-muted-foreground">Store these somewhere safe — each can be used once if you lose your device.</p>
<div className="mt-2 grid grid-cols-2 gap-1 font-mono">
{backupCodes.map((c) => <span key={c}>{c}</span>)}
</div>
</details>
) : null}
<div className="space-y-2">
<Label htmlFor="rem-code">6-digit code</Label>
<Input id="rem-code" inputMode="numeric" autoComplete="one-time-code" maxLength={8}
placeholder="123456" value={code} onChange={(e) => setCode(e.target.value)} required />
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<Button type="submit" className="w-full" disabled={busy}>
{busy ? 'Verifying…' : 'Verify & continue'}
</Button>
<SignOutLink />
</form>
);
}
1 change: 1 addition & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,4 @@ export type {
AssistantEditorContext,
AssistantEditorField,
} from './assistant/assistantBus';
export { RemediationOverlay } from './console/RemediationOverlay';
12 changes: 12 additions & 0 deletions packages/auth/src/AuthContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ export interface AuthContextValue {
* password. Use this when `hasLocalPassword()` returns true.
*/
changePassword: (currentPassword: string, newPassword: string, options?: { revokeOtherSessions?: boolean }) => Promise<void>;
/**
* 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<void>;
/**
* Set an INITIAL local password for an SSO-onboarded user that has no
* credential account yet. Refuses if a password already exists — call
Expand Down
18 changes: 18 additions & 0 deletions packages/auth/src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,6 +73,7 @@ export function AuthProvider({
const [session, setSession] = useState<AuthContextValue['session']>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [remediationRequired, setRemediationRequired] = useState<{ code: string; message: string } | null>(null);

// Organization / workspace state
const [organizations, setOrganizations] = useState<AuthOrganization[]>([]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -551,6 +564,10 @@ export function AuthProvider({
sendVerificationEmail,
resetPassword,
changePassword,
remediationRequired,
setRemediationRequired,
enrollTotp,
verifyTotp,
setInitialPassword,
hasLocalPassword,
getAuthConfig,
Expand Down Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions packages/auth/src/auth-gate-events.ts
Original file line number Diff line number Diff line change
@@ -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<Listener>();

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<string, unknown>).error;
if (!err || typeof err !== 'object') return null;
const code = (err as Record<string, unknown>).code;
if (code === 'MFA_REQUIRED' || code === 'PASSWORD_EXPIRED') {
const message = (err as Record<string, unknown>).message;
return { code, message: typeof message === 'string' ? message : '' };
}
return null;
}
Loading
Loading