/**
* The single "establish a session and return the user where they started"
* primitive.
*
* Before this file the framework had five return-path validators, four
* "don't redirect to yourself" checks, and two complete copies of the login
* document — so every anti-loop fix landed on whichever fork the ticket named
* and the reports never stopped. Every sign-in surface now calls exactly this:
* the client gate (`RequireSession`), the login document, the sign-in entry
* route, and every template button.
*
* The continuation is a PATH, never a URL, and it is carried opaquely in the
* `c` query param. That combination is why nesting is structurally impossible
* rather than guarded against:
*
* - the grammar is not recursive: decoding a continuation yields a string,
* never another continuation;
* - `signInJourney` returns `signInHref: null` — no token at all — when the
* browser is already at an auth entry path, so the one function permitted
* to mint continuations refuses to mint the dangerous value;
* - the sign-in URL is `/_agent-native/sign-in?c=`, so
* "capture the current location as the return" has no URL-shaped input to
* re-encode;
* - `c` does not look like a URL, so Better Auth's `callbackURL`, a proxy, or
* a future patch cannot mistake it for a redirect target and wrap it.
*
* There is exactly ONE auth-entry-path predicate left in the codebase and it
* lives in `normalizeAppPath` below. The four call-site copies are gone.
*
* Not signed. Signing buys nothing: the user already controls their own
* browser and can type any URL. The only threat is open redirect, which is a
* validation problem, not an integrity problem — and an HMAC would add a
* secret dependency to code that must run inside a CDN-cached public document.
*/
export const SIGN_IN_CONTINUATION_PARAM = "c";
/**
* Legacy entry-point grammar. Generated apps in the wild hand-write
* `/_agent-native/sign-in?return=` and are not upgradeable, so the
* CONSUMER side of this param is permanent API surface. New producers must
* emit `c` instead; deleting the consumer fallback silently redirects every
* generated app to `/`, which reads as a UX quirk rather than a regression and
* therefore will not be caught.
*/
export const SIGN_IN_LEGACY_RETURN_PARAM = "return";
export const SIGN_IN_ENTRY_PATH = "/_agent-native/sign-in";
/** Max length of an encoded continuation token, in characters. */
export const SIGN_IN_CONTINUATION_MAX_LENGTH = 512;
export interface SignInJourney {
/**
* Where to send the browser to authenticate — always same-origin, always
* under the app base path, always carrying exactly one continuation.
* `null` when the browser is ALREADY at an auth entry path: there is no
* such thing as signing in from the sign-in page, and a caller must not
* navigate. Distinguishable absence, never a lookalike default.
*/
readonly signInHref: string | null;
/**
* Where to send the browser once a session exists. Always same-origin,
* always under the app base path, never an auth entry path. Always a value —
* worst case the app home, which is a correct answer rather than a swallowed
* failure.
*/
readonly resumeHref: string;
}
export interface SignInJourneyInput {
/** Current location as a PATH: pathname + search + hash. Never a URL. */
at: string;
/** Whatever arrived in the `c` query param, if anything. */
continuation?: string | null;
/**
* Legacy `?return=` value, if anything. Accepted forever; see
* SIGN_IN_LEGACY_RETURN_PARAM.
*/
legacyReturn?: string | null;
/** App base path, `""` for root deploys. */
basePath?: string;
}
/**
* The whole runtime, as one self-contained function.
*
* Self-contained on purpose: `signInJourneyInlineScript()` emits this via
* `Function.prototype.toString()` so the login documents run the identical
* code instead of a hand-transcribed copy. A reference to anything outside
* this function body would be undefined inside the emitted script.
*/
function createSignInJourneyRuntime(basePath: string) {
var PARAM = "c";
var LEGACY_PARAM = "return";
var ENTRY_PATH = "/_agent-native/sign-in";
var MAX_TOKEN = 512;
var SENTINEL = "http://an.invalid";
function normalizeBasePath(raw: string): string {
if (!raw || raw === "/") return "";
var trimmed = String(raw).replace(/^\/+/, "").replace(/\/+$/, "");
return trimmed ? "/" + trimmed : "";
}
var base = normalizeBasePath(basePath);
function hasControlCharacter(value: string): boolean {
for (var i = 0; i < value.length; i++) {
var code = value.charCodeAt(i);
if (code < 0x20 || code === 0x7f) return true;
}
return false;
}
function isAuthEntryPath(pathname: string): boolean {
// Suffix match rather than equality so this holds under any base path,
// including one this process was not configured with (a workspace host
// serving a sibling app's URL).
if (
pathname === ENTRY_PATH ||
pathname.slice(-ENTRY_PATH.length) === ENTRY_PATH
) {
return true;
}
var rest = pathname;
if (base) {
if (rest === base) rest = "/";
else if (rest.slice(0, base.length + 1) === base + "/")
rest = rest.slice(base.length);
}
return rest === "/login" || rest === "/signup";
}
/**
* The shared validator. Returns a full same-origin app path, or `null` —
* never `"/"`, so an invalid continuation stays distinguishable from a
* genuine request for the home page. Callers that want a fallback write it
* at the call site.
*/
function normalizeAppPath(raw: string | null | undefined): string | null {
if (typeof raw !== "string" || !raw) return null;
// Header/URL injection: a `\r\n` reaching a `Location` splits the response.
if (hasControlCharacter(raw)) return null;
if (raw.charAt(0) !== "/") return null;
// Scheme-relative, plus the backslash form WHATWG normalizes to `//`.
if (raw.charAt(1) === "/" || raw.charAt(1) === "\\") return null;
var parsed;
try {
parsed = new URL(raw, SENTINEL);
} catch (e) {
return null;
}
// Catches everything WHATWG normalizes that prefix checks miss. This is
// why prefix checks alone are insufficient.
if (parsed.origin !== SENTINEL) return null;
var pathname = parsed.pathname || "/";
// WHATWG path normalization can turn traversal such as `/..//host` into
// `//host`; validate the normalized path before returning it to a redirect
// sink.
if (pathname.startsWith("//")) return null;
if (isAuthEntryPath(pathname)) return null;
// Containment in this app's own base path. On a multi-app workspace host
// `/otherapp/admin` is same-origin but is NOT this app, so this is the
// only control that makes an unsigned path-only token safe there. `base`
// is resolved from configured env / `appBasePath()` and NEVER from the
// continuation itself.
if (
base &&
pathname !== base &&
pathname.slice(0, base.length + 1) !== base + "/"
) {
return null;
}
return pathname + parsed.search + parsed.hash;
}
function encodeContinuation(path: string | null | undefined): string {
var normalized = normalizeAppPath(path);
if (!normalized) return "";
var token;
try {
// encodeURIComponent first so the input to btoa is pure ASCII; the pair
// round-trips any UTF-8 exactly.
token = btoa(encodeURIComponent(normalized))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
} catch (e) {
return "";
}
return token.length > MAX_TOKEN ? "" : token;
}
function decodeContinuation(token: string | null | undefined): string | null {
if (typeof token !== "string" || !token) return null;
if (token.length > MAX_TOKEN) return null;
var decoded;
try {
var b64 = token.replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4 !== 0) b64 += "=";
decoded = decodeURIComponent(atob(b64));
} catch (e) {
return null;
}
// Re-validated on decode: a user can paste any `c`, so encode-time
// validation is never trusted here.
return normalizeAppPath(decoded);
}
function homeHref(): string {
return base || "/";
}
function signInJourney(input: {
at: string;
continuation?: string | null;
legacyReturn?: string | null;
}) {
var here = normalizeAppPath(input.at);
var resume =
decodeContinuation(input.continuation) ??
normalizeAppPath(input.legacyReturn) ??
here ??
homeHref();
var signInHref = null as string | null;
if (here !== null) {
var token = encodeContinuation(here);
signInHref = base + ENTRY_PATH + (token ? "?" + PARAM + "=" + token : "");
}
return { signInHref: signInHref, resumeHref: resume };
}
/** Read a query param out of a raw `?a=b` / `a=b` search string. */
function readParam(search: string, name: string): string | null {
try {
return new URLSearchParams(search || "").get(name);
} catch (e) {
return null;
}
}
function journeyForLocation(location: {
pathname: string;
search: string;
hash: string;
}) {
return signInJourney({
at:
(location.pathname || "/") +
(location.search || "") +
(location.hash || ""),
continuation: readParam(location.search, PARAM),
legacyReturn: readParam(location.search, LEGACY_PARAM),
});
}
return {
PARAM: PARAM,
LEGACY_PARAM: LEGACY_PARAM,
basePath: base,
homeHref: homeHref,
normalizeAppPath: normalizeAppPath,
encodeContinuation: encodeContinuation,
decodeContinuation: decodeContinuation,
signInJourney: signInJourney,
journeyForLocation: journeyForLocation,
};
}
type SignInJourneyRuntime = ReturnType;
const runtimeCache = new Map();
function runtime(basePath: string | undefined): SignInJourneyRuntime {
const key = basePath ?? "";
let cached = runtimeCache.get(key);
if (!cached) {
cached = createSignInJourneyRuntime(key);
runtimeCache.set(key, cached);
}
return cached;
}
/**
* Validate a candidate app path. Returns the normalised full path (base path
* included) or `null`. Pass `basePath` to additionally require containment —
* omit it for surfaces such as provider OAuth returns whose targets are not
* guaranteed to be base-path prefixed.
*/
export function normalizeAppPath(
raw: string | null | undefined,
basePath = "",
): string | null {
return runtime(basePath).normalizeAppPath(raw);
}
/** Encode an app path as a continuation token. `""` for anything not returnable. */
export function encodeContinuation(
path: string | null | undefined,
basePath = "",
): string {
return runtime(basePath).encodeContinuation(path);
}
/** Decode a continuation token to an app path. `null` for anything invalid. */
export function decodeContinuation(
token: string | null | undefined,
basePath = "",
): string | null {
return runtime(basePath).decodeContinuation(token);
}
export function signInJourney(input: SignInJourneyInput): SignInJourney {
return runtime(input.basePath).signInJourney(input);
}
/**
* The runtime as `