[PR #9131] fix(auth): harden dynamic baseURL resolution #16705

Open
opened 2026-04-13 10:39:05 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/9131
Author: @gustavovalverde
Created: 4/12/2026
Status: 🔄 Open

Base: 2026-04-11/fix/dynamic-baseurl-api-callsHead: 2026-04-12/refactor/als-baseurl-resolution


📝 Commits (10+)

  • 6564d46 fix(auth)!: harden dynamic baseURL resolution
  • aa11da1 refactor(auth): drop ALS request-state; cover MCP metadata helpers
  • 80820c9 fix(auth): close three gaps between direct-API and HTTP-handler paths
  • 3f3c1ec refactor(auth): keep pre-existing proxy-header default on dynamic path
  • 23d900e fix(auth): tighten isRequestLike and protocol/host sourcing
  • 3cc4ed7 test(auth): add E2E coverage for dynamic baseURL on the wire
  • 20136a3 fix(test-utils): don't split host pattern on ? before wildcard substitution
  • 70c91f8 refactor(auth): dedupe trustedProxyHeaders default and metadata wrappers
  • ec9cc42 chore: drop transitive comments from PR #9131
  • 63bb522 fix(auth): address PR #9131 review feedback and CI failure

📊 Changes

18 files changed (+850 additions, -137 deletions)

View changed files

📝 .changeset/lovely-toes-swim.md (+17 -1)
📝 e2e/integration/package.json (+1 -0)
📝 e2e/integration/vanilla-node/e2e/app.ts (+109 -12)
e2e/integration/vanilla-node/e2e/dynamic-base-url.spec.ts (+135 -0)
📝 e2e/integration/vanilla-node/e2e/utils.ts (+31 -0)
📝 e2e/integration/vanilla-node/package.json (+1 -0)
📝 packages/better-auth/src/api/to-auth-endpoints.test.ts (+166 -0)
📝 packages/better-auth/src/api/to-auth-endpoints.ts (+37 -22)
📝 packages/better-auth/src/auth/base.ts (+16 -13)
📝 packages/better-auth/src/auth/full.test.ts (+7 -0)
📝 packages/better-auth/src/context/helpers.ts (+75 -6)
📝 packages/better-auth/src/plugins/mcp/index.ts (+24 -4)
📝 packages/better-auth/src/test-utils/test-instance.ts (+17 -0)
📝 packages/better-auth/src/utils/url.test.ts (+57 -21)
📝 packages/better-auth/src/utils/url.ts (+73 -26)
📝 packages/oauth-provider/src/metadata.test.ts (+53 -0)
📝 packages/oauth-provider/src/metadata.ts (+25 -32)
📝 pnpm-lock.yaml (+6 -0)

📄 Description

Stacks on top of #9113 to fix the remaining issues when baseURL is a dynamic { allowedHosts, ... } config. Non-breaking: preserves the pre-existing trustedProxyHeaders default so deployments that relied on implicit proxy trust keep working. The security-hardening flip of that default is split into #9134.

Closes #9105 (direct auth.api crash on new URL("")) and
Closes #8447 (empty issuer in OIDC metadata).

Direct auth.api.* calls

  • Throw a structured APIError when a dynamic baseURL can't be resolved (no source and no fallback), instead of silently leaving ctx.context.baseURL = "" for downstream plugins to crash on.
  • Convert allowedHosts mismatches on the direct-API path to APIError too.
  • resolveRequestContext rehydrates trustedOrigins, trustedProviders, and cookies per call.
  • Pass a synthesized Request to user-defined trustedOrigins(req) / trustedProviders(req) callbacks on the headers-only path so they always see req.headers.
  • Infer http for loopback hosts (localhost, 127.0.0.1, [::1], 0.0.0.0) on the headers-only protocol fallback, so local-dev calls don't resolve to https://localhost:3000.
  • hasRequest uses isRequestLike, so cross-realm Request inputs (edge runtimes, polyfills) return a Response as intended. isRequestLike itself now rejects objects that spoof Symbol.toStringTag without the real shape.

Metadata / plugin helpers

  • oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oAuthDiscoveryMetadata, and oAuthProtectedResourceMetadata forward the incoming request to their chained auth.api calls, so issuer and discovery URLs reflect the request host on dynamic configs.
  • withMcpAuth forwards the incoming request to getMcpSession and threads trustedProxyHeaders. When baseURL can't be resolved, the WWW-Authenticate header is now a bare Bearer challenge instead of Bearer resource_metadata="undefined/...".
  • metadataResponse in oauth-provider normalizes headers via new Headers() so callers can pass Headers, tuple arrays, or records without silently dropping entries.

Small refactors

  • resolveDynamicTrustedProxyHeaders(options) centralizes the proxy-header default in one place.
  • pickSource lives next to resolveRequestContext in context/helpers.ts and reuses an existing Headers instance instead of cloning.

Tests

Unit tests cover every item above. Three Playwright E2E specs in e2e/integration/vanilla-node exercise the full HTTP stack:

  • oauthProviderAuthServerMetadata emits the request-host issuer on a dynamic config (regression for #8447; verified to fail when the wrapper is reverted).
  • Server-side auth.api.getSession({ headers }) resolves cleanly on a dynamic config (regression for #9105).
  • Direct auth.api.* with no source and no fallback returns a structured 500.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/better-auth/better-auth/pull/9131 **Author:** [@gustavovalverde](https://github.com/gustavovalverde) **Created:** 4/12/2026 **Status:** 🔄 Open **Base:** `2026-04-11/fix/dynamic-baseurl-api-calls` ← **Head:** `2026-04-12/refactor/als-baseurl-resolution` --- ### 📝 Commits (10+) - [`6564d46`](https://github.com/better-auth/better-auth/commit/6564d465897323ee5f96a6984d6e36ae272cab5f) fix(auth)!: harden dynamic baseURL resolution - [`aa11da1`](https://github.com/better-auth/better-auth/commit/aa11da10a8d6aa11c9d55c02ba1bd407b31c3c51) refactor(auth): drop ALS request-state; cover MCP metadata helpers - [`80820c9`](https://github.com/better-auth/better-auth/commit/80820c9d0713a794d3a91eb99215748350951b95) fix(auth): close three gaps between direct-API and HTTP-handler paths - [`3f3c1ec`](https://github.com/better-auth/better-auth/commit/3f3c1ec3c865c46619a9667b184e6a1e7694bc89) refactor(auth): keep pre-existing proxy-header default on dynamic path - [`23d900e`](https://github.com/better-auth/better-auth/commit/23d900edc1a44acd81ec914f6a6acd72f35c626c) fix(auth): tighten isRequestLike and protocol/host sourcing - [`3cc4ed7`](https://github.com/better-auth/better-auth/commit/3cc4ed72c603c87c8b10b1c8fd464dd888a5c590) test(auth): add E2E coverage for dynamic baseURL on the wire - [`20136a3`](https://github.com/better-auth/better-auth/commit/20136a3204c16548e6770bf5806eef4f363b9f67) fix(test-utils): don't split host pattern on ? before wildcard substitution - [`70c91f8`](https://github.com/better-auth/better-auth/commit/70c91f8b538a20ada373b4143e0d26902dbcdcb0) refactor(auth): dedupe trustedProxyHeaders default and metadata wrappers - [`ec9cc42`](https://github.com/better-auth/better-auth/commit/ec9cc42a0cae3e6e962b83f3ac7b7a7126fe7df7) chore: drop transitive comments from PR #9131 - [`63bb522`](https://github.com/better-auth/better-auth/commit/63bb522d5bad91974c4b9bda764095aba7597d21) fix(auth): address PR #9131 review feedback and CI failure ### 📊 Changes **18 files changed** (+850 additions, -137 deletions) <details> <summary>View changed files</summary> 📝 `.changeset/lovely-toes-swim.md` (+17 -1) 📝 `e2e/integration/package.json` (+1 -0) 📝 `e2e/integration/vanilla-node/e2e/app.ts` (+109 -12) ➕ `e2e/integration/vanilla-node/e2e/dynamic-base-url.spec.ts` (+135 -0) 📝 `e2e/integration/vanilla-node/e2e/utils.ts` (+31 -0) 📝 `e2e/integration/vanilla-node/package.json` (+1 -0) 📝 `packages/better-auth/src/api/to-auth-endpoints.test.ts` (+166 -0) 📝 `packages/better-auth/src/api/to-auth-endpoints.ts` (+37 -22) 📝 `packages/better-auth/src/auth/base.ts` (+16 -13) 📝 `packages/better-auth/src/auth/full.test.ts` (+7 -0) 📝 `packages/better-auth/src/context/helpers.ts` (+75 -6) 📝 `packages/better-auth/src/plugins/mcp/index.ts` (+24 -4) 📝 `packages/better-auth/src/test-utils/test-instance.ts` (+17 -0) 📝 `packages/better-auth/src/utils/url.test.ts` (+57 -21) 📝 `packages/better-auth/src/utils/url.ts` (+73 -26) 📝 `packages/oauth-provider/src/metadata.test.ts` (+53 -0) 📝 `packages/oauth-provider/src/metadata.ts` (+25 -32) 📝 `pnpm-lock.yaml` (+6 -0) </details> ### 📄 Description Stacks on top of #9113 to fix the remaining issues when `baseURL` is a dynamic `{ allowedHosts, ... }` config. Non-breaking: preserves the pre-existing `trustedProxyHeaders` default so deployments that relied on implicit proxy trust keep working. The security-hardening flip of that default is split into #9134. Closes #9105 (direct `auth.api` crash on `new URL("")`) and Closes #8447 (empty `issuer` in OIDC metadata). ## Direct `auth.api.*` calls - Throw a structured `APIError` when a dynamic `baseURL` can't be resolved (no source and no `fallback`), instead of silently leaving `ctx.context.baseURL = ""` for downstream plugins to crash on. - Convert `allowedHosts` mismatches on the direct-API path to `APIError` too. - `resolveRequestContext` rehydrates `trustedOrigins`, `trustedProviders`, and cookies per call. - Pass a synthesized `Request` to user-defined `trustedOrigins(req)` / `trustedProviders(req)` callbacks on the headers-only path so they always see `req.headers`. - Infer `http` for loopback hosts (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) on the headers-only protocol fallback, so local-dev calls don't resolve to `https://localhost:3000`. - `hasRequest` uses `isRequestLike`, so cross-realm `Request` inputs (edge runtimes, polyfills) return a `Response` as intended. `isRequestLike` itself now rejects objects that spoof `Symbol.toStringTag` without the real shape. ## Metadata / plugin helpers - `oauthProviderAuthServerMetadata`, `oauthProviderOpenIdConfigMetadata`, `oAuthDiscoveryMetadata`, and `oAuthProtectedResourceMetadata` forward the incoming request to their chained `auth.api` calls, so `issuer` and discovery URLs reflect the request host on dynamic configs. - `withMcpAuth` forwards the incoming request to `getMcpSession` and threads `trustedProxyHeaders`. When `baseURL` can't be resolved, the `WWW-Authenticate` header is now a bare `Bearer` challenge instead of `Bearer resource_metadata="undefined/..."`. - `metadataResponse` in `oauth-provider` normalizes headers via `new Headers()` so callers can pass `Headers`, tuple arrays, or records without silently dropping entries. ## Small refactors - `resolveDynamicTrustedProxyHeaders(options)` centralizes the proxy-header default in one place. - `pickSource` lives next to `resolveRequestContext` in `context/helpers.ts` and reuses an existing `Headers` instance instead of cloning. ## Tests Unit tests cover every item above. Three Playwright E2E specs in `e2e/integration/vanilla-node` exercise the full HTTP stack: - `oauthProviderAuthServerMetadata` emits the request-host `issuer` on a dynamic config (regression for #8447; verified to fail when the wrapper is reverted). - Server-side `auth.api.getSession({ headers })` resolves cleanly on a dynamic config (regression for #9105). - Direct `auth.api.*` with no source and no `fallback` returns a structured 500. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-04-13 10:39:05 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#16705