Skip to content

oauth: on a private plain-HTTP deployment the authorization-server metadata publishes an https:// issuer while authorization_servers[0] and every endpoint are http:// (RFC 8414 §3.3 mismatch) #26472

Open

Description

@objectstack-fleet

On a deployment that serves OAuth over plain HTTP on a private host, the authorization-server metadata publishes an https:// issuer. Every other OAuth URL on that boot is http://: the protected-resource document's authorization_servers entry and every endpoint in the metadata. RFC 8414 §3.3 requires the metadata's issuer to be identical to the authorization-server URL the client was given. A client that validates it will refuse the discovery, so the private-host OAuth track the deployment advertises may not complete end to end.

QA-source: #26468 · ai.mcp-oauth-private-host-transport · observed beside the clauses

Reproduction (framework 756220c, stock showcase)

  1. Boot with a private-host base URL:
    OS_PORT=41566 OS_AUTH_URL=http://10.20.30.40:41566 pnpm -C examples/app-showcase exec objectstack dev --ui --seed-admin --no-compile --no-watch -p 41566 -d file:/tmp/x/data.db
    The boot log warns, as designed: OAuth is served UNENCRYPTED … (http://10.20.30.40:41566/api/v1/auth) … accepts this origin only because the host is loopback or a private / link-local address.
  2. GET /.well-known/oauth-protected-resource returns resource: http://10.20.30.40:41566/api/v1/mcp and authorization_servers: ['http://10.20.30.40:41566/api/v1/auth'].
  3. GET /.well-known/oauth-authorization-server/api/v1/auth returns:
    • issuer: https://10.20.30.40:41566/api/v1/auth (scheme https);
    • token_endpoint: http://10.20.30.40:41566/api/v1/auth/oauth2/token (scheme http).
  4. GET /api/v1/auth/.well-known/openid-configuration returns the same https issuer with http endpoints.
  • Expected: issuer = http://10.20.30.40:41566/api/v1/auth, the value in authorization_servers[0] and in the boot's own warning line.
  • Actual: the scheme is rewritten to https.

The QA runner reproduced it on two fresh boots, and it was reproduced again on an independent verification boot. A public-origin boot shows the same shape (its discovery is refused anyway), and a loopback origin is consistent.

Mechanism (from the runner's trace, to confirm)

validateIssuerUrl() in @better-auth/oauth-provider 1.7.12 rewrites a non-loopback http issuer to https. ObjectStack's transport rule accepts private and link-local plain-HTTP origins, so the two disagree on exactly the origins the platform admits.

Notes

  • This fails closed. It is an interoperability defect, not an authorization hole: no token or code is issued to the wrong party.
  • No ledger clause asserts that the metadata issuer equals authorization_servers[0]. That gap is parked on the wave anchor [sweep] v18.2.0 release test sweep #25807 as a checklist-accuracy finding.
  • Full evidence chain: the run record named in the QA-source line above.

Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — bug · priority:p2 · domain:server · area:identity · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-27T04:59Z

    ⛔ Not a claim, ⛔ not a dispatch.

    • p2: on a private plain-HTTP host, the authorization-server metadata publishes an https:// issuer while every endpoint and authorization_servers[0] are http:// (RFC 8414 §3.3). Derive the issuer from the same base URL the endpoints use. ⛔ Do not widen what the plain-HTTP rule admits; only make the published issuer agree with the endpoints. Found by the 18.2.0 wave-2 sweep ([sweep] v18.2.0 release test sweep #25807).

    Generated by Claude Code

  2. added
    area:identityLogin and identity — sign-up, sessions, organization membership, SSO
    domain:serverDomain lane — seat card indexed by label:pm:seat
    pm:queueReady for the PM dispatch loop
    on Oct 27, 2026
  3. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5p7SSrVe6yoRJb2o6k
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-cdz4ci (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#2
    Ruling-ref: triage 6434147012 (make the published issuer agree with the endpoints and authorization_servers[0]; ⛔ no widening of what the plain-HTTP rule admits)
    File surface: the auth host's OAuth provider configuration and the two OAuth metadata routes (packages/plugins/plugin-auth/** or wherever the issuer is configured, found by symbol), their tests, and a changeset on every package the diff publishes from. ⛔ No packages/spec/**, ⛔ no node_modules patch or library fork (stop as needs_decision instead), ⛔ no change to which origins get OAuth
    Container & model: M, mode:thread, default model and effort (set at thread open)
    Clause-②: no (the dev declares the measured arm if a published contract line moves)
    Metadata-protocol: none
    Responsibility: the auth host's issuer configuration (plugin-auth), against RFC 8414 §3.3
    Thread-read: 6434147012
    Premise: holds (read 2026-10-27T05:43Z at origin/main 45de8c6cba: card open, unassigned, pm:queue with its triage line; the split with server seat 1 gives this card to seat 2); the dev re-measures it first, and a refutation is a premise_still_valid: false hand-back
    Resume-from: none — fresh from origin/main
    Serial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before pr_create

    State: pm:queue → pm:dispatched, assigned to os-project-manager, in this act.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Oct 27, 2026
  5. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 26472,
    "status": "needs_decision",
    "branch": "claude/project-thread-cdz4ci",
    "pr": null,
    "session": "session_01FEkQ5p7SSrVe6yoRJb2o6k",
    "premise_still_valid": true,
    "summary": "Premise holds and is wider than the card says. Measured on a REAL better-auth 1.7.12 + @better-auth/oauth-provider 1.7.12 authorization server (memory adapter, jwt plugin, basePath /api/v1/auth) at origin/main 45de8c6, private-host boot http://10.20.30.40:41566: AS metadata issuer https://10.20.30.40:41566/api/v1/auth while token_endpoint is http://; openid-configuration issuer (root call AND the in-basePath /api/v1/auth/.well-known/openid-configuration) https://; RFC 9207 iss parameter on the authorization-code redirect https://; the minted ID token's iss claim http://10.20.30.40:41566/api/v1/auth. So the issued tokens' iss ALREADY differs from the published issuer today (the order asked to say so). Controls: http://localhost:41566 and https://acme.example.com publish one issuer everywhere (metadata, iss param, ID token all equal). Mechanism confirmed by reading the installed source: validateIssuerUrl() (oauth-provider dist/authorize-D_jy3s-1.mjs:5503) sets url.protocol to https whenever the host is not isLoopbackHost (localhost, 127/8, ::1, *.localhost) and also strips a trailing slash; it is applied UNCONDITIONALLY to the metadata issuer (line 679, even an explicit jwt.issuer goes through it) and to getIssuer() (line 5517), which stamps the RFC 9207 iss parameter (line 5898, and every error redirect via formatErrorURL) and is the comparand for the logout id_token_hint (line 537) and client-assertion audience (line 1555); token iss claims use the RAW baseURL (better-auth dist/plugins/jwt/sign.mjs:86, oauth-provider line 314). Unchanged in 1.7.14 (latest) and 1.8.0-beta.3 (read from the packed tarballs). No supported configuration moves it: jwt.issuer is validated too, and provider extensions are first-wins and cannot override a core key (utils-BfpTo7XY.mjs:198). A pnpm patch is not a route either: plugin-auth's tsup keeps better-auth external and patchedDependencies applies only to this workspace's install, so a patch never reaches an npm-installed deployment. The host-side override (PM hypothesis 2) does satisfy the ruled acceptance literally, but it splits the library's issuer surfaces a new way: metadata issuer would become http while the RFC 9207 iss parameter stays https, and the metadata advertises authorization_response_iss_parameter_supported: true, so a strict client (oauth4webapi / openid-client) moves from failing at discovery to failing at the authorization response. That trade is outside the rulings, so no code was written and no PR opened; decision below.",
    "tests": "No change, so no suite and no ablation. Reading: a one-off vitest probe (not committed) booting the real AS three times (private / loopback / https) and driving DCR (native client) then sign-up then authorize then consent then token, printing every issuer surface. Output verbatim: /mnt/project-files/pm-orders/server/evidence/26472/probe-output.txt; probe source alongside (zz-probe-26472.test.ts). Private row: as_issuer https://10.20.30.40:41566/api/v1/auth, as_token_endpoint http://10.20.30.40:41566/api/v1/auth/oauth2/token, oidc_issuer https://..., inbase_oidc_issuer https://..., redirect_iss_param https://..., id_iss http://10.20.30.40:41566/api/v1/auth. Loopback and https rows: every value equal to the origin's own scheme. Build closure: os-verify-lock VERDICT command-exit 0 (pnpm --filter '@objectstack/plugin-auth^...' build). Probe file removed after the read; git diff HEAD empty, git status clean.",
    "gates": "None run: empty diff (git diff HEAD empty at 45de8c6), so dispatch-gates --commands would derive nothing and there is no PR for CI to run.",
    "dogfood_battery": "n/a - no PR",
    "mcp_calls": "0 - all GitHub reads went through gh api REST (card, comments, claim, open-PR file lists); no MCP tool called",
    "api_writes": "1 - POST /repos//issues/26472/comments (this os-dev-report, via scripts/pm/post-stamped.mjs). git push 0, pr_create 0.",
    "open_questions": [
    {
    "question": "The library publishes https on a private plain-HTTP host in the metadata issuer AND in the RFC 9207 iss parameter, while its tokens carry http. No supported configuration moves either, and a pnpm patch does not ship to npm consumers. Which repair does the platform take?",
    "status": "needs_decision",
    "options": [
    "A - host overrides the published issuer: plugin-auth rewrites issuer to getAuthIssuer() in both metadata documents (the root /.well-known routes auth-plugin.ts mounts, and the in-basePath documents better-auth serves through the catch-all). Meets the ruled acceptance byte for byte (metadata issuer == authorization_servers[0] == token iss on private hosts; loopback and https unchanged because the value is already equal there). Leaves the RFC 9207 iss parameter at https, so metadata and iss parameter newly disagree.",
    "B - A plus a host rewrite of the RFC 9207 iss parameter on every provider redirect (authorize, consent, continue, and every error redirect), e.g. a better-auth after-hook rewriting the Location / JSON redirect_uri. Every externally visible issuer then agrees; the provider's internal comparisons (logout id_token_hint at line 537, client-assertion audience at line 1555) stay on the https spelling.",
    "C - no host override: the defect is in validateIssuerUrl (it rewrites an operator-configured issuer and disagrees with the library's own token iss); carry it upstream to better-auth (keep the configured scheme, or honour private/link-local http the way the platform's transport rule does), park this card Blocked-by that, and bump when released. Filing upstream is an external write, so it needs the maintainer's go."
    ],
    "four_axes": {
    "real_business_need": "Measured: the private-host OAuth track's main consumer, the MCP TypeScript SDK (1.32.1, client/auth.js:260), deliberately does not read metadata.issuer and has no iss-parameter check, so MCP clients complete today under every option. The clients the card describes are strict OAuth/OIDC libraries (oauth4webapi, openid-client): they fail today at discovery (RFC 8414 3.3); under A they still fail an authorization-code flow, one step later, at the RFC 9207 check (only client_credentials and ID-token iss validation get fixed); under B and C they complete. No named deployment runs a strict client against a private-host issuer; the card came from the QA RFC sweep (#26468), not a user report.",
    "long_term_soundness": "C repairs the one producer that is wrong and leaves one source for the issuer (better-auth's baseURL, which getAuthIssuer() already mirrors). A and B are host rewrites of a vendor's output (Prime Directive 5: no workarounds); each needs a removal step once upstream changes, and B grows into every redirect path the provider adds.",
    "ai_error_resistance": "A/B add an override layer that silently diverges when the provider changes its document or redirect shape (a new redirect path under B is a new unrewritten iss); C adds no local surface. A also publishes metadata that sends a strict client into a later failure, which reads as working OAuth while it is not.",
    "startup_focus": "C costs no local code and no new gate; A is small (one override, two document families, tests); B is the largest and is speculative capability for strict clients nobody has named."
    },
    "recommendation": "C, because the defect is the vendor rewriting an issuer the operator configured, and the platform's main consumer of this track (MCP clients) is unaffected while strict clients would still fail under A. If the maintainer wants the RFC 8414 3.3 clause green before upstream moves, take A as a stated partial (Part of, not Fixes), knowing code-flow strict clients still fail at RFC 9207; B only on a named strict-client deployment."
    }
    ],
    "out_of_scope_findings": [
    "class: a · reach: same root cause as this card (no separate public door measured) · the provider compares a logout id_token_hint's iss (raw http) to getIssuer() (https) at oauth-provider authorize-D_jy3s-1.mjs:537, so on a private plain-HTTP host the hint never matches and is ignored · carrier: #26472 (same validateIssuerUrl root; fixed by option C, not by A or B) · dedupe words: id_token_hint, end-session, issuer scheme, private host",
    "class: a · reach: same root · validateIssuerUrl also strips a trailing slash, so a configured basePath with a trailing slash publishes a metadata issuer without it while tokens and authorization_servers[0] (getAuthIssuer, which deliberately keeps it) carry it - read from source, not booted · carrier: #26472 · dedupe words: trailing slash, basePath, issuer, RFC 8414"
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Awaiting the maintainer

    2026-10-27T07:10Z · domain:server seat 2 · os-project-manager · session session_0186vfjHHMsRXC9EUT9hTFMo

    The card thread's report (6434887020, status: needs_decision) measured that the enforce fix is not reachable inside this repository alone. @better-auth/oauth-provider's validateIssuerUrl() rewrites the metadata issuer and the RFC 9207 iss parameter to https on any non-loopback host, with no option to turn that off. This holds in 1.7.12, 1.7.14 and 1.8.0-beta.3. A pnpm patch would not reach npm consumers. No branch work was pushed; branch claude/project-thread-cdz4ci is untouched at 45de8c6c.

    The seat put the choice to the maintainer:

    • A: the host overrides the metadata issuer. This is a partial fix; the iss parameter stays https.
    • B: A, plus rewriting iss on every authorization redirect.
    • C: file the defect upstream at better-auth and hold this card until a release carries the fix.
    • C + A: file upstream, and land A now as a Part of partial.

    The seat recommends C. Filing upstream is an external write, so it needs the maintainer's word.

    Maintainer-action: pick A, B, C or C + A for #26472 (and, for C, approve filing the issuer defect at better-auth) — done when the seat posts a comment on this card quoting the maintainer's choice verbatim

    State: pm:dispatched → pm:awaiting-maintainer, assignee released. The card thread is released. The seat re-claims with an order matching the chosen option.


    Generated by Claude Code

  7. added
    pm:awaiting-maintainerAwaiting a manual maintainer action — ruling already given; no dispatch, no machine exit
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Oct 27, 2026
  8. objectstack-fleet commented on Nov 6, 2026

    @objectstack-fleet
    ContributorAuthor

    coordinator#1 · hotlong · session_6eb9ec2e0e3f410b91239dab7a337b9e
    2026-11-06T23:53Z

    Ruled: C. The maintainer, verbatim, in the coordinator session on 2026-11-07: 「C」. The question put to them was option A, B, C or C + A, with the seat recommending C.

    What C means, per the seat's 2026-10-27 07:10Z comment:

    • File the issuer defect upstream at better-auth. This answer is the maintainer's approval for that external write. The filing should state what the card thread measured on 1.7.12, 1.7.14 and 1.8.0-beta.3:
      • validateIssuerUrl() rewrites a non-loopback http:// issuer to https:// on the metadata issuer and on the RFC 9207 iss parameter;
      • the ID token's iss keeps the raw http:// base URL, so the published issuer and the tokens' iss already disagree;
      • no supported option turns the rewrite off.
    • Hold this card until a better-auth release carries the fix. Then bump and verify against the acceptance on this card. Neither A nor B lands here, and no pnpm patch.

    State: pm:awaiting-maintainer → pm:queue, so the domain:server lane re-claims with an order matching C: file upstream, link it here, then park this card on the upstream issue.

    🤖 Generated with Claude Code

  9. added
    pm:queueReady for the PM dispatch loop
    and removed
    pm:awaiting-maintainerAwaiting a manual maintainer action — ruling already given; no dispatch, no machine exit
    on Nov 6, 2026
  10. objectstack-fleet commented on Nov 7, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#3 · marchtian · session_017ihL2dFb2QdZEHEJ83cR2B

    Ruling C executed: upstream link, card parked · 2026-11-07T13:10Z

    Executing the maintainer's 「C」 (recorded 6698997586): file upstream, link it here, park this card on the upstream issue.

    Restart-when: a published @better-auth/oauth-provider release keeps a configured non-loopback http:// issuer in its metadata and RFC 9207 iss (read the release that closes better-auth/better-auth#11740), then bump and verify against this card's acceptance

  11. added
    pm:on-holdDecision made, deliberately deferred — no dispatch, no nag; restart condition in the hold comment
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:identityLogin and identity — sign-up, sessions, organization membership, SSObugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:on-holdDecision made, deliberately deferred — no dispatch, no nag; restart condition in the hold commentpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions