[PR #5521] PR: Fix — OAuth per-flow errorCallbackURL on state-mismatch (#5467) #6057

Open
opened 2026-03-13 12:46:01 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/5521
Author: @Adityakk9031
Created: 10/23/2025
Status: 🔄 Open

Base: canaryHead: Fix#5467


📝 Commits (7)

📊 Changes

4 files changed (+80 additions, -13 deletions)

View changed files

📝 .vscode/settings.json (+1 -1)
📝 packages/better-auth/src/init.ts (+2 -0)
📝 packages/better-auth/src/oauth2/state.ts (+43 -12)
📝 packages/core/src/types/init-options.ts (+34 -0)

📄 Description

Summary

This PR addresses issue #5467 — OAuth Error callback URL discrepancies. When an OAuth state cookie check failed (state cookie missing or mismatch), the code always fell back to the global onAPIError.errorURL (or the default backend error page) and ignored the per-flow errorCallbackURL that is stored in the OAuth state. This prevented client/native flows (e.g., Expo deep links) from being redirected to their per-flow error handlers.

This change implements an opt-in, safe mechanism to allow using the parsed per-flow errorCallbackURL on state-mismatch scenarios only when the origin of that per-flow URL is explicitly trusted via a new trustedErrorRedirectOrigins option.


Files changed (high-level)

  1. packages/core/src/init-options.ts (types)

    • Add trustedErrorRedirectOrigins?: string[] to BetterAuthOptions so that the option is typed and documented.
  2. packages/better-auth/src/init.ts (runtime initialization)

    • Merge a default value into runtime options: trustedErrorRedirectOrigins: options.trustedErrorRedirectOrigins ?? [] so the option is always defined at runtime.
  3. packages/better-auth/src/oauth2/state.ts (state parsing / error redirect)

    • Update parseState to prefer the per-flow errorURL on a state-cookie mismatch only if that URL's origin is present in ctx.options.trustedErrorRedirectOrigins (opt-in). Otherwise it falls back to the existing global error URL.

Motivation & security rationale

  • Problem: per-flow errorCallbackURL is stored inside the OAuth state verification record. When the state cookie mismatch occurs, the code previously did not use the parsed per-flow errorCallbackURL because the cookie mismatch made the state appear untrusted.

  • Risk: blindly trusting the state payload when cookies are missing opens the door to open-redirect and CSRF-like misuse.

  • Solution: make per-flow redirects available only when the developer explicitly opts-in via a whitelist of trusted origins (trustedErrorRedirectOrigins). By default this list is empty, preserving the existing secure behavior.

This keeps the security posture conservative (opt-in) while giving native clients (Expo / deep links) a way to receive per-flow redirects for state-mismatch errors.


Implementation details (concise)

init-options.ts

  • Added to BetterAuthOptions:
/**
 * Optional whitelist of origins that are allowed as per-flow error redirect targets.
 * Example: ["https://app.example.com", "myapp://host"]
 * If omitted or empty, parsed per-flow errorCallbackURL will NOT be used on state-mismatch.
 */
trustedErrorRedirectOrigins?: string[];

init.ts

  • Ensure runtime options always has the field defined (defaults to []):
options = {
  ...options,
  // existing defaults ...
  trustedErrorRedirectOrigins: options.trustedErrorRedirectOrigins ?? [],
};

oauth2/state.ts (parseState change)

  • Current behavior (before): on state-cookie mismatch the code used c.context.options.onAPIError?.errorURL || default.

  • New behavior: compute redirectTo as follows:

    1. defaultErrorURL = c.context.options.onAPIError?.errorURL || ${c.context.baseURL}/error``
    2. If parsedData.errorURL exists and ctx.options.trustedErrorRedirectOrigins is non-empty, try to parse parsedData.errorURL (using new URL(parsedData.errorURL, c.context.baseURL) to support relative URLs).
    3. If parsing succeeds and url.origin is present in ctx.options.trustedErrorRedirectOrigins, use parsedData.errorURL as the redirect target.
    4. Otherwise fall back to defaultErrorURL.

This logic ensures developers must explicitly list client/native deep-link origins in order to enable per-flow redirects in state-mismatch scenarios.


Example configuration (docs / README snippet)

import { betterAuth } from "better-auth";

export const auth = betterAuth({
  // ...other options
  onAPIError: { errorURL: "https://example.com/error" },
  // Opt-in whitelist for per-flow error redirect targets
  trustedErrorRedirectOrigins: [
    "https://app.example.com",
    "myapp://host",
  ],
});
  • For Expo/native error deep links, add the app deep link origin (e.g., myapp://host).
  • Warning: Only list origins you control. This is opt-in and must be populated by the developer to allow per-flow error redirects on state-mismatch.

Tests added / manual validation

  1. Unit tests (suggested):

    • parseState when:

      • a) parsedData.errorURL is present and trustedErrorRedirectOrigins includes its origin ⇒ redirected to parsedData.errorURL on state-cookie mismatch
      • b) parsedData.errorURL present but trustedErrorRedirectOrigins is empty ⇒ redirected to global error URL * c) parsedData.errorURL present but origin not in whitelist ⇒ global error URL * d) parsedData.errorURL is relative (e.g., /err) and baseURL origin is in whitelist ⇒ redirect to resolved URL
    • Negative tests for malformed URLs in parsedData.errorURL (should fall back to global error URL)

  2. Manual reproduction (QA)

    • Reproduce original issue steps (from #5467):

      • Start OAuth flow with an errorCallbackURL set to myapp://host/err and state stored in DB.
      • Simulate missing/mismatched state cookie on callback. * With trustedErrorRedirectOrigins: [] (default) — expect redirect to global backend error page. * With trustedErrorRedirectOrigins: ["myapp://host"] — expect redirect to myapp://host/err?error=state_mismatch.

Docs

  • Add trustedErrorRedirectOrigins to BetterAuth options docs, explaining purpose, examples, and security warning.
  • Add migration note: this option is opt-in (default []) — existing behavior unchanged.

Security & backward compatibility

  • Default behavior unchanged (secure): if trustedErrorRedirectOrigins is empty or not set, per-flow errorCallbackURL will not be used for state-mismatch redirections.
  • Developers must explicitly opt-in by providing a list of origins they control.

Release notes

  • Feature: allow per-flow OAuth errorCallbackURL to be used on state-cookie mismatch if the target origin is whitelisted via trustedErrorRedirectOrigins.
  • Security: default is secure (empty whitelist).

Checklist

  • Type definitions updated (init-options.ts)
  • Runtime default merged (init.ts)
  • parseState modified to check whitelist before honoring per-flow errorCallbackURL (state.ts)
  • Unit tests added/updated
  • Docs entry added
  • Changelog entry added

Reviewer notes

  • This change introduces a new opt-in surface (trustedErrorRedirectOrigins) — please carefully review the whitelist check in parseState.
  • Suggested reviewers: security-focused maintainers and OAuth plugin owners.


🔄 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/5521 **Author:** [@Adityakk9031](https://github.com/Adityakk9031) **Created:** 10/23/2025 **Status:** 🔄 Open **Base:** `canary` ← **Head:** `Fix#5467` --- ### 📝 Commits (7) - [`65ca091`](https://github.com/better-auth/better-auth/commit/65ca0916bcb90879630e1c86dbc46570b14ca91a) PR: Fix — OAuth per-flow errorCallbackURL on state-mismatch (#5467) - [`0257d0d`](https://github.com/better-auth/better-auth/commit/0257d0dd86409209381273f63ab79fb9713ba85a) Merge branch 'canary' into Fix#5467 - [`b0711ca`](https://github.com/better-auth/better-auth/commit/b0711caad15ea8c8b1743f64d8fca896a961f016) Update state.ts - [`df6b7ae`](https://github.com/better-auth/better-auth/commit/df6b7aeae9cec670ec3a809a4bb9364cfa7fa184) Merge branch 'Fix#5467' of https://github.com/Adityakk9031/better-auth into Fix#5467 - [`2e77128`](https://github.com/better-auth/better-auth/commit/2e7712892f033da1f0997d2eb4dc1025f611c0cd) Update settings.json - [`489669e`](https://github.com/better-auth/better-auth/commit/489669e6997ad30bcf5dfba42202b5040845b046) Update init.ts - [`44c2794`](https://github.com/better-auth/better-auth/commit/44c279450cc8847aa5bcb7018d40aa5b5c7b6178) Merge branch 'canary' into Fix#5467 ### 📊 Changes **4 files changed** (+80 additions, -13 deletions) <details> <summary>View changed files</summary> 📝 `.vscode/settings.json` (+1 -1) 📝 `packages/better-auth/src/init.ts` (+2 -0) 📝 `packages/better-auth/src/oauth2/state.ts` (+43 -12) 📝 `packages/core/src/types/init-options.ts` (+34 -0) </details> ### 📄 Description ## Summary This PR addresses issue **#5467 — OAuth Error callback URL discrepancies**. When an OAuth state cookie check failed (state cookie missing or mismatch), the code always fell back to the global `onAPIError.errorURL` (or the default backend error page) and ignored the per-flow `errorCallbackURL` that is stored in the OAuth state. This prevented client/native flows (e.g., Expo deep links) from being redirected to their per-flow error handlers. This change implements an **opt-in, safe mechanism** to allow using the parsed per-flow `errorCallbackURL` on state-mismatch scenarios **only when the origin of that per-flow URL is explicitly trusted** via a new `trustedErrorRedirectOrigins` option. --- ## Files changed (high-level) 1. **`packages/core/src/init-options.ts`** (types) * Add `trustedErrorRedirectOrigins?: string[]` to `BetterAuthOptions` so that the option is typed and documented. 2. **`packages/better-auth/src/init.ts`** (runtime initialization) * Merge a default value into runtime options: `trustedErrorRedirectOrigins: options.trustedErrorRedirectOrigins ?? []` so the option is always defined at runtime. 3. **`packages/better-auth/src/oauth2/state.ts`** (state parsing / error redirect) * Update `parseState` to prefer the per-flow `errorURL` on a state-cookie mismatch *only if* that URL's origin is present in `ctx.options.trustedErrorRedirectOrigins` (opt-in). Otherwise it falls back to the existing global error URL. --- ## Motivation & security rationale * **Problem:** per-flow `errorCallbackURL` is stored inside the OAuth `state` verification record. When the state cookie mismatch occurs, the code previously did not use the parsed per-flow `errorCallbackURL` because the cookie mismatch made the state appear untrusted. * **Risk:** blindly trusting the `state` payload when cookies are missing opens the door to open-redirect and CSRF-like misuse. * **Solution:** make per-flow redirects available only when the developer explicitly opts-in via a whitelist of trusted origins (`trustedErrorRedirectOrigins`). By default this list is empty, preserving the existing secure behavior. This keeps the security posture conservative (opt-in) while giving native clients (Expo / deep links) a way to receive per-flow redirects for state-mismatch errors. --- ## Implementation details (concise) ### `init-options.ts` * Added to `BetterAuthOptions`: ```ts /** * Optional whitelist of origins that are allowed as per-flow error redirect targets. * Example: ["https://app.example.com", "myapp://host"] * If omitted or empty, parsed per-flow errorCallbackURL will NOT be used on state-mismatch. */ trustedErrorRedirectOrigins?: string[]; ``` ### `init.ts` * Ensure runtime `options` always has the field defined (defaults to `[]`): ```ts options = { ...options, // existing defaults ... trustedErrorRedirectOrigins: options.trustedErrorRedirectOrigins ?? [], }; ``` ### `oauth2/state.ts` (`parseState` change) * Current behavior (before): on state-cookie mismatch the code used `c.context.options.onAPIError?.errorURL || default`. * New behavior: compute `redirectTo` as follows: 1. `defaultErrorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`` 2. If `parsedData.errorURL` exists **and** `ctx.options.trustedErrorRedirectOrigins` is non-empty, try to parse `parsedData.errorURL` (using `new URL(parsedData.errorURL, c.context.baseURL)` to support relative URLs). 3. If parsing succeeds and `url.origin` is present in `ctx.options.trustedErrorRedirectOrigins`, use `parsedData.errorURL` as the redirect target. 4. Otherwise fall back to `defaultErrorURL`. This logic ensures developers must explicitly list client/native deep-link origins in order to enable per-flow redirects in state-mismatch scenarios. --- ## Example configuration (docs / README snippet) ```ts import { betterAuth } from "better-auth"; export const auth = betterAuth({ // ...other options onAPIError: { errorURL: "https://example.com/error" }, // Opt-in whitelist for per-flow error redirect targets trustedErrorRedirectOrigins: [ "https://app.example.com", "myapp://host", ], }); ``` * For Expo/native error deep links, add the app deep link origin (e.g., `myapp://host`). * **Warning**: Only list origins you control. This is opt-in and must be populated by the developer to allow per-flow error redirects on state-mismatch. --- ## Tests added / manual validation 1. **Unit tests** (suggested): * `parseState` when: * a) `parsedData.errorURL` is present and `trustedErrorRedirectOrigins` includes its origin ⇒ redirected to `parsedData.errorURL` on state-cookie mismatch * b) `parsedData.errorURL` present but `trustedErrorRedirectOrigins` is empty ⇒ redirected to global error URL * c) `parsedData.errorURL` present but origin not in whitelist ⇒ global error URL * d) `parsedData.errorURL` is relative (e.g., `/err`) and baseURL origin is in whitelist ⇒ redirect to resolved URL * Negative tests for malformed URLs in `parsedData.errorURL` (should fall back to global error URL) 2. **Manual reproduction (QA)** * Reproduce original issue steps (from #5467): * Start OAuth flow with an `errorCallbackURL` set to `myapp://host/err` and state stored in DB. * Simulate missing/mismatched state cookie on callback. * With `trustedErrorRedirectOrigins: []` (default) — expect redirect to global backend error page. * With `trustedErrorRedirectOrigins: ["myapp://host"]` — expect redirect to `myapp://host/err?error=state_mismatch`. --- ## Docs * Add `trustedErrorRedirectOrigins` to BetterAuth options docs, explaining purpose, examples, and security warning. * Add migration note: this option is **opt-in** (default `[]`) — existing behavior unchanged. --- ## Security & backward compatibility * Default behavior unchanged (secure): if `trustedErrorRedirectOrigins` is empty or not set, **per-flow** `errorCallbackURL` will **not** be used for state-mismatch redirections. * Developers must explicitly opt-in by providing a list of origins they control. --- ## Release notes * Feature: allow per-flow OAuth `errorCallbackURL` to be used on state-cookie mismatch **if** the target origin is whitelisted via `trustedErrorRedirectOrigins`. * Security: default is secure (empty whitelist). --- ## Checklist * [ ] Type definitions updated (`init-options.ts`) * [ ] Runtime default merged (`init.ts`) * [ ] `parseState` modified to check whitelist before honoring per-flow `errorCallbackURL` (`state.ts`) * [ ] Unit tests added/updated * [ ] Docs entry added * [ ] Changelog entry added --- ## Reviewer notes * This change introduces a new opt-in surface (`trustedErrorRedirectOrigins`) — please carefully review the whitelist check in `parseState`. * Suggested reviewers: security-focused maintainers and OAuth plugin owners. --- --- <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-03-13 12:46:01 -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#6057