Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ const secure = z
.default(!(dev || config.ALLOW_INSECURE_COOKIES === "true"))
.parse(config.COOKIE_SECURE === "" ? undefined : config.COOKIE_SECURE === "true");

function sanitizeReturnPath(path: string | undefined | null): string | undefined {
if (!path) {
return undefined;
}
if (path.startsWith("//")) {
return undefined;
}
if (!path.startsWith("/")) {
return undefined;
}
return path;
}

export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
cookies.set(config.COOKIE_NAME, sessionId, {
path: "/",
Expand Down Expand Up @@ -197,10 +210,20 @@ export function tokenSetToSessionOauth(tokenSet: TokenSet): Session["oauth"] {
/**
* Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough.
*/
export async function generateCsrfToken(sessionId: string, redirectUrl: string): Promise<string> {
export async function generateCsrfToken(
sessionId: string,
redirectUrl: string,
next?: string
): Promise<string> {
const sanitizedNext = sanitizeReturnPath(next);
const data = {
expiration: addHours(new Date(), 1).getTime(),
redirectUrl,
...(sanitizedNext ? { next: sanitizedNext } : {}),
} as {
expiration: number;
redirectUrl: string;
next?: string;
};

return Buffer.from(
Expand Down Expand Up @@ -249,10 +272,14 @@ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {

export async function getOIDCAuthorizationUrl(
settings: OIDCSettings,
params: { sessionId: string }
params: { sessionId: string; next?: string }
): Promise<string> {
const client = await getOIDCClient(settings);
const csrfToken = await generateCsrfToken(params.sessionId, settings.redirectURI);
const csrfToken = await generateCsrfToken(
params.sessionId,
settings.redirectURI,
sanitizeReturnPath(params.next)
);

return client.authorizationUrl({
scope: OIDConfig.SCOPES,
Expand Down Expand Up @@ -291,13 +318,16 @@ export async function validateAndParseCsrfToken(
): Promise<{
/** This is the redirect url that was passed to the OIDC provider */
redirectUrl: string;
/** Relative path (within this app) to return to after login */
next?: string;
} | null> {
try {
const { data, signature } = z
.object({
data: z.object({
expiration: z.number().int(),
redirectUrl: z.string().url(),
next: z.string().optional(),
}),
signature: z.string().length(64),
})
Expand All @@ -306,7 +336,7 @@ export async function validateAndParseCsrfToken(
const reconstructSign = await sha256(JSON.stringify(data) + "##" + sessionId);

if (data.expiration > Date.now() && signature === reconstructSign) {
return { redirectUrl: data.redirectUrl };
return { redirectUrl: data.redirectUrl, next: sanitizeReturnPath(data.next) };
}
} catch (e) {
logger.error(e);
Expand Down Expand Up @@ -493,9 +523,23 @@ export async function triggerOauthFlow({
}
}

// Preserve a safe in-app return path after login.
// Priority: explicit ?next=... (must be an absolute path), else the current path (when auto-login kicks in).
let next: string | undefined = undefined;
const nextParam = sanitizeReturnPath(url.searchParams.get("next"));
if (nextParam) {
// Only accept absolute in-app paths to prevent open redirects
next = nextParam;
} else if (!url.pathname.startsWith(`${base}/login`)) {
// For automatic login on protected pages, return to the page the user was on
next = sanitizeReturnPath(`${url.pathname}${url.search}`) ?? `${base}/`;
} else {
next = sanitizeReturnPath(`${base}/`) ?? "/";
}

const authorizationUrl = await getOIDCAuthorizationUrl(
{ redirectURI },
{ sessionId: locals.sessionId }
{ sessionId: locals.sessionId, next }
);

throw redirect(302, authorizationUrl);
Expand Down
6 changes: 6 additions & 0 deletions src/routes/login/callback/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,11 @@ export async function GET({ url, locals, cookies, request, getClientAddress }) {
ip: getClientAddress(),
});

// Prefer returning the user to their original in-app path when provided.
// `validatedToken.next` is sanitized server-side to avoid protocol-relative redirects.
const next = validatedToken.next;
if (next) {
return redirect(302, next);
}
return redirect(302, `${base}/`);
}
Loading