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 (
+ { void signOut(); }}
+ className="mt-4 w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
+ >
+ Sign out instead
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+ }
+
+ return (
+
+ );
+}
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 () => ({}),