Skip to content

Commit 5a8b420

Browse files
committed
Recover OAuth login when callback lands in a different browser
When the OIDC callback lands in a browser that didn't start the login flow (e.g. "Open in Safari" from an in-app browser), both the CSRF state validation and the PKCE code verifier cookie check fail, and the user dead-ends on a 403. Restart the login flow once in the new browser instead, guarded by a short-lived one-shot cookie to prevent redirect loops, and best-effort preserve the original return path (re-sanitized) via the login route's ?next= param. Re-applies gary149/chat-ui#18 which had targeted the wrong repository. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QWamsbC2xoGRoQazysMEVp
1 parent ebdcd75 commit 5a8b420

2 files changed

Lines changed: 61 additions & 8 deletions

File tree

src/lib/server/auth.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const sameSite = z
6767
.default(!secure || dev || config.ALLOW_INSECURE_COOKIES === "true" ? "lax" : "none")
6868
.parse(config.COOKIE_SAMESITE === "" ? undefined : config.COOKIE_SAMESITE);
6969

70-
function sanitizeReturnPath(path: string | undefined | null): string | undefined {
70+
export function sanitizeReturnPath(path: string | undefined | null): string | undefined {
7171
if (!path) {
7272
return undefined;
7373
}
@@ -80,6 +80,32 @@ function sanitizeReturnPath(path: string | undefined | null): string | undefined
8080
return path;
8181
}
8282

83+
/**
84+
* One-shot guard used when restarting the OAuth flow after a callback that was started
85+
* in another browser (e.g. "Open in Safari" from an in-app browser). Prevents redirect loops.
86+
*/
87+
const loginRetryCookieName = "hfChat-loginRetry";
88+
89+
export function hasLoginRetryCookie(cookies: Cookies): boolean {
90+
return cookies.get(loginRetryCookieName) === "1";
91+
}
92+
93+
export function setLoginRetryCookie(cookies: Cookies) {
94+
cookies.set(loginRetryCookieName, "1", {
95+
path: "/",
96+
// `strict` would keep this cookie from being sent on the cross-site IdP -> callback
97+
// navigation, which is exactly where the loop guard must be observable
98+
sameSite: sameSite === "strict" ? "lax" : sameSite,
99+
secure,
100+
httpOnly: true,
101+
maxAge: 5 * 60,
102+
});
103+
}
104+
105+
export function clearLoginRetryCookie(cookies: Cookies) {
106+
cookies.delete(loginRetryCookieName, { path: "/" });
107+
}
108+
83109
export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
84110
cookies.set(config.COOKIE_NAME, sessionId, {
85111
path: "/",

src/routes/login/callback/+server.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { error, redirect } from "@sveltejs/kit";
2-
import { getOIDCUserData, validateAndParseCsrfToken } from "$lib/server/auth";
2+
import {
3+
clearLoginRetryCookie,
4+
getOIDCUserData,
5+
hasLoginRetryCookie,
6+
sanitizeReturnPath,
7+
setLoginRetryCookie,
8+
validateAndParseCsrfToken,
9+
} from "$lib/server/auth";
310
import { z } from "zod";
411
import { base } from "$app/paths";
512
import { config } from "$lib/server/config";
@@ -47,14 +54,32 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) {
4754
const csrfToken = Buffer.from(state, "base64").toString("utf-8");
4855

4956
const validatedToken = await validateAndParseCsrfToken(csrfToken, locals.sessionId);
57+
const codeVerifier = cookies.get("hfChat-codeVerifier");
5058

51-
if (!validatedToken) {
52-
throw error(403, "Invalid or expired CSRF token");
53-
}
59+
if (!validatedToken || !codeVerifier) {
60+
// The `state` token and PKCE code verifier are bound to the browser that started the
61+
// login flow, so either check can fail on its own; when the callback lands in a
62+
// different browser (e.g. "Open in Safari" from an in-app browser) both do. Restart
63+
// the flow once in this browser instead of dead-ending on a 403.
64+
if (!hasLoginRetryCookie(cookies)) {
65+
setLoginRetryCookie(cookies);
66+
67+
// Best-effort recovery of the return path from the (unverified) state payload;
68+
// it is re-sanitized to an in-app absolute path before use.
69+
let next: string | undefined;
70+
try {
71+
next = sanitizeReturnPath(JSON.parse(csrfToken)?.data?.next);
72+
} catch {
73+
next = undefined;
74+
}
75+
76+
return redirect(302, `${base}/login${next ? `?next=${encodeURIComponent(next)}` : ""}`);
77+
}
5478

55-
const codeVerifier = cookies.get("hfChat-codeVerifier");
56-
if (!codeVerifier) {
57-
throw error(403, "Code verifier cookie not found");
79+
throw error(
80+
403,
81+
validatedToken ? "Code verifier cookie not found" : "Invalid or expired CSRF token"
82+
);
5883
}
5984

6085
const { userData, token } = await getOIDCUserData(
@@ -93,6 +118,8 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) {
93118
ip: getClientAddress(),
94119
});
95120

121+
clearLoginRetryCookie(cookies);
122+
96123
// Prefer returning the user to their original in-app path when provided.
97124
// `validatedToken.next` is sanitized server-side to avoid protocol-relative redirects.
98125
const next = validatedToken.next;

0 commit comments

Comments
 (0)