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
15 changes: 15 additions & 0 deletions .changeset/retire-the-dispatcher-auth-mock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/runtime': major
---

The `/auth` domain no longer fabricates a login. With no auth service registered it answers **501**; the mock that answered 200 with a made-up session is deleted (#4113).

`packages/runtime/src/domains/auth.ts` carried a `mockAuthFallback` that answered `POST /auth/sign-up/email`, `/register`, `/sign-in/email`, `/login`, `GET /get-session` and `POST /sign-out` with **200 and a fabricated user plus a 24-hour `mock_token_*` session — for any email and any password, which was never read**. It shipped in `@objectstack/runtime` rather than behind a dev-only plugin, and gated on nothing but an empty `auth` slot, so `os serve --preset minimal` and any embedder that mounts the dispatcher without `@objectstack/plugin-auth` served it.

It was never a bypass: no session store backs the token, so `resolve-execution-context.ts` still resolved anonymous and `shouldDenyAnonymous` still denied data access. It was worse in a different way — it told the client the one thing a server must never lie about, that it had authenticated someone, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. Its stated justification ("MSW/browser-only environments") had no consumer in this repository or in `objectui`, whose auth tests mock at the HTTP client layer; the only things pinning it were tests asserting the mock itself.

ADR-0115 retired this whole class of fabricating fallback inside `plugin-dev`. This was its last surviving member and the only one that shipped to production; the lineage before it — the #3891 analytics shim, #4000's dev stub, the three in #4058/#4086, #4126's security trio — was retired the same way: deleted, not put behind a flag.

**501 rather than 404**, following `/i18n`, the nearest precedent in shape: a core capability, a dispatcher-owned domain, an optional plugin behind it, and a route discovery already declines to advertise when the slot is empty. The route is mounted; what is missing is the implementation behind it — which is what 501 states and 404 would misdescribe. A wrong-shaped occupant (a service without the contract's `handleRequest`) takes the same 501, which is the sharper case: the slot is filled, so discovery advertises `routes.auth`, and that request previously got a fabricated session.

FROM → TO: a deployment without an auth service now gets `501 "Auth service not available — register @objectstack/plugin-auth to enable authentication"` on `/api/v1/auth/*` instead of a 200 carrying a session that never worked. Install `@objectstack/plugin-auth` (it is in the default `os serve` preset), or treat the absence as production already required — the 200 never produced a session the identity path accepted, so no working flow depended on it. Front-ends that mocked auth through this fallback should mock at the HTTP client layer or with an MSW handler, as `objectui` already does.
23 changes: 11 additions & 12 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1068,22 +1068,21 @@ await kernel.bootstrap();

> ⚠️ **Warning:** The secret above is for **local development only**. In production, always use a strong random secret from an environment variable (`process.env.OS_AUTH_SECRET`).

### Mock Fallback Endpoints
### No auth service? `/auth/*` answers 501 — it never fabricates a session

When no auth service handler is registered and the legacy broker login is unavailable, `HttpDispatcher.handleAuth()` automatically provides mock responses for:
The dispatcher used to carry a **mock fallback**: with no auth service in the slot, `sign-up/email`, `register`, `sign-in/email`, `login`, `get-session` and `sign-out` all answered `200` with a made-up user and a 24-hour `mock_token_*` session — for any email and any password, which was never read.

| Endpoint | Method | Description |
|:---|:---:|:---|
| `sign-up/email` | POST | Returns mock user + session |
| `sign-in/email` | POST | Returns mock user + session |
| `login` | POST | Legacy login — returns mock user + session |
| `register` | POST | Alias for sign-up |
| `get-session` | GET | Returns `{ session: null, user: null }` |
| `sign-out` | POST | Returns `{ success: true }` |
**That is removed** ([#4113](https://github.com/objectstack-ai/objectstack/issues/4113)). An unserved `auth` slot now answers:

This ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments.
```
501 Auth service not available — register @objectstack/plugin-auth to enable authentication
```

It was never an authentication bypass — no session store backed that token, so the identity path still resolved anonymous and anonymous data access was still denied. The problem was that it told the client it had authenticated someone when it had not, while discovery simultaneously reported `auth: unavailable` and advertised no `routes.auth`. A capability the runtime does not have must not be advertised by pretending to serve it (ADR-0076 D12, ADR-0115).

A service registered in the slot but not implementing the contract's `handleRequest` takes the same 501.

> **Note:** In server mode with AuthPlugin loaded, the auth service handler takes priority and the mock fallback is never reached. The mock fallback only activates when AuthPlugin is not loaded (e.g. browser-only Console/MSW builds where `better-auth` is unavailable).
**If you were relying on the mock** for a browser-only or MSW build: mock at the HTTP client layer, or with an MSW handler in your own test setup, which is what the console does. Load `AuthPlugin` (it needs no HTTP server — see above) and the real service answers instead.

## Next Steps

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0115-plugin-dev-assembly-not-stub-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Three facts changed between ratification and execution, all shrinking the work:
1. **#4086's merged form deleted nothing.** It gated the six dispatcher domains on `handlerReady` and explicitly deferred "should the fabricators keep occupying slots" to this ADR's Tier A. So `ai` / `automation` / `notification` join Tier A's deletion list here — the Context table's "retired" row described their HTTP surface (gated to empty-slot answers since #4086), not their registration.
2. **#4089 closed at the source.** #4082 had already given core's five fallbacks their `__serviceInfo` self-descriptions, so Tier B's core half was done before PR-2 existed; what remained of Tier B was only plugin-dev's `metadata` copy and the four wrappers.
3. **With the core half gone, the PR-1/PR-2 boundary lost its reason** (Tier B no longer reached into core), and the maintainer directed completing all remaining work at once. The implementation therefore landed as one PR: Tiers A+B+C, the D6 guard, the D4 assembly auto-wire, and the D7 docs convergence — the full end state above, under a single FROM → TO changeset narrative.
4. **#4126 landed the D2 security trio + D6 guard as a first subset while the full PR was in flight.** Its choices are canonical where they overlap: the escape hatch is `OS_ALLOW_DEV_PLUGIN` (not the longer name this ADR first wrote), the guard is a module-level `assertNotProduction()`, and an empty security slot gets one loud boot-log warn ("RBAC, row-level security and field masking are NOT enforced") instead of silence. It also filed #4113 — the dispatcher's `/auth` domain carries its own mock fallback in `packages/runtime` — as the remaining fabricator OUTSIDE plugin-dev; retiring plugin-dev's `auth` stub neither worsens nor fixes that path (both the stub and the runtime mock fabricate a 200; neither yields a session the identity resolver accepts), so #4113 stays the one place that class of fake survives.
4. **#4126 landed the D2 security trio + D6 guard as a first subset while the full PR was in flight.** Its choices are canonical where they overlap: the escape hatch is `OS_ALLOW_DEV_PLUGIN` (not the longer name this ADR first wrote), the guard is a module-level `assertNotProduction()`, and an empty security slot gets one loud boot-log warn ("RBAC, row-level security and field masking are NOT enforced") instead of silence. It also filed #4113 — the dispatcher's `/auth` domain carries its own mock fallback in `packages/runtime` — as the remaining fabricator OUTSIDE plugin-dev; retiring plugin-dev's `auth` stub neither worsens nor fixes that path (both the stub and the runtime mock fabricate a 200; neither yields a session the identity resolver accepts), so #4113 stays the one place that class of fake survives. *(Update: #4113 is now closed too — the `/auth` mock is deleted and an unserved `auth` slot answers 501. That makes this ADR's rule hold platform-wide rather than only inside plugin-dev, and it was the one member of the class that shipped to production rather than to a dev assembly. 501 rather than the 404 an empty optional slot usually takes, following `/i18n`: the route is mounted, the implementation behind it is not, and 404 would misdescribe that.)*

## Consequences

Expand Down
14 changes: 10 additions & 4 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,11 +638,17 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => {
expect(handleRequest).toHaveBeenCalledTimes(1);
});

it('/auth mock fallback serves sign-up when no auth service is registered', async () => {
// [#4113] Was: "mock fallback serves sign-up when no auth service is
// registered" — asserting a 200 whose `session.token` matched
// /^mock_token_/. That mock is retired; an empty slot now answers 501 and
// mints nothing. Routed through `dispatch` (not the domain body directly)
// so the whole registry path is covered.
it('/auth answers 501 — and no session — when no auth service is registered', async () => {
const result = await makeDispatcher().dispatch('POST', '/auth/sign-up/email', { email: 'a@b.c', name: 'A' }, {}, {} as any);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.user?.email).toBe('a@b.c');
expect(result.response?.body?.session?.token).toMatch(/^mock_token_/);
expect(result.response?.status).toBe(501);
expect(result.response?.body?.user).toBeUndefined();
expect(result.response?.body?.session).toBeUndefined();
expect(JSON.stringify(result.response?.body ?? {})).not.toMatch(/mock_token_/);
});

it('/ai/agents returns an empty list (not 404) when no AI service is configured', async () => {
Expand Down
132 changes: 36 additions & 96 deletions packages/runtime/src/domains/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,14 @@

/**
* `/auth` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-7).
* Bridges to the `auth` service's better-auth handler; when no auth service
* is registered (MSW / browser-only mock environments) a minimal mock
* fallback keeps core sign-up/sign-in/session flows from 404ing.
* Bridges to the `auth` service's contract handler. With no auth service
* registered the domain answers 501 — it never fabricates a session (#4113).
*/

import { CoreServiceName } from '@objectstack/spec/system';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';

/**
* Browser-safe UUID generator — prefers Web Crypto's `randomUUID`, falls back
* to an RFC 4122 v4 built from `crypto.getRandomValues` (available everywhere
* `randomUUID` might be missing, e.g. non-secure contexts). The legacy
* `Math.random()` fallback was a latent CodeQL js/insecure-randomness hit
* surfaced by the extraction — these ids feed mock session tokens, so use
* CSPRNG bytes regardless.
*/
function randomUUID(): string {
const c: Crypto | undefined = globalThis.crypto;
if (c && typeof c.randomUUID === 'function') {
return c.randomUUID();
}
const bytes = new Uint8Array(16);
if (c && typeof c.getRandomValues === 'function') {
c.getRandomValues(bytes);
} else {
// No crypto at all (ancient runtime) — mock-only path; still avoid
// Math.random by deriving from the only entropy available.
for (let i = 0; i < 16; i++) bytes[i] = (Date.now() + i * 7919) & 0xff;
}
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

export function createAuthDomain(deps: DomainHandlerDeps): DomainRoute {
return {
prefix: '/auth',
Expand All @@ -64,78 +36,46 @@ export async function handleAuthRequest(deps: DomainHandlerDeps, path: string, m
// the fabricated shape, not the declared one (the same test-side hole that
// kept #4087 green, catalogued in #4127's last section).
//
// Reading the contract also makes the branch reachable for the first time.
// Reading the contract also made this branch reachable for the first time.
// The Hono adapter calls `handleRequest` itself and only falls through to
// this dispatcher when no usable auth service answered, so nothing was
// silently served by the mock below in that deployment — but a host that
// reaches `handleAuth` directly WITH an auth service registered used to get
// `mockAuthFallback`'s `mock_<uuid>` session instead of real authentication.
// It now gets the auth service.
// silently served by the since-retired mock in that deployment — but a host
// that reached `handleAuth` directly WITH an auth service registered used to
// get that mock's `mock_<uuid>` session instead of real authentication. It
// now gets the auth service; #4113 removed the mock entirely (see below).
const authService = await deps.getService(CoreServiceName.enum.auth);
if (authService && typeof authService.handleRequest === 'function') {
const response = await authService.handleRequest(context.request as Request);
return { handled: true, result: response };
}

// 2. Mock fallback for MSW/test environments when no auth service is registered
const normalizedPath = path.replace(/^\/+/, '');
return mockAuthFallback(normalizedPath, method, body);
}

/**
* Provides mock auth responses for core better-auth endpoints when
* AuthPlugin is not loaded (e.g. MSW/browser-only environments).
* This ensures registration/sign-in flows do not 404 in mock mode.
*/
function mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
const m = method.toUpperCase();
const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours

// POST sign-up/email
if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
const id = `mock_${randomUUID()}`;
return {
handled: true,
response: {
status: 200,
body: {
user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
},
},
};
}

// POST sign-in/email or login
if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
const id = `mock_${randomUUID()}`;
return {
handled: true,
response: {
status: 200,
body: {
user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
},
},
};
}

// GET get-session
if (path === 'get-session' && m === 'GET') {
return {
handled: true,
response: { status: 200, body: { session: null, user: null } },
};
}

// POST sign-out
if (path === 'sign-out' && m === 'POST') {
return {
handled: true,
response: { status: 200, body: { success: true } },
};
}

return { handled: false };
// 2. No auth service — 501, never a fabricated session (#4113).
//
// This used to answer `POST /auth/sign-in/email` (and sign-up, get-session,
// sign-out) with 200 and a `mock_<uuid>` user + a 24-hour `mock_token_*`
// session, for ANY email and ANY password — the password was never read.
// It shipped in `packages/runtime`, not behind a dev-only plugin, and it
// gated on nothing but "is the slot empty", so `os serve --preset minimal`
// and any embedder without plugin-auth got it. Not a bypass — no session
// store backs the token, so `resolve-execution-context.ts` still resolves
// anonymous and `shouldDenyAnonymous` still denies — but it told the client
// the one thing a server must never lie about: that it had authenticated
// someone. Its own justification ("MSW/browser-only environments") had no
// consumer in this repo or in `objectui`, whose auth tests mock at the HTTP
// client layer; only two tests pinned it, and they pinned the mock itself.
//
// ADR-0115 retired this whole class inside plugin-dev; this was the last
// member, and the only one that shipped to production. Its lineage — the
// #3891 analytics shim, #4000's dev stub, #4058/#4086's three, #4126's
// security trio — was retired the same way: deleted, not flagged.
//
// 501, not 404, following `/i18n` — the nearest precedent in shape (a core
// capability, a dispatcher-owned domain, an optional plugin behind it, and
// a route discovery already declines to advertise when the slot is empty).
// The route IS mounted here; what is missing is the implementation behind
// it, which is what 501 states and 404 would misdescribe. It also keeps
// faith with the one true observation the mock was built on — that a bare
// 404 on sign-in sends the operator hunting for a routing bug — without
// the lie it used to answer that concern.
return { handled: true, response: deps.error('Auth service not available — register @objectstack/plugin-auth to enable authentication', 501) };
}
Loading
Loading