Repository navigation
fix(plugin-auth): a refused OAuth application registration answers the status the refusal declared, not 500 - #24853
Conversation
…e status the refusal declared, not 500
better-auth raises each refusal as better-call's APIError, whose HTTP
status is the number `statusCode` and the name `status` ('BAD_REQUEST').
The sys-oauth-application/register wrapper read only a numeric `status`,
so a named refusal (an invalid_redirect_uri redirect URI, a missing
session's UNAUTHORIZED) answered 500. It now reads `statusCode` first; a
throw that declares no status stays 500.
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
…s through unknown Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
…ough resolveThrownHttpError The repo's one rule for "what status does a thrown error declare" already reads both spellings (numeric `status`, then `statusCode`) and falls back to 500 for a throw that declares none. The door calls it instead of a second hand-written copy. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1529c2159c87fd14fc742341bd9993a3dab9336f && git checkout 1529c2159c87fd14fc742341bd9993a3dab9336f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41646a5788f5362a66527a287200f9832fb00602 622e30aa99033ab38fca59216d48b70b8e3d76a6 && git checkout -B drift-repro 41646a5788f5362a66527a287200f9832fb00602 && git merge --no-ff 622e30aa99033ab38fca59216d48b70b8e3d76a6
node scripts/docs-audit/affected-docs.mjs --json 41646a5788f5362a66527a287200f9832fb00602 |
席位核对ACCEPT · #24773 half 1 ·
Generated by Claude Code |
Part of #24773 — this PR does the first half only: a refused registration now answers 4xx instead of 500. The second half, whether a
webclient may register anhttp://localhost/http://127.0.0.1redirect URI, is an authentication-flow acceptance rule. It was measured here and nothing about it changed. It goes to a decision card. #24773 remains open.Clause-②: no
What changed
POST /api/v1/auth/sys-oauth-application/register(packages/plugins/plugin-auth/src/auth-plugin.ts) answered most refusals from better-auth as500 OAUTH_REGISTER_FAILED.@better-auth/oauth-provider@1.7.3/better-call@1.4.0. Every refusal better-auth raises is better-call'sAPIError. It carries its status twice: as the numberstatusCode(400) and as the namestatus('BAD_REQUEST'). The door read only a numericstatus, so every refusal with a named status fell through to500. The body-schemaValidationErrorhas the number400in itsstatus, so that refusal answered400from the same door. One door gave two answers for the same class of refusal.resolveThrownHttpError(@objectstack/types). That is the repo's one rule for which status a thrown error declares: a numericstatus, thenstatusCode, and the caller's fallback (500) when the throw declares neither. The code staysOAUTH_REGISTER_FAILED(already in the ledger; no new code).details.upstreamErrorstill carries the vendor's discriminator.web+http://localhost:4001/callbackorhttp://127.0.0.1:4001/callback:500→400(invalid_redirect_uri). No session:500→401. A storage fault while persisting the client: still500. Body-schema refusal (http://app.example.com/callback): still400.https://app.example.com/callback: still200.Tests
New
packages/plugins/plugin-auth/src/oauth-register-refusal-status.test.ts. It runs real better-auth with the OIDC provider on, behind the plugin's real route registration on a real Hono app, using thein-process-session-renewal.pin.test.tsharness. Each refusal asserts the ADR-0112 envelope (code+status) anddetails.upstreamErrorwhere the vendor sends one, and asserts that nosys_oauth_applicationrow was written.APIError(status: 'BAD_REQUEST',statusCode: 400). If the vendor ever switched to a numericstatus, the pins below would pass on the old door too, and this precondition says so.const status = typeof err?.status === 'number' ? err.status : 500;). The mutation was applied withscripts/ablation-replace.mjs, which confirmed it on disk (anchor 1 → 0, replacement 0 → 1, blob03e026dea→a6bb1a7f3). Result:Tests 3 failed | 14 passed (17). The failures wereexpected 500 to be 400(localhost),expected 500 to be 400(127.0.0.1) andexpected 500 to be 401(no session). Restored: blob equal to HEAD,git diff HEADempty.resolveThrownHttpError(err, 400)). Disk-verified the same way (blob03e026dea→16aa6cab0). Result:Tests 1 failed | 16 passed (17)withexpected 400 to be 500on the storage-fault case. Restored the same way.5dc8add4f, the commit that carries the fix. The merge ofmainthat followed left both files byte-identical (blobs03e026deaand8b2336493).Head
622e30aa9is the merge ofmainat5cb6e6e0a. The incoming commits touch none of this diff's files, andpackages/speccheck:generatedis all up to date after the rebuild. On that head:pnpm --filter @objectstack/plugin-auth exec vitest run:Test Files 169 passed (169),Tests 3241 passed | 10 skipped (3251).pnpm --filter @objectstack/plugin-auth typecheck: exit 0, withcheck:test-typecheck: OK.node scripts/pm/dispatch-gates.mjs --commands, 72 commands) ran with every exit code captured before any pipe: 72 of 72 exit 0. The--ranreconciliation printed72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN.Half 2 — measured, not changed (for the decision card)
validateClientRedirectUri,@better-auth/oauth-provider@1.7.3. Awebclient is refused unless the URI ishttps:and the host is not loopback (localhostor a loopback IP). The message "web clients require https redirect URIs on non-loopback hosts" states that conjunction. It does not exempt loopback hosts. Measured through this door, every loopback form is refused forweb:http://localhost,http://localhost:4001,http://127.0.0.1:4001,http://[::1]:4001, and alsohttps://localhostandhttps://127.0.0.1. Port and IP family make no difference. Anativeclient may usehttpon exactlylocalhost,127.0.0.1or[::1], with any port. Called directly withapplication_type: 'native', the vendor acceptedhttp://localhost:4001/callbackandhttp://127.0.0.1:4001/callback. The predicate takes no options, so no configuration plugin-auth passes (trusted origins, dev mode, dynamic client registration) governs it.typeselect (web / native / user-agent-based) is forwarded as the body keytype. The installed vendor's/oauth2/create-clientbody schema declares notype. It declaresapplication_type: 'web' | 'native'. So the key is stripped, and every registration is judged and stored asweb. Measured:type: 'native'+http://127.0.0.1:4001/callbackis refused with the web rule.type: 'native'+ anhttpsURI is stored withapplication_type: "web".packages/platform-objects,sys_oauth_applicationcreate action) does not match the measured rule forweb: https always, never a loopback host.The options, analysed on the four axes, are in the dev report on the card. This PR changes none of them.
Acceptance notes
message(in the test,storage fault (test)) and logs nothing on that branch. This is the existing behaviour and out of this card's scope.test:repo(vitestrepoproject) was not run locally; it is CI's (Test Core).Generated by Claude Code