src/lib/auth-redirect.ts
Rama main · réplica de lectura del repositorio original.
// Where a visitor should land once they've signed in, and how that destination
// survives the trip through their inbox.
//
// Three different lifetimes, of which this file stores one:
// - In-page sign-in - the six-digit code, and a passkey ceremony - never leaves
// the tab, so the destination is just passed down as a prop. No storage.
// - The email magic link leaves the app entirely and comes back on
// `/auth/verify`, a page that has no idea where the visitor was originally
// headed. `localStorage` carries it across that hop, under the single
// read-once key below.
// - Google leaves the tab too, and comes back on `/auth/google/callback`. Its
// destination is *not* stored here: it rides inside the per-attempt record
// `lib/google-oauth.ts` keeps, keyed by that attempt's `state`. The single
// key below would be the wrong home for it, because two tabs signing in at
// once would overwrite each other's - tab A would then sign in perfectly and
// land on tab B's page, with nothing anywhere reporting a problem. Only the
// sanitizing below is shared with that flow.
//
// `localStorage` (not `sessionStorage`) because the link is clicked from a mail
// client, and that practically never reuses the tab that asked for it: a desktop
// mail app hands the URL to the browser, and webmail opens it with `noopener`.
// Either way the destination lands in a browsing context with no opener, and a
// `sessionStorage` entry - which is copied only from an opener - isn't there.
// The value that used to be stored was therefore lost in the ordinary case, and
// every magic-link sign-in fell through to `/home`.
//
// What's stored is still only a path the visitor's own browser was already
// pointed at, and it's still read exactly once, but `localStorage` outlives the
// tab - so it carries its own expiry rather than relying on the tab closing.
// `lib/chart-period-view.ts` and the other remembered-view modules store their
// preferences the same way, for a related reason.
import type { AuthStatus } from "@/lib/api/auth";
const POST_AUTH_REDIRECT_KEY = "opendiving:post-auth-redirect";
// A backstop, not a mirror of the link's own life. The obvious value would be
// `MAGIC_LINK_TOKEN_EXPIRE_MINUTES`, but that's read from the environment on the
// backend (30 is only its default) and nothing here can see it - so matching it
// by hand would mean an operator who raises it to an hour silently reintroduces
// the bug this storage exists to fix, with every sign-in in the back half of the
// window landing on `/home` again.
//
// The asymmetry decides it: too short breaks the feature, too long costs
// essentially nothing. A destination can only be *read* by `/auth/verify`, which
// needs a live token, and a live token means a link request, which re-stamps
// this entry anyway. So the expiry is only there to stop an abandoned path
// living in storage forever, and a day is long enough to be safely past any
// plausible link lifetime.
const POST_AUTH_REDIRECT_TTL_MS = 24 * 60 * 60 * 1000;
interface StoredRedirect {
path: string;
expiresAt: number;
}
// Where every auth entry point sends a freshly signed-in user when there's no
// remembered destination.
export const DEFAULT_POST_AUTH_REDIRECT = "/home";
// The two destinations that are not a sign-in: a verified identity with no account
// yet, and an account inside its deletion grace period. Both are screens that ask for
// one more explicit decision before there is a session.
const ONBOARDING_PATH = "/onboarding";
const RESTORE_PATH = "/restore";
// Where an applied `AuthOutcome` sends the visitor, in the one place that decides it.
//
// Every entry point reaches this - the magic link, the six-digit code, Google and a
// passkey. A status branched on at each of them is a chance per caller to route
// somebody holding no onboarding token into the onboarding form, so the mapping lives
// here and the call sites pass a status.
//
// `next` is only honoured for a sign-in, and is sanitized here rather than at each
// call site: the magic link reads it out of `localStorage` under the key in this
// file, Google from its own per-attempt record, and the rest take it from a prop -
// and none should have to remember. The other statuses drop it - a brand-new account
// has nothing to return to, and neither has an account that isn't back yet. Both
// screens end at the default.
export function destinationForOutcome(
status: AuthStatus,
next?: string | null,
): string {
switch (status) {
case "onboarding_required":
return ONBOARDING_PATH;
case "deletion_pending":
return RESTORE_PATH;
// Exhaustive on purpose: a fourth status added to `AuthStatus` is a type error
// here, in the one file, rather than a silent mis-route in four.
case "authenticated":
return sanitizeRedirectPath(next) ?? DEFAULT_POST_AUTH_REDIRECT;
}
}
// A placeholder origin to parse against: `.invalid` is reserved and never resolves, so
// only a value that names some other host can come out with a different origin.
const PARSE_BASE = "https://x.invalid";
// Characters the WHATWG URL parser discards *before* it parses, which is what
// lets them hide a leading `//` from a naive prefix check: it strips ASCII tab,
// LF and CR from anywhere in the input, and trims leading/trailing C0 controls
// and spaces. So `"/\t/evil.example"` passes a `startsWith("//")` test and then
// resolves to `https://evil.example/` anyway - a working open redirect.
//
// No legitimate destination this app produces contains any of these; a real
// path percent-encodes them. So the whole C0-and-space range (plus DEL) is
// rejected rather than stripped - patching the value up would only move the
// guesswork somewhere else.
function isStrippedByUrlParser(value: string): boolean {
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
// C0 controls and space (0x00-0x20), plus DEL (0x7f). Written as code
// points rather than a regex character class so the range stays readable
// and no literal control bytes end up in this file.
if (code <= 0x20 || code === 0x7f) return true;
}
return false;
}
// Only same-origin, path-relative destinations are ever honoured. Anything else
// is rejected outright rather than patched up, so a hand-crafted `?next=` can't
// turn our own sign-in page into an open redirect.
//
// Judged by what the URL parser makes of the value, not by its spelling: `//host`
// and `/\host` name another origin outright, and dot segments - `/.//host`,
// `/a/..//host`, `%2e` spellings - collapse to a pathname of `//host`, which reads
// as another origin the moment anything resolves that path again. Next's
// hard-navigation fallback does exactly that. What comes back is the parser's own
// normalised path, so no caller ever holds the spelling that was checked.
export function sanitizeRedirectPath(
value: string | null | undefined,
): string | null {
if (!value || !value.startsWith("/")) return null;
if (isStrippedByUrlParser(value)) return null;
let url: URL;
try {
url = new URL(value, PARSE_BASE);
} catch {
return null;
}
if (url.origin !== PARSE_BASE || url.pathname.startsWith("//")) return null;
return `${url.pathname}${url.search}${url.hash}`;
}
// Builds the sign-in URL for a signed-out visitor who was trying to reach
// `next`. The `next` parameter is omitted when there's nothing worth coming
// back to (the landing page, or a destination that failed sanitizing), so the
// common case stays a clean `/signin`.
export function signInHref(next?: string | null): string {
const target = sanitizeRedirectPath(next);
if (!target || target === "/" || target.startsWith("/signin")) {
return "/signin";
}
return `/signin?next=${encodeURIComponent(target)}`;
}
// Stores the destination to return to after the magic-link round trip. Always
// call this when requesting a link - passing no path *clears* any previously
// remembered one, so a destination abandoned earlier (e.g. the visitor opened
// `/signin?next=/dives/abc`, then signed in from the landing page instead) can
// never hijack a later sign-in.
export function rememberPostAuthRedirect(path?: string | null): void {
const target = sanitizeRedirectPath(path);
try {
if (target) {
const stored: StoredRedirect = {
path: target,
expiresAt: Date.now() + POST_AUTH_REDIRECT_TTL_MS,
};
window.localStorage.setItem(
POST_AUTH_REDIRECT_KEY,
JSON.stringify(stored),
);
} else {
window.localStorage.removeItem(POST_AUTH_REDIRECT_KEY);
}
} catch {
// Storage can be unavailable (Safari private mode, storage disabled, quota).
// Losing the destination just means landing on the default - not worth
// failing the sign-in over.
}
}
// Reads and clears the remembered destination. Single-use by design: it's
// consumed by whichever page finishes the sign-in, so it can't leak into the
// next one. Removed before it's even inspected, so a stale, expired or
// unparseable entry clears itself out too rather than sitting there being
// rejected on every subsequent sign-in.
export function consumePostAuthRedirect(): string | null {
let raw: string | null;
try {
raw = window.localStorage.getItem(POST_AUTH_REDIRECT_KEY);
window.localStorage.removeItem(POST_AUTH_REDIRECT_KEY);
} catch {
return null;
}
if (!raw) return null;
try {
// Anything that isn't the shape written above is treated as absent: an entry
// left by an older build stored the bare path, and `JSON.parse` throws on it.
const stored: unknown = JSON.parse(raw);
if (typeof stored !== "object" || stored === null) return null;
const { path, expiresAt } = stored as Partial<StoredRedirect>;
if (typeof path !== "string" || typeof expiresAt !== "number") return null;
if (Date.now() > expiresAt) return null;
return sanitizeRedirectPath(path);
} catch {
return null;
}
}