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

Closed
opened 2026-04-15 22:51:11 -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: Merged
Merged: 4/14/2026
Merged by: @gustavovalverde

Base: mainHead: 2026-04-12/refactor/als-baseurl-resolution


📝 Commits (10+)

  • d5d8de1 fix(auth)!: harden dynamic baseURL resolution
  • 4505f45 refactor(auth): drop ALS request-state; cover MCP metadata helpers
  • ec187a3 fix(auth): close three gaps between direct-API and HTTP-handler paths
  • 4f37d82 refactor(auth): keep pre-existing proxy-header default on dynamic path
  • 4fcc1b7 fix(auth): tighten isRequestLike and protocol/host sourcing
  • 591f50a test(auth): add E2E coverage for dynamic baseURL on the wire
  • a391213 fix(test-utils): don't split host pattern on ? before wildcard substitution
  • 320d30f refactor(auth): dedupe trustedProxyHeaders default and metadata wrappers
  • f8dbd36 chore: drop transitive comments from PR #9131
  • abbdf81 fix(auth): address PR #9131 review feedback and CI failure

📊 Changes

18 files changed (+854 additions, -136 deletions)

View changed files

.changeset/dynamic-baseurl-hardening.md (+21 -0)
📝 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

Closes the remaining gaps when baseURL is a dynamic { allowedHosts, ... } config, now that #9113 has landed. 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 #8447 (empty issuer in OIDC metadata on dynamic configs).

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.
  • Honor advanced.trustedProxyHeaders on the dynamic path. Previously x-forwarded-host / -proto were unconditionally trusted with allowedHosts; they now go through the same gate as the static path (default true, unchanged).
  • resolveRequestContext rehydrates trustedProviders and cookies per call, not just trustedOrigins.
  • 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 (regression for #8447).
  • 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.
  • 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:** ✅ Merged **Merged:** 4/14/2026 **Merged by:** [@gustavovalverde](https://github.com/gustavovalverde) **Base:** `main` ← **Head:** `2026-04-12/refactor/als-baseurl-resolution` --- ### 📝 Commits (10+) - [`d5d8de1`](https://github.com/better-auth/better-auth/commit/d5d8de1f2e3173605a91de1ff8439d67a46984ec) fix(auth)!: harden dynamic baseURL resolution - [`4505f45`](https://github.com/better-auth/better-auth/commit/4505f45b2ee118aac6cbc909a98c2eedf302dc9d) refactor(auth): drop ALS request-state; cover MCP metadata helpers - [`ec187a3`](https://github.com/better-auth/better-auth/commit/ec187a3b89429af06e382ddea4926705df0f3043) fix(auth): close three gaps between direct-API and HTTP-handler paths - [`4f37d82`](https://github.com/better-auth/better-auth/commit/4f37d827eb6442a7674e51989481e4e371c8542d) refactor(auth): keep pre-existing proxy-header default on dynamic path - [`4fcc1b7`](https://github.com/better-auth/better-auth/commit/4fcc1b72788b8ed746a525c05873ddc1a3ee77ff) fix(auth): tighten isRequestLike and protocol/host sourcing - [`591f50a`](https://github.com/better-auth/better-auth/commit/591f50a1c5eeaa153fdbb864d97be008df8f35fd) test(auth): add E2E coverage for dynamic baseURL on the wire - [`a391213`](https://github.com/better-auth/better-auth/commit/a3912132730490c7972b56e2de6af4a912673a55) fix(test-utils): don't split host pattern on ? before wildcard substitution - [`320d30f`](https://github.com/better-auth/better-auth/commit/320d30f2fef6bf526292864bb2ec299846a71a2b) refactor(auth): dedupe trustedProxyHeaders default and metadata wrappers - [`f8dbd36`](https://github.com/better-auth/better-auth/commit/f8dbd366a3650b891854c986a1b0c801277594f7) chore: drop transitive comments from PR #9131 - [`abbdf81`](https://github.com/better-auth/better-auth/commit/abbdf815963f9237efb93b001eb011a75146f8d0) fix(auth): address PR #9131 review feedback and CI failure ### 📊 Changes **18 files changed** (+854 additions, -136 deletions) <details> <summary>View changed files</summary> ➕ `.changeset/dynamic-baseurl-hardening.md` (+21 -0) 📝 `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 Closes the remaining gaps when `baseURL` is a dynamic `{ allowedHosts, ... }` config, now that #9113 has landed. 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 #8447 (empty `issuer` in OIDC metadata on dynamic configs). ## 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. - Honor `advanced.trustedProxyHeaders` on the dynamic path. Previously `x-forwarded-host` / `-proto` were unconditionally trusted with `allowedHosts`; they now go through the same gate as the static path (default `true`, unchanged). - `resolveRequestContext` rehydrates `trustedProviders` and cookies per call, not just `trustedOrigins`. - 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 (regression for #8447). - `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. - 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-15 22:51:11 -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#25359