Authentication system for Object UI — AuthProvider, guards, login/register forms, and token management.
- 🔐 AuthProvider Context - Wrap your app with authentication state and methods
- 🛡️ AuthGuard - Protect routes and components from unauthenticated access
- 📝 Pre-built Forms - LoginForm, RegisterForm, and ForgotPasswordForm ready to use
- 👤 UserMenu - Display authenticated user info with sign-out support
- 🔑 Auth Client Factory -
createAuthClientpowered by official better-auth client - 🌐 Authenticated Fetch -
createAuthenticatedFetchfor automatic token injection - 👀 Preview Mode - Auto-login with simulated identity for marketplace demos and app showcases
- 🎯 Type-Safe - Full TypeScript support with exported types
npm install @object-ui/authPeer Dependencies:
react^18.0.0 || ^19.0.0
import type { ReactNode } from 'react';
import { AuthProvider, AuthGuard, createAuthClient, useAuth } from '@object-ui/auth';
// Your app supplies the sign-in page shown to unauthenticated visitors.
declare const LoginPage: () => ReactNode;
const authUrl = 'https://api.example.com/auth';
const authClient = createAuthClient({ baseURL: authUrl });
function App() {
return (
<AuthProvider authUrl={authUrl} client={authClient}>
<AuthGuard fallback={<LoginPage />}>
<Dashboard />
</AuthGuard>
</AuthProvider>
);
}
function Dashboard() {
const { user, signOut } = useAuth();
return (
<div>
<p>Welcome, {user?.name}</p>
<button onClick={signOut}>Sign Out</button>
</div>
);
}Wraps your application with authentication context. authUrl is required;
client is optional, and when you pass one it is used instead of the client
AuthProvider would otherwise build from authUrl:
import type { ReactNode } from 'react';
import { AuthProvider } from '@object-ui/auth';
import type { AuthClient } from '@object-ui/auth';
declare const authClient: AuthClient;
declare const App: () => ReactNode;
<AuthProvider authUrl="/api/v1/auth" client={authClient}>
<App />
</AuthProvider>;Hook for accessing auth state and methods:
import { useAuth } from '@object-ui/auth';
const {
user,
session,
signIn,
signOut,
signUp,
isAuthenticated,
isLoading,
isPreviewMode,
previewMode,
} = useAuth();| Property | Type | Description |
|---|---|---|
user |
AuthUser | null |
Current authenticated user |
session |
AuthClientSession | null |
Current session information |
isAuthenticated |
boolean |
Whether the user is authenticated |
isLoading |
boolean |
Whether auth state is loading |
isPreviewMode |
boolean |
Whether the app is running in preview mode |
previewMode |
PreviewModeOptions | null |
Preview mode configuration (only set when isPreviewMode is true) |
signIn |
(email, password) => Promise |
Sign in with credentials |
signOut |
() => Promise |
Sign out the current user |
signUp |
(name, email, password) => Promise |
Register a new user |
Protects children from unauthenticated access:
import type { ReactNode } from 'react';
import { AuthGuard, LoginForm } from '@object-ui/auth';
declare const ProtectedContent: () => ReactNode;
<AuthGuard fallback={<LoginForm />}>
<ProtectedContent />
</AuthGuard>;Pre-built authentication form components:
import { ForgotPasswordForm, LoginForm, RegisterForm } from '@object-ui/auth';
declare const navigate: (to: string) => void;
<LoginForm onSuccess={() => navigate('/dashboard')} />;
<RegisterForm onSuccess={() => navigate('/welcome')} />;
<ForgotPasswordForm onSuccess={() => navigate('/check-email')} />;labels is how the forms take translated text, and it reaches the social
provider buttons the server's /auth/config turns on: socialButton
is a template whose {provider} is replaced with the provider's display name
(defaults "Continue with {provider}" on LoginForm, "Sign up with {provider}"
on RegisterForm), and orText is the divider under the buttons (default
"or continue with email").
The divider introduces the email form, so LoginForm draws it only while that
form is shown: under SSO-only mode (features.ssoEnforced) the buttons stand
alone until "Use a password instead" reveals the form. A host rendering
SocialSignInButtons itself omits the divider with hideDivider (default
false). While the auth config is still loading, LoginForm's header shows
its title and no description, because the description may describe a password
form the server does not offer.
When the server requires email verification, a registration ends in the user's
inbox, and the verification link is the only way back. verificationCallbackURL
says where that link lands, e.g. the invitation the user signed up from. It is
sent as better-auth's sign-up callbackURL (the same value as the fourth
argument of useAuth().signUp). The server accepts only a root-relative path
or an absolute URL on a trusted origin, and refuses a document-relative ./…
with INVALID_CALLBACK_URL, which fails the sign-up. Resolve an SPA route
against its mount first. Omitted, the server default (/) applies.
import { RegisterForm } from '@object-ui/auth';
declare const navigate: (to: string) => void;
<RegisterForm
verificationCallbackURL="/_console/accept-invitation/inv_123"
onVerificationRequired={(email) => navigate(`/verify-email-prompt?email=${encodeURIComponent(email)}`)}
/>;Displays current user info with avatar and sign-out:
import { UserMenu } from '@object-ui/auth';
<UserMenu />;Creates a fetch wrapper that injects the stored Bearer token (plus
X-Tenant-ID and Accept-Language) into API requests:
import { createAuthenticatedFetch } from '@object-ui/auth';
const authedFetch = createAuthenticatedFetch();
// For fetches whose target URL may come from metadata (an API-call
// action's target), restrict credential injection to the current page's
// origin so the platform token never leaks to third-party hosts:
const sameOriginFetch = createAuthenticatedFetch({ sameOriginOnly: true });When the session resolves to a different user than the one this browser last
held, AuthProvider drops the previous user's client state (objectui#5664).
That clears storage only. State already derived from that storage and held in
memory, such as the UI language the boot resolved, has to be re-derived by
whoever owns it. These two exports tell that owner a change happened in this
page-load (objectui#10193). The count lets an owner that mounts after the purge
still see it:
import { useSyncExternalStore } from 'react';
import { getSessionOwnerChangeCount, subscribeSessionOwnerChange } from '@object-ui/auth';
const ownerChanges = useSyncExternalStore(
subscribeSessionOwnerChange,
getSessionOwnerChangeCount,
getSessionOwnerChangeCount,
);The count lives in memory for one page-load and is never persisted.
@object-ui/app-shell's useSignedInUserLocale is the in-repo consumer.
createAuthenticatedFetch stamps X-Tenant-ID on the requests it wraps. This section is
that header's contract: what it means, who stamps it, who reads it, what a reader may
assume, and the one window in which it is not sent at all.
It is written down because a grep cannot answer any of those questions from this
repository. The header's only non-CORS consumer lives in the cloud repository, so a
search confined to this repo and the framework (objectstack) finds zero readers and
reads as "nothing consumes this stamp". That reading is what
objectui#5279 was filed on, and
it is false. Do not act on it.
A routing hint for the hosting edge — this request belongs to tenant <id>:
X-Tenant-ID: <better-auth activeOrganizationId>The value is the active organization id, the same id the session carries as
session.activeOrganizationId. The header is not an identity claim, not an
authorization input, and not what scopes rows.
| Stamped by | createAuthenticatedFetch (src/createAuthenticatedFetch.ts) |
| Value read from | ActiveOrganizationStorage (src/ActiveOrganizationStorage.ts) — localStorage, key auth-active-organization-id:u:$userId (per-user since objectui#5664; the bare key is the retired pre-#5664 spelling) |
| Condition | that storage holds a non-empty value |
| Not conditioned on | the URL being an /api/ call. Authorization and Accept-Language are; this is not |
| Suppressed by | createAuthenticatedFetch({ sameOriginOnly: true }) for cross-origin URLs — it returns before any header work |
| Precedence | overwrites an X-Tenant-ID the caller passed in init.headers |
ActiveOrganizationStorage has exactly one writer, AuthProvider:
| Event | Effect |
|---|---|
refreshOrganizations — after getSession → listOrganizations → getActiveOrganization resolves (including the ADR-0081 single-membership repair) |
set |
switchOrganization — the org switcher |
set, or clear when the server returns no org |
deleteOrganization / leaveOrganization, when the active org is the one going away |
clear |
| sign-out | clear |
The cloud edge — yes. The non-test readers are recorded on objectui#5279:
packages/service-tenant/src/tenant-context.ts (header resolution) and
packages/tenant-router/src/spec/turso-multi-tenant.zod.ts, both in the cloud
repository. That repository is not readable from this one, so those paths are cited as the
recorded reading rather than re-derived here.
Its configuration contract, though, IS readable from here, because this package
depends on @objectstack/spec. TenantRoutingConfigSchema (@objectstack/spec/cloud) is
what configures a tenant resolver; parsing an empty config on the version this package
resolves today (17.1.0) yields:
enabled: false
identificationSources: ["subdomain", "header", "jwt_claim"]
tenantHeaderName: "X-Tenant-ID"
jwtOrganizationClaim: "organizationId"
Two consequences matter to a client author:
- The header name is configurable.
X-Tenant-IDis its default, not a constant. - The header is one of six identification sources (
subdomain,custom_domain,header,jwt_claim,session,default), and in the default precedence it ranks second, behindsubdomain. On a subdomain-routed deployment it is not the thing that picks the tenant, and a deployment may leave it out ofidentificationSourcesentirely.
The framework (objectstack) — no. resolveAuthzContext
(packages/core/src/security/resolve-authz-context.ts) derives tenantId from the
API-key principal or from session.activeOrganizationId, and from no header;
packages/verify/src/harness.org-context.test.ts pins it — "session.activeOrganizationId
is the ONE field resolveAuthzContext reads into tenantId". Environment and kernel
routing read the hostname and X-Environment-Id. The framework's only other mentions are
the CORS preflight allow-list (DEFAULT_CORS_ALLOW_HEADERS, whose comment describes
X-Tenant-ID / X-Environment-Id as what routes "a request to its environment") and
plugin-sharing, which records that trusting x-tenant-id as identity was a
vulnerability: its secure default stopped reading identity from headers because doing so
let a client forge attribution and enumerate or revoke other users' links.
A reader may:
- use it to select the tenant database or environment to route to, subject to its own
identificationSourcesprecedence; - treat it as a hint that may be absent, stale, or contradicted by the session.
A reader may not:
- treat it as authenticated identity, or as an authorization decision. It is
client-controlled — any caller can send any value, and
plugin-sharingis the recorded precedent for what happens to a server that trusts it; - assume the row scoping it sees downstream came from this header. It did not: the framework scopes from the session;
- assume the header is present. See below.
The stamp reads storage that AuthProvider fills only after getSession →
listOrganizations → getActiveOrganization resolves. Every request that leaves
before that chain completes carries no X-Tenant-ID at all.
The window opens in five situations:
- a browser that has never signed in, or whose site data was cleared, including a private window;
- the page load in which the user signs in — from the sign-in response until the organization chain resolves;
- after sign-out, which clears the storage, until a new organization resolves;
- outside a browser (SSR, tests, a worker), where
localStorageis unavailable and the storage falls back to a per-process in-memory value that starts empty on every cold start; - a browser where
localStorageexists but rejects writes (Safari private browsing, quota exhaustion). There the window never closes — the in-memory fallback is written but never read back, becauseget()only falls back when reading itself throws, and a rejected write leaves reads working and returningnull. Reported on objectui#5279 as a separate defect; it is not fixed here.
Case 1 is not theoretical in this codebase: app-shell's MetadataProvider issues its
eager app metadata fetch inside exactly this window, which is why its first-boot cache
scope needed its own reasoning
(packages/app-shell/src/providers/MetadataProvider.tsx,
pinned by MetadataProvider.firstBootOrgScope.test.tsx).
What a reader observes in the window: the header is absent, never
present-and-empty. A resolver must fall through to its next configured identification
source — subdomain already outranks it by default, with jwt_claim, session and
defaultTenantId behind it — rather than fail closed on the absence. It must also not
cache a routing decision taken inside the window as the tenant for the session: the very
next request will normally carry the header.
Why the gap does not corrupt data scoping. A response computed inside the window is
still computed for the right tenant, because the framework takes tenantId from the
session rather than from the header. The gap is a routing-input gap, not a scoping gap.
This is the same reasoning objectui#5243 relied on when it relabelled a metadata cache
entry that had been written in the window.
Closing the gap is a separate decision, not an omission. The cloud readers observe the current behaviour, so changing when the header first appears changes what they see. If it should be closed, it needs its own card.
createAuthClient().getConfig() fetches the server's public auth configuration. The
features map on it tells the login surface which capabilities the deployment actually
has, so the UI never renders an entry point whose endpoint is not mounted — features.sso
gates the "Sign in with SSO" button, features.phoneNumberOtp gates the
verification-code mode, features.deviceAuthorization gates the device-approval page,
and so on.
features.audiencePosture reports which audience posture is in force: invite_only (the
default when the deployment declares none), email_domain or open. It is a value, not a
flag, and the server does not force emailPassword.disableSignUp from it: under
invite_only the sign-up route still admits a pending invitee, and a fresh deployment's
first owner. So disableSignUp: false on its own does not mean a stranger may register.
A surface that offers a generic sign-up reads both keys. disableSignUp: true hides
sign-up outright. Otherwise offer it when the posture is open or email_domain, or when
the visitor came from an invitation; under invite_only say that registration is by
invitation instead of rendering a form the server will refuse. A server that sends no
audiencePosture predates the key, and disableSignUp alone decides there. The
console's login and register pages are that reader (objectui#11691).
Two members of that map are declared but deliberately not consumed by this package:
| Flag | Status | What enabling it does in the UI today |
|---|---|---|
features.passkeys |
Reserved | Nothing. There is no passkey sign-in or registration UI for it to gate. |
features.magicLink |
Reserved | Nothing. There is neither a magic-link request step nor a route that consumes the emailed token. |
They are typed so the /auth/config payload round-trips without loss, not because
anything reads them. Turning either flag on server-side adds no entry point to the login
page. If you are enabling one because you expect a button to appear, it will not — and
that is the whole reason this section exists.
Building the two flows is tracked as a separate, unscheduled feature card, objectui#4179. Documenting them as reserved rather than building them now follows the maintainer's ruling on objectui#2514: mark them reserved today, build the UI when the login surface gets roadmap time, at which point it renders driven by these same flags. When that lands, this section retires with it.
features.twoFactor is not in this category, despite also not being read by
LoginForm. Two-factor authentication is implemented here (enableTwoFactor /
verifyTwoFactor on the auth client) and its challenge is driven by server-side
remediation rather than by the flag, so the login form ignoring the flag is the design,
not a gap.
Preview mode allows visitors (e.g. marketplace customers) to explore the platform without registering or logging in. The AuthProvider auto-authenticates with a simulated user identity and bypasses login/registration screens.
This capability is host-supplied and has no @objectstack/spec anchor. It aligned with PreviewModeConfig from @objectstack/spec/kernel until that symbol was retired upstream (objectstack#11846), which removed it together with the RuntimeMode value 'preview'; the spec this package resolves no longer exports it. The previewMode prop below is unaffected — it is the host's to supply, and always was.
import type { ReactNode } from 'react';
import { AuthProvider, PreviewBanner } from '@object-ui/auth';
declare const Dashboard: () => ReactNode;
function App() {
return (
<AuthProvider
authUrl="/api/v1/auth"
previewMode={{
simulatedRole: 'admin',
simulatedUserName: 'Demo Admin',
readOnly: false,
bannerMessage: 'You are exploring a demo — data will be reset periodically.',
}}
>
<PreviewBanner />
<Dashboard />
</AuthProvider>
);
}| Property | Type | Default | Description |
|---|---|---|---|
autoLogin |
boolean |
true |
Auto-login as simulated user, skipping login/registration pages |
simulatedRole |
'admin' | 'user' | 'viewer' |
'admin' |
Permission role for the simulated preview user |
simulatedUserName |
string |
'Preview User' |
Display name for the simulated preview user |
readOnly |
boolean |
false |
Restrict the preview session to read-only operations |
expiresInSeconds |
number |
0 |
Preview session duration in seconds (0 = no expiration) |
bannerMessage |
string |
— | Banner message displayed in the UI during preview mode |
A component that renders a status banner when preview mode is active. Shows bannerMessage from the preview config, or a default message.
import { PreviewBanner } from '@object-ui/auth';
// Only renders when isPreviewMode is true
<PreviewBanner />Use the useAuth hook to check if the app is in preview mode:
import { useAuth } from '@object-ui/auth';
function MyComponent() {
const { isPreviewMode, previewMode } = useAuth();
if (isPreviewMode && previewMode?.readOnly) {
// Disable write operations
}
return <div>...</div>;
}
⚠️ Security: Preview mode should never be used in production environments.
- 📦 npm package
- 📝 Changelog
- 🐛 Report an issue
- 🤝 Contributing Guide
- 🗺️ Roadmap
MIT — see LICENSE.