Skip to content

Commit 5b08389

Browse files
os-zhuangclaude
andauthored
fix(runtime)!: the /auth domain answers 501 instead of fabricating a login (#4113) (#4179)
`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`, not 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 plugin-auth served it. It was never a bypass: no session store backs that token, so `resolve-execution-context.ts` still resolved anonymous and `shouldDenyAnonymous` still denied data access. It was wrong 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 repo or in objectui, whose auth tests mock at the HTTP client layer. The only things pinning it were nine tests asserting the mock itself. ADR-0115 retired this 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 (#3891's 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, which would keep the code path in every release and become the next thing nobody audits. 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; 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 — its comment said it existed to keep sign-in "from 404ing" — without the lie it answered that concern with. The message names the remedy. A wrong-shaped occupant (a service without the contract's `handleRequest`) takes the same 501, and that is the sharper case: the slot is FILLED, so discovery advertises `routes.auth` while the request used to get a fabricated session. The `randomUUID` helper goes with it: a CSPRNG built solely to mint mock session ids, dead the moment the mock is. `domains/auth.ts` drops 141 → 81 lines. Two things the deletion left behind, both caught after the fact: the route-envelope ledger's `handBuilt: 4` exemption for `auth.ts` drops to 0 — those four better-auth-shaped bodies were the MOCK's, not the bridge's, since the real service returns its own `Response` built nowhere in this file — and `content/docs/permissions/authentication.mdx`, which documented all six fabricating endpoints as a feature, now documents the 501 and points anyone who relied on it at mocking in the HTTP client layer or loading AuthPlugin (which needs no HTTP server). Tests replace the nine that pinned the mock: all six formerly-mocked paths parametrized so a partial re-introduction names itself, no `mock_`/user/session in the body, the remedy named, an identical answer for any credentials (a 501 that varied by password would be an oracle), a registered service still delegated to, and the wrong-shaped-occupant case. Verified: runtime 940, plugin-hono-server 149, plugin-auth 579, rest 512 pass; build 71/71; pnpm lint and all ten check:* scripts clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 48bec97 commit 5b08389

7 files changed

Lines changed: 139 additions & 158 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': major
3+
---
4+
5+
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).
6+
7+
`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.
8+
9+
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.
10+
11+
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.
12+
13+
**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.
14+
15+
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.

content/docs/permissions/authentication.mdx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,22 +1068,21 @@ await kernel.bootstrap();
10681068

10691069
> ⚠️ **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`).
10701070
1071-
### Mock Fallback Endpoints
1071+
### No auth service? `/auth/*` answers 501 — it never fabricates a session
10721072

1073-
When no auth service handler is registered and the legacy broker login is unavailable, `HttpDispatcher.handleAuth()` automatically provides mock responses for:
1073+
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.
10741074

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

1084-
This ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments.
1077+
```
1078+
501 Auth service not available — register @objectstack/plugin-auth to enable authentication
1079+
```
1080+
1081+
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).
1082+
1083+
A service registered in the slot but not implementing the contract's `handleRequest` takes the same 501.
10851084

1086-
> **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).
1085+
**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.
10871086

10881087
## Next Steps
10891088

docs/adr/0115-plugin-dev-assembly-not-stub-table.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ Three facts changed between ratification and execution, all shrinking the work:
115115
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.
116116
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.
117117
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.
118-
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.
118+
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.)*
119119

120120
## Consequences
121121

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -638,11 +638,17 @@ describe('HttpDispatcher extracted domains (PR-7: auth/ai)', () => {
638638
expect(handleRequest).toHaveBeenCalledTimes(1);
639639
});
640640

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

648654
it('/ai/agents returns an empty list (not 404) when no AI service is configured', async () => {

packages/runtime/src/domains/auth.ts

Lines changed: 36 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,14 @@
22

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

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

14-
/**
15-
* Browser-safe UUID generator — prefers Web Crypto's `randomUUID`, falls back
16-
* to an RFC 4122 v4 built from `crypto.getRandomValues` (available everywhere
17-
* `randomUUID` might be missing, e.g. non-secure contexts). The legacy
18-
* `Math.random()` fallback was a latent CodeQL js/insecure-randomness hit
19-
* surfaced by the extraction — these ids feed mock session tokens, so use
20-
* CSPRNG bytes regardless.
21-
*/
22-
function randomUUID(): string {
23-
const c: Crypto | undefined = globalThis.crypto;
24-
if (c && typeof c.randomUUID === 'function') {
25-
return c.randomUUID();
26-
}
27-
const bytes = new Uint8Array(16);
28-
if (c && typeof c.getRandomValues === 'function') {
29-
c.getRandomValues(bytes);
30-
} else {
31-
// No crypto at all (ancient runtime) — mock-only path; still avoid
32-
// Math.random by deriving from the only entropy available.
33-
for (let i = 0; i < 16; i++) bytes[i] = (Date.now() + i * 7919) & 0xff;
34-
}
35-
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
36-
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
37-
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
38-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
39-
}
40-
4113
export function createAuthDomain(deps: DomainHandlerDeps): DomainRoute {
4214
return {
4315
prefix: '/auth',
@@ -64,78 +36,46 @@ export async function handleAuthRequest(deps: DomainHandlerDeps, path: string, m
6436
// the fabricated shape, not the declared one (the same test-side hole that
6537
// kept #4087 green, catalogued in #4127's last section).
6638
//
67-
// Reading the contract also makes the branch reachable for the first time.
39+
// Reading the contract also made this branch reachable for the first time.
6840
// The Hono adapter calls `handleRequest` itself and only falls through to
6941
// this dispatcher when no usable auth service answered, so nothing was
70-
// silently served by the mock below in that deployment — but a host that
71-
// reaches `handleAuth` directly WITH an auth service registered used to get
72-
// `mockAuthFallback`'s `mock_<uuid>` session instead of real authentication.
73-
// It now gets the auth service.
42+
// silently served by the since-retired mock in that deployment — but a host
43+
// that reached `handleAuth` directly WITH an auth service registered used to
44+
// get that mock's `mock_<uuid>` session instead of real authentication. It
45+
// now gets the auth service; #4113 removed the mock entirely (see below).
7446
const authService = await deps.getService(CoreServiceName.enum.auth);
7547
if (authService && typeof authService.handleRequest === 'function') {
7648
const response = await authService.handleRequest(context.request as Request);
7749
return { handled: true, result: response };
7850
}
7951

80-
// 2. Mock fallback for MSW/test environments when no auth service is registered
81-
const normalizedPath = path.replace(/^\/+/, '');
82-
return mockAuthFallback(normalizedPath, method, body);
83-
}
84-
85-
/**
86-
* Provides mock auth responses for core better-auth endpoints when
87-
* AuthPlugin is not loaded (e.g. MSW/browser-only environments).
88-
* This ensures registration/sign-in flows do not 404 in mock mode.
89-
*/
90-
function mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
91-
const m = method.toUpperCase();
92-
const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours
93-
94-
// POST sign-up/email
95-
if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
96-
const id = `mock_${randomUUID()}`;
97-
return {
98-
handled: true,
99-
response: {
100-
status: 200,
101-
body: {
102-
user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
103-
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
104-
},
105-
},
106-
};
107-
}
108-
109-
// POST sign-in/email or login
110-
if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
111-
const id = `mock_${randomUUID()}`;
112-
return {
113-
handled: true,
114-
response: {
115-
status: 200,
116-
body: {
117-
user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
118-
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
119-
},
120-
},
121-
};
122-
}
123-
124-
// GET get-session
125-
if (path === 'get-session' && m === 'GET') {
126-
return {
127-
handled: true,
128-
response: { status: 200, body: { session: null, user: null } },
129-
};
130-
}
131-
132-
// POST sign-out
133-
if (path === 'sign-out' && m === 'POST') {
134-
return {
135-
handled: true,
136-
response: { status: 200, body: { success: true } },
137-
};
138-
}
139-
140-
return { handled: false };
52+
// 2. No auth service — 501, never a fabricated session (#4113).
53+
//
54+
// This used to answer `POST /auth/sign-in/email` (and sign-up, get-session,
55+
// sign-out) with 200 and a `mock_<uuid>` user + a 24-hour `mock_token_*`
56+
// session, for ANY email and ANY password — the password was never read.
57+
// It shipped in `packages/runtime`, not behind a dev-only plugin, and it
58+
// gated on nothing but "is the slot empty", so `os serve --preset minimal`
59+
// and any embedder without plugin-auth got it. Not a bypass — no session
60+
// store backs the token, so `resolve-execution-context.ts` still resolves
61+
// anonymous and `shouldDenyAnonymous` still denies — but it told the client
62+
// the one thing a server must never lie about: that it had authenticated
63+
// someone. Its own justification ("MSW/browser-only environments") had no
64+
// consumer in this repo or in `objectui`, whose auth tests mock at the HTTP
65+
// client layer; only two tests pinned it, and they pinned the mock itself.
66+
//
67+
// ADR-0115 retired this whole class inside plugin-dev; this was the last
68+
// member, and the only one that shipped to production. Its lineage — the
69+
// #3891 analytics shim, #4000's dev stub, #4058/#4086's three, #4126's
70+
// security trio — was retired the same way: deleted, not flagged.
71+
//
72+
// 501, not 404, following `/i18n` — the nearest precedent in shape (a core
73+
// capability, a dispatcher-owned domain, an optional plugin behind it, and
74+
// a route discovery already declines to advertise when the slot is empty).
75+
// The route IS mounted here; what is missing is the implementation behind
76+
// it, which is what 501 states and 404 would misdescribe. It also keeps
77+
// faith with the one true observation the mock was built on — that a bare
78+
// 404 on sign-in sends the operator hunting for a routing bug — without
79+
// the lie it used to answer that concern.
80+
return { handled: true, response: deps.error('Auth service not available — register @objectstack/plugin-auth to enable authentication', 501) };
14181
}

0 commit comments

Comments
 (0)