You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit e148ca9
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
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
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).'}}};
// [#22398] The inner route reads the session (`sessionMiddleware`); only its
@@ -468,18 +519,15 @@ export async function runVerifyDomain(
468
519
if(resp.ok){
469
520
return{status: 200,body: {success: true,data: { providerId,verified: true,message: 'Domain ownership verified — this provider can now sign users in.'}}};
470
521
}
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.
0 commit comments