Skip to content

Commit e148ca9

Browse files
fix(plugin-auth): with SSO domain verification on, the domain-verification doors answer an unknown provider 404 RESOURCE_NOT_FOUND (#22486)
Fixes #22463 Clause-②: yes (widening) ## What changed With `OS_SSO_DOMAIN_VERIFICATION` on, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` answered an unknown `providerId` with `400 DOMAIN_VERIFICATION_DISABLED` ("set OS_SSO_DOMAIN_VERIFICATION"), telling the operator to turn on a feature that was already on. `@better-auth/sso` 1.7.3 answers both an unmounted route (feature off) and an unknown provider (feature on, `checkProviderAccess`) with a `404` that carries no `code`, and both bridges mapped every such `404` to the feature-off answer. Following triage `6079484598`, the bridges now decide from the environment's own setting, never from the vendor's 404 body: - `packages/plugins/plugin-auth/src/register-sso-provider.ts`: `runRequestDomainVerification` and `runVerifyDomain` take an optional third argument, `{ domainVerificationEnabled?: boolean }`. - `false`: `400 DOMAIN_VERIFICATION_DISABLED`, returned after the existing input checks and before any vendor call. - `true`: a code-less vendor `404` is the provider lookup missing, answered `404 RESOURCE_NOT_FOUND` with the message `SSO provider "ID" was not found.` A `404` that carries a code (`NO_PENDING_VERIFICATION`) is still the vendor's answer and passes through. - omitted: the earlier mapping, byte for byte. The cloud auth proxy mounts these exported helpers and hands no flag, so it sees no change. - `packages/plugins/plugin-auth/src/auth-plugin.ts`: the two mounts hand `this.authManager!.isSsoDomainVerificationEnabled()`, read after `gateAdmin`, so the gate still answers first. - No new error code. `RESOURCE_NOT_FOUND` is `HttpStatusErrorCodeMap[404]`; `check:error-status-conformance` derives the new site (`register-sso-provider.ts:381`, `RESOURCE_NOT_FOUND` at `404`) and stays green. - `admin-sso-bridge-gate.test.ts`: its stand-in auth manager gains `isSsoDomainVerificationEnabled: () => true` (its vendor runs `sso({ domainVerification: { enabled: true } })`); the mounts now read that seam after the gate. ## Reproduction (before), on the mounted route `origin/main` `f66c440de`: the plugin's real `registerAuthRoutes` on Hono, a real `AuthManager` (better-auth 1.7.3, `@better-auth/sso` 1.7.3), legacy-`role` platform admin by bearer, body `{ "providerId": "no-such-provider-22463" }`. | flag | door | vendor's inner answer | door's answer | |---|---|---|---| | on | `request-domain-verification` | `404 {"message":"Provider not found"}` | `400 DOMAIN_VERIFICATION_DISABLED` | | on | `verify-domain` | `404 {"message":"Provider not found"}` | `400 DOMAIN_VERIFICATION_DISABLED` | | off (SSO on) | `request-domain-verification` | `404`, empty body | `400 DOMAIN_VERIFICATION_DISABLED` | | off (SSO on) | `verify-domain` | `404`, empty body | `400 DOMAIN_VERIFICATION_DISABLED` | ## Pins Real stack: `sso-domain-verification-unknown-provider.pin.test.ts` (new). Two real `AuthManager`s over the shared in-memory engine (`ssoDomainVerification` on, and SSO on with it off), each behind the plugin's real route registration on Hono. Vendor calls are counted on `AuthManager.handleRequest`, filtered to the inner path. | pin | answer | |---|---| | precondition | the flag reads `true` / `false` on the two stacks; the vendor's two code-less 404 shapes, measured live | | ON, unknown provider, both doors | `404 RESOURCE_NOT_FOUND`, message names the provider, vendor asked once | | OFF, both doors (control) | `400 DOMAIN_VERIFICATION_DISABLED`, vendor never asked | | ON, the admin's own provider, `request-domain-verification` | the vendor's `201` becomes the `200` DNS record | | ON, the admin's own provider, `verify-domain` | the vendor's coded `404 NO_PENDING_VERIFICATION` passes through, not `RESOURCE_NOT_FOUND` | | anonymous, both doors, ON and OFF | `401 UNAUTHENTICATED`, vendor never asked | | a bridge handed no flag, real ON stack, both bridges | `400 DOMAIN_VERIFICATION_DISABLED`, as before | Unit (`sso-domain-verification-error-codes.test.ts`, fake vendor): the same three flag states on both bridges, plus: ON with an empty or reworded 404 body gives the same not-found answer (the decision does not read the wording), and OFF still answers a missing `providerId` with `400 INVALID_REQUEST` first. ## Ablations Each leg ran through `scripts/ablation-replace.mjs` (anchor hit and landing proven on disk) on the committed fix (`527974a2c`), against the two pin files. Every restore was proven: blob equal to the `HEAD` blob, `git diff HEAD` empty, no other file in `git status`. The pins import the subject by relative path, so no `dist/` is involved. | leg | mutation | red | failing output | |---|---|---|---| | A1 (card's) | the any-404 mapping restored: every code-less 404 answers DISABLED again | 6: the card's pin on both doors, real and unit, plus both wording pins | `expected 'DOMAIN_VERIFICATION_DISABLED' to be 'RESOURCE_NOT_FOUND'`; `expected 400 to be 404` | | A2 | the off early return deleted (both bridges) | 4: OFF "vendor never asked", real and unit, both doors | `expected 1 to be +0` | | A3 | the mounts hand no flag | 4: the card's pin and the OFF control, real stack, both doors | `expected 400 to be 404`; `expected 1 to be +0` | | A4 | every ON 404 read as not-found, coded or not | 2: `NO_PENDING_VERIFICATION` passes through, real and unit | `expected 'RESOURCE_NOT_FOUND' to be 'NO_PENDING_VERIFICATION'` | | A5 | a missing flag read as on | 7: the no-flag pins, real and unit, plus the three earlier DISABLED pins | `expected 'RESOURCE_NOT_FOUND' to be 'DOMAIN_VERIFICATION_DISABLED'` | | A6 | a capability answer moved ahead of `gateAdmin` on the request mount | 2: anonymous OFF on that door, and the OFF control's message | `expected 400 to be 401` | A6's first attempt was a no-op: the replacement re-contained the anchor, the tool refused it (exit 1) before any test ran, and the leg was re-run with an anchor-free replacement. The ablations also caught a dead counter in the first draft of the real-stack pins. `impersonation-bearer-rotation.test.ts` (where `createMemoryEngine` lives) registers `afterEach(() => vi.restoreAllMocks())` in every file that imports it, so a `vi.spyOn` counter was stripped after the first test, and every "vendor never asked" pin passed on a dead counter. The "vendor asked once" legs turned red on it. The counter is now a plain instance wrapper, and A2 and A3 show it reads. ## Wall time Shared-box seconds, `vitest run --maxWorkers=2`, one file per run: - `in-process-session-renewal.pin.test.ts` (not edited): 20.65 s before (`f66c440de`, 39 tests), 19.74 s after (`dd583360b`, 39 tests). - `sso-domain-verification-unknown-provider.pin.test.ts` (new): 18.58 s, 25 tests. 15 are its own; the other 10 are `impersonation-bearer-rotation.test.ts`'s, which every importer of `createMemoryEngine` runs again. ## Verification - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` at `527974a2c`: 135 files, 2766 passed, 10 skipped. The merge of `main` (`dd583360b`) brought only `scripts/pm/fleet-write/**`, `.github/workflows/fleet-write.yml` and one `.claude` reference doc. - The four affected files at `dd583360b`: 111 passed. - `pnpm --filter @objectstack/plugin-auth typecheck` at `527974a2c` (src, examples, test layer): green. The emitted `.d.ts` adds only the optional third parameter; the two helper types are not exported. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` at `dd583360b` derived 68. 67 exit 0. One is NOT MEASURED: `check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET) because it needs every package's `dist/`, and this worktree built only `plugin-auth`'s closure. `--ran` reconciliation: 68 accounted for, 0 unrun. The four artifact-roster families whose roster sits in a touched directory (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) also exit 0. ## Acceptance notes - **`authInstance` boundary.** `isSsoDomainVerificationEnabled()` reads `config.plugins` and the env, the same expressions `buildPluginList` uses, and `applyConfigPatch` discards the built instance when they change, so the flag tracks what is mounted. A host that hands `AuthManagerOptions.authInstance` (a pre-built better-auth instance) is not described by that config: with domain verification on in its instance but not in its config, the mounts would now answer DISABLED without asking the vendor. Zero in-repo producers of `authInstance` (`git grep` at `dd583360b`: the declaration and its one use in `auth-manager.ts`). Owner: none. - **The cloud auth proxy** is not measured: this session cannot read `objectstack-ai/cloud`. It hands no flag, so its answers are unchanged; it gets the not-found answer only once it passes `{ domainVerificationEnabled }`. - **`DOMAIN_VERIFICATION_FAILED` on the real stack** (the vendor's 502 when the DNS TXT record is missing) is not pinned here: it needs a DNS lookup. The unit pins cover the 502 path, unchanged. - **Test-harness trap, observation only.** Importing `createMemoryEngine` from `impersonation-bearer-rotation.test.ts` brings that file's 10 tests and its `afterEach(restoreAllMocks)` into each of the 15 importers. A lexical scan of the other 14 for a non-console `vi.spyOn` inside `beforeAll` found none; it does not see spies set inside helpers called from `beforeAll`, which was this PR's shape. Owner: none. - Dogfood expectations are unchanged: with SSO off, the flag is false and both doors still answer `400 DOMAIN_VERIFICATION_DISABLED`, now without a vendor call. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 557ae7c commit e148ca9

6 files changed

Lines changed: 463 additions & 24 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
With SSO domain verification on, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` answer an unknown `providerId` with `404 RESOURCE_NOT_FOUND`, no longer with `400 DOMAIN_VERIFICATION_DISABLED`
6+
7+
Clause-②: yes (widening)
8+
9+
- **Before.** With `OS_SSO_DOMAIN_VERIFICATION` on, a platform admin who sent a `providerId` that does not exist got `400 DOMAIN_VERIFICATION_DISABLED`, "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION)": an instruction to turn on a feature that was already on. `@better-auth/sso` answers both an unmounted route (feature off) and an unknown provider (feature on) with a `404` that carries no `code`, and both bridges mapped every such `404` to the feature-off answer.
10+
- **Now.** The two mounts hand each bridge the environment's own setting (`AuthManager.isSsoDomainVerificationEnabled()`). Off: `400 DOMAIN_VERIFICATION_DISABLED`, as before, and the vendor is no longer called. On: a code-less vendor `404` is the provider lookup missing, answered `404 RESOURCE_NOT_FOUND` with a message naming the provider id. The decision never reads the vendor's `404` wording.
11+
- **Unchanged.** The platform-admin gate answers first, on or off (an anonymous caller gets `401`). A missing `providerId` is still `400 INVALID_REQUEST`. With the feature on, an existing provider still gets the vendor's own answer: the DNS record, `NO_PENDING_VERIFICATION`, `DOMAIN_VERIFICATION_FAILED`, a `403` for a provider the caller may not manage.
12+
- **The exported bridges.** `runRequestDomainVerification` and `runVerifyDomain` take an optional third argument, `{ domainVerificationEnabled?: boolean }`. A caller that omits it keeps the earlier mapping: a code-less `404` is answered `400 DOMAIN_VERIFICATION_DISABLED`, byte for byte. A host that mounts these helpers itself passes the environment's setting to get the not-found answer.

‎packages/plugins/plugin-auth/src/admin-sso-bridge-gate.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,10 @@ const mockCtx = (): PluginContext =>
8787
/**
8888
* Mount the plugin's REAL route registration on a real Hono app (the
8989
* auth-catchall-fallthrough.test.ts harness), with the auth manager reduced to
90-
* the two seams the bridges read: `getApi().getSession` (what the gate judges)
91-
* and `handleRequest` (where the bridge delegates).
90+
* the seams the bridges read: `getApi().getSession` (what the gate judges),
91+
* `handleRequest` (where the bridge delegates) and, after the gate, the two
92+
* domain-verification mounts' `isSsoDomainVerificationEnabled()` [#22463] —
93+
* ON here, as in `makeSsoVendor` below.
9294
*/
9395
async function mountBridges(deps: {
9496
getSession: (headers: Headers) => unknown | Promise<unknown>;
@@ -110,6 +112,7 @@ async function mountBridges(deps: {
110112
getApi: async () => ({
111113
getSession: async ({ headers }: { headers: Headers }) => deps.getSession(headers),
112114
}),
115+
isSsoDomainVerificationEnabled: () => true,
113116
};
114117
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
115118
return app;

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3073,7 +3073,9 @@ export class AuthPlugin implements Plugin {
30733073
// verify-domain} (so the per-provider admin gate runs) and reshape into the
30743074
// `{ success, data }` envelope the action `resultDialog` / toast reads:
30753075
// request returns the ready-to-paste DNS TXT record, verify returns a clear
3076-
// success/error. A 404 from the inner endpoint = feature OFF for this env.
3076+
// success/error. [#22463] Each hands the bridge this environment's own
3077+
// setting, so a feature-off answer and an unknown provider's are told apart
3078+
// (the vendor answers both with a code-less 404).
30773079
rawApp.post(`${basePath}/admin/sso/request-domain-verification`, async (c: any) => {
30783080
try {
30793081
// #9653 — same ADR-0068 D4 platform-admin gate as /admin/sso/register
@@ -3084,6 +3086,7 @@ export class AuthPlugin implements Plugin {
30843086
const { status, body } = await runRequestDomainVerification(
30853087
(req) => this.authManager!.handleRequest(req),
30863088
c.req.raw,
3089+
{ domainVerificationEnabled: this.authManager!.isSsoDomainVerificationEnabled() },
30873090
);
30883091
return c.json(body, status as any);
30893092
} catch (error) {
@@ -3101,6 +3104,7 @@ export class AuthPlugin implements Plugin {
31013104
const { status, body } = await runVerifyDomain(
31023105
(req) => this.authManager!.handleRequest(req),
31033106
c.req.raw,
3107+
{ domainVerificationEnabled: this.authManager!.isSsoDomainVerificationEnabled() },
31043108
);
31053109
return c.json(body, status as any);
31063110
} catch (error) {

‎packages/plugins/plugin-auth/src/register-sso-provider.ts‎

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,54 @@ export async function runRegisterSamlProviderFromForm(
335335
// through the real endpoints (so the per-provider admin gate runs) and reshape
336336
// the response into the `{ success, data }` envelope the action `resultDialog`
337337
// reads — request returns the ready-to-paste DNS record; verify returns a
338-
// friendly success/error message. A `404` from the inner endpoint means the
339-
// feature is OFF for this env (endpoints unmounted) → surfaced as such, not a
340-
// bare "not found".
338+
// friendly success/error message.
339+
//
340+
// [#22463] The vendor answers two different `404`s, neither with a `code`: the
341+
// feature OFF (the inner route is not mounted, empty body) and, with it ON, an
342+
// unknown `providerId` (`checkProviderAccess`). The bridge does not tell them
343+
// apart by the body — its wording is the vendor's, not a contract — but by the
344+
// environment's own setting, which the caller hands in
345+
// (`AuthManager.isSsoDomainVerificationEnabled()`, the decision
346+
// `buildPluginList` makes for `sso({ domainVerification })`):
347+
// • `false` → `400 DOMAIN_VERIFICATION_DISABLED`, without calling the vendor;
348+
// • `true` → the inner route exists, so a code-less `404` is the provider
349+
// lookup missing → `404 RESOURCE_NOT_FOUND`;
350+
// • omitted → the two cannot be told apart, and both keep the earlier answer,
351+
// `400 DOMAIN_VERIFICATION_DISABLED` (a caller that hands no flag,
352+
// such as the cloud auth proxy, sees no change).
341353

342354
/** @better-auth/sso default verification token prefix (we don't override `tokenPrefix`). */
343355
const SSO_DOMAIN_TOKEN_PREFIX = 'better-auth-token';
344356

357+
/** What the caller knows about this environment's domain-verification setting. */
358+
interface SsoDomainVerificationBridgeOptions {
359+
/**
360+
* Whether `@better-auth/sso` mounted the inner domain-verification routes on
361+
* this environment. Omitted: the bridge cannot tell a feature-off `404` from
362+
* an unknown provider's, and answers both as `DOMAIN_VERIFICATION_DISABLED`.
363+
*/
364+
domainVerificationEnabled?: boolean;
365+
}
366+
367+
type DomainVerificationBridgeResult = RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } };
368+
369+
/** The feature is off on this environment — one answer for both routes (#10859). */
370+
function domainVerificationDisabled(): DomainVerificationBridgeResult {
371+
return { status: 400, body: { success: false, error: { code: 'DOMAIN_VERIFICATION_DISABLED', message: 'Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).' } } };
372+
}
373+
374+
/**
375+
* The answer to a vendor `404` that carries no `code` (see the block above).
376+
* Only a caller that says the feature is on gets the not-found answer; with no
377+
* flag the earlier mapping stands.
378+
*/
379+
function codelessVendorNotFound(providerId: string, options: SsoDomainVerificationBridgeOptions): DomainVerificationBridgeResult {
380+
if (options.domainVerificationEnabled === true) {
381+
return { status: 404, body: { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: `SSO provider "${providerId}" was not found.` } } };
382+
}
383+
return domainVerificationDisabled();
384+
}
385+
345386
/**
346387
* Strip protocol / path / port so `https://acme.com/` → `acme.com` for the DNS
347388
* record. Regex-free on purpose — `domain` is request-controlled input, so a
@@ -390,11 +431,15 @@ function forwardAuthHeaders(request: Request, origin: string): Headers {
390431
* return the ready-to-paste DNS record (for a one-shot `resultDialog`).
391432
*
392433
* Body: `{ providerId, domain? }` (domain only shapes the displayed record name).
434+
*
435+
* @param options `domainVerificationEnabled` — the environment's own setting
436+
* (see the block above); omitted keeps the earlier mapping.
393437
*/
394438
export async function runRequestDomainVerification(
395439
handle: AuthRequestHandler,
396440
request: Request,
397-
): Promise<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
441+
options: SsoDomainVerificationBridgeOptions = {},
442+
): Promise<DomainVerificationBridgeResult> {
398443
let body: any;
399444
try { body = await request.json(); } catch { body = {}; }
400445
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
@@ -406,6 +451,8 @@ export async function runRequestDomainVerification(
406451

407452
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/request-domain-verification$/, '/sso/request-domain-verification');
408453
if (!rw) return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Bad request URL' } } };
454+
// [#22463] Off: the inner route is not mounted, so there is nothing to ask.
455+
if (options.domainVerificationEnabled === false) return domainVerificationDisabled();
409456
const headers = forwardAuthHeaders(request, rw.origin);
410457

411458
// [#22398] The inner route reads the session (`sessionMiddleware`); only its
@@ -414,9 +461,7 @@ export async function runRequestDomainVerification(
414461
let parsed: any = {};
415462
try { const t = await resp.text(); parsed = t ? JSON.parse(t) : {}; } catch { parsed = {}; }
416463
if (!resp.ok) {
417-
if (resp.status === 404 && !parsed?.code) {
418-
return { status: 400, body: { success: false, error: { code: 'DOMAIN_VERIFICATION_DISABLED', message: 'Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).' } } };
419-
}
464+
if (resp.status === 404 && !parsed?.code) return codelessVendorNotFound(providerId, options);
420465
// ADR-0112: `parsed?.code` is better-auth's own code passing through — never
421466
// overwritten. The DEFAULT is ours, so it is the registered SCREAMING ledger
422467
// entry for this feature (`DOMAIN_VERIFICATION_FAILED`, @objectstack/plugin-auth)
@@ -443,11 +488,15 @@ export async function runRequestDomainVerification(
443488
* envelope so the action surfaces a clear toast.
444489
*
445490
* Body: `{ providerId }`.
491+
*
492+
* @param options `domainVerificationEnabled` — the environment's own setting
493+
* (see the block above); omitted keeps the earlier mapping.
446494
*/
447495
export async function runVerifyDomain(
448496
handle: AuthRequestHandler,
449497
request: Request,
450-
): Promise<RegisterSsoFormResult & { body: RegisterSsoFormResult['body'] & { data?: any } }> {
498+
options: SsoDomainVerificationBridgeOptions = {},
499+
): Promise<DomainVerificationBridgeResult> {
451500
let body: any;
452501
try { body = await request.json(); } catch { body = {}; }
453502
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
@@ -458,6 +507,8 @@ export async function runVerifyDomain(
458507

459508
const rw = rewriteSsoAdminUrl(request, /\/admin\/sso\/verify-domain$/, '/sso/verify-domain');
460509
if (!rw) return { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Bad request URL' } } };
510+
// [#22463] Off: the inner route is not mounted, so there is nothing to ask.
511+
if (options.domainVerificationEnabled === false) return domainVerificationDisabled();
461512
const headers = forwardAuthHeaders(request, rw.origin);
462513

463514
// [#22398] The inner route reads the session (`sessionMiddleware`); only its
@@ -468,18 +519,15 @@ export async function runVerifyDomain(
468519
if (resp.ok) {
469520
return { status: 200, body: { success: true, data: { providerId, verified: true, message: 'Domain ownership verified — this provider can now sign users in.' } } };
470521
}
471-
// The feature is OFF for this env: `@better-auth/sso` never mounted the inner
472-
// endpoint, so its `404` describes the INNER route. THIS route is mounted
473-
// unconditionally, so passing that status through says "no such endpoint"
474-
// about a resource that exists. Answer the sibling's answer instead — same
475-
// condition, same code, same status (#10859). Before this, the branch
476-
// rewrote only the `message` and let the code fall through to the generic
477-
// default below, so the machine-readable field said "verification failed"
478-
// while the human-readable one said "the feature is off"; a caller can only
479-
// act on the first.
480-
if (resp.status === 404 && !parsed?.code) {
481-
return { status: 400, body: { success: false, error: { code: 'DOMAIN_VERIFICATION_DISABLED', message: 'Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION).' } } };
482-
}
522+
// A code-less `404`: with the feature on, the provider lookup missed; with no
523+
// flag, the earlier answer (see the block above). When the feature is OFF,
524+
// `@better-auth/sso` never mounted the inner endpoint, so its `404` describes
525+
// the INNER route, while THIS route is mounted unconditionally: passing that
526+
// status through would say "no such endpoint" about a resource that exists,
527+
// so the off case answers the sibling's answer — same condition, same code,
528+
// same status (#10859). A `404` that carries a code (`NO_PENDING_VERIFICATION`)
529+
// is the vendor's own diagnosis and passes through below.
530+
if (resp.status === 404 && !parsed?.code) return codelessVendorNotFound(providerId, options);
483531
// Friendlier copy for the expected failure modes.
484532
let message = parsed?.message || 'Domain verification failed';
485533
if (parsed?.code === 'NO_PENDING_VERIFICATION') {

‎packages/plugins/plugin-auth/src/sso-domain-verification-error-codes.test.ts‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { describe, it, expect } from 'vitest';
2727
// root entry re-exports the schemas only. Reading it through the published
2828
// exports map (i.e. `dist`) is deliberate: this asserts against the surface a
2929
// consumer actually gets, not against a source file this test could reach.
30-
import { ERROR_CODE_LEDGER } from '@objectstack/spec/api';
30+
import { ERROR_CODE_LEDGER, HttpStatusErrorCodeMap } from '@objectstack/spec/api';
3131
import { runRequestDomainVerification, runVerifyDomain, type AuthRequestHandler } from './register-sso-provider.js';
3232

3333
/** The default this package authors for both routes when the vendor gives no code. */
@@ -201,3 +201,106 @@ describe('#10859 the DISABLED condition gets ONE answer across both routes', ()
201201
expect(res.status).toBe(502);
202202
});
203203
});
204+
205+
describe('[#22463] the environment’s own setting tells the two code-less 404s apart', () => {
206+
/**
207+
* The vendor answers two different 404s, neither with a `code`: the feature
208+
* OFF (inner route unmounted, empty body) and, with it ON, an unknown
209+
* provider (`checkProviderAccess`, `{"message":"Provider not found"}`). The
210+
* bridges decide from the flag their caller hands in, never from the body:
211+
* the vendor's wording is not a contract. The real-stack pins, through the
212+
* mounted routes, are in `sso-domain-verification-unknown-provider.pin.test.ts`.
213+
*/
214+
const ON = { domainVerificationEnabled: true } as const;
215+
const OFF = { domainVerificationEnabled: false } as const;
216+
const NOT_FOUND = 'RESOURCE_NOT_FOUND';
217+
const DISABLED = 'DOMAIN_VERIFICATION_DISABLED';
218+
/** A handle that records whether the vendor was asked at all. */
219+
const counted = (status: number, body: unknown) => {
220+
const calls: Request[] = [];
221+
const inner = fakeHandle(status, body);
222+
const handle: AuthRequestHandler = async (req) => {
223+
calls.push(req);
224+
return inner(req);
225+
};
226+
return { handle, calls };
227+
};
228+
const BRIDGES = [
229+
{ label: 'request-domain-verification', run: runRequestDomainVerification, url: REQUEST_URL },
230+
{ label: 'verify-domain', run: runVerifyDomain, url: VERIFY_URL },
231+
] as const;
232+
233+
it('the not-found answer is the envelope’s own code for 404 — reused, not invented', () => {
234+
expect(HttpStatusErrorCodeMap[404]).toBe(NOT_FOUND);
235+
});
236+
237+
for (const bridge of BRIDGES) {
238+
it(`${bridge.label}: ON, the vendor’s code-less 404 → 404 RESOURCE_NOT_FOUND naming the provider`, async () => {
239+
const { handle, calls } = counted(404, { message: 'Provider not found' });
240+
const res = await bridge.run(handle, post(bridge.url), ON);
241+
242+
expect(calls).toHaveLength(1);
243+
expect(res.body.success).toBe(false);
244+
expect(res.body.error?.code).toBe(NOT_FOUND);
245+
expect(res.status).toBe(404);
246+
expect(res.body.error?.message).toContain('"p1"');
247+
expect(res.body.error?.message).not.toContain('OS_SSO_DOMAIN_VERIFICATION');
248+
});
249+
250+
it(`${bridge.label}: ON, the decision does not read the vendor’s 404 wording`, async () => {
251+
// An empty body and a reworded one get the SAME answer as the measured
252+
// body: a vendor upgrade that changes the copy changes nothing here.
253+
for (const body of [undefined, { message: 'Unknown provider (reworded upstream)' }]) {
254+
const res = await bridge.run(fakeHandle(404, body), post(bridge.url), ON);
255+
expect(res.body.error?.code, JSON.stringify(body)).toBe(NOT_FOUND);
256+
expect(res.status, JSON.stringify(body)).toBe(404);
257+
}
258+
});
259+
260+
it(`${bridge.label}: OFF → 400 DOMAIN_VERIFICATION_DISABLED, and the vendor is never asked`, async () => {
261+
const { handle, calls } = counted(404, undefined);
262+
const res = await bridge.run(handle, post(bridge.url), OFF);
263+
264+
expect(res.body.error?.code).toBe(DISABLED);
265+
expect(res.status).toBe(400);
266+
expect(res.body.error?.message).toContain('OS_SSO_DOMAIN_VERIFICATION');
267+
expect(calls).toHaveLength(0);
268+
});
269+
270+
it(`${bridge.label}: OFF still answers a missing providerId first, as before`, async () => {
271+
const { handle, calls } = counted(404, undefined);
272+
const req = new Request(bridge.url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
273+
const res = await bridge.run(handle, req, OFF);
274+
275+
expect(res.body.error?.code).toBe('INVALID_REQUEST');
276+
expect(res.status).toBe(400);
277+
expect(calls).toHaveLength(0);
278+
});
279+
280+
it(`${bridge.label}: no flag (a caller such as the cloud proxy) → the earlier mapping, for both 404 shapes`, async () => {
281+
for (const body of [undefined, { message: 'Provider not found' }]) {
282+
const { handle, calls } = counted(404, body);
283+
const res = await bridge.run(handle, post(bridge.url));
284+
expect(res.body.error?.code, JSON.stringify(body)).toBe(DISABLED);
285+
expect(res.status, JSON.stringify(body)).toBe(400);
286+
expect(calls, JSON.stringify(body)).toHaveLength(1);
287+
}
288+
});
289+
}
290+
291+
it('verify-domain: ON, a 404 that CARRIES a vendor code is still the vendor’s answer', async () => {
292+
const res = await runVerifyDomain(fakeHandle(404, { code: VENDOR_CODE, message: 'vendor copy' }), post(VERIFY_URL), ON);
293+
294+
expect(res.body.error?.code).toBe(VENDOR_CODE);
295+
expect(res.status).toBe(404);
296+
expect(res.body.error?.message).toContain('Request Domain Verification');
297+
});
298+
299+
it('request-domain-verification: ON, the vendor’s 201 still yields the DNS record', async () => {
300+
const res = await runRequestDomainVerification(fakeHandle(201, { domainVerificationToken: 'tok' }), post(REQUEST_URL), ON);
301+
302+
expect(res.status).toBe(200);
303+
expect(res.body.success).toBe(true);
304+
expect(res.body.data?.dnsRecordValue).toBe('_better-auth-token-p1=tok');
305+
});
306+
});

0 commit comments

Comments
 (0)