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
27 changes: 27 additions & 0 deletions apps/web/src/functions/auth-last-used.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createServerFn, createServerOnlyFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";

import { getRequestAppOrigin } from "@/functions/app-origin";
import {
parseAuthSignInMethod,
type AuthSignInMethod,
} from "@/lib/auth-last-sign-in-method";

const LAST_SIGN_IN_METHOD_COOKIE = "anarlog-last-sign-in-method";
const LAST_SIGN_IN_METHOD_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;

export const fetchLastSignInMethod = createServerFn({ method: "GET" }).handler(
() => parseAuthSignInMethod(getCookie(LAST_SIGN_IN_METHOD_COOKIE)),
);

export const rememberLastSignInMethod = createServerOnlyFn(
(method: AuthSignInMethod) => {
setCookie(LAST_SIGN_IN_METHOD_COOKIE, method, {
httpOnly: true,
maxAge: LAST_SIGN_IN_METHOD_MAX_AGE_SECONDS,
path: "/",
sameSite: "lax",
secure: getRequestAppOrigin().startsWith("https://"),
});
},
);
90 changes: 71 additions & 19 deletions apps/web/src/functions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";

import { isAdminEmail } from "@/functions/admin";
import { getRequestAppOrigin } from "@/functions/app-origin";
import { rememberLastSignInMethod } from "@/functions/auth-last-used";
import { mintDesktopSessionForAuthenticatedUser } from "@/functions/auth-session";
import { desktopSchemeSchema } from "@/functions/desktop-flow";
import { ensureNewAccountTrial } from "@/functions/new-account-trial";
Expand All @@ -25,6 +26,12 @@ import {
getSupabaseDesktopFlowClient,
getSupabaseServerClient,
} from "@/functions/supabase";
import {
authSignInMethods,
resolveSignInMethod,
shouldRememberOtpSignIn,
type AuthSignInMethod,
} from "@/lib/auth-last-sign-in-method";
import { sanitizeInternalReturnPath } from "@/lib/auth-redirect";
import { captureOperationalError } from "@/lib/error-reporting";
import {
Expand All @@ -44,6 +51,22 @@ type FlowTokenResult =
| { ok: true; access_token: string; refresh_token: string }
| { ok: false; error: string };

const authSignInMethodSchema = z.enum(authSignInMethods);

function rememberSessionSignInMethod(
session: Session,
attemptedMethod?: AuthSignInMethod,
) {
const method = resolveSignInMethod({
attemptedMethod,
provider: session.user.app_metadata.provider,
usesSso: sessionUsesSso(session),
});
if (method) {
rememberLastSignInMethod(method);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

async function rejectIfEmailRequiresSso(
supabase: SupabaseClient,
email: string,
Expand Down Expand Up @@ -115,16 +138,20 @@ async function prepareNewAccountTrial(
return { needsTrialCheckout: false, session: data.session };
}

function buildAuthCallbackParams(data: {
flow: Flow;
scheme?: string;
redirect?: string;
}) {
function buildAuthCallbackParams(
data: {
flow: Flow;
scheme?: string;
redirect?: string;
},
method?: AuthSignInMethod,
) {
const params = new URLSearchParams({ flow: data.flow });
if (data.scheme) params.set("scheme", data.scheme);
if (data.redirect) {
params.set("redirect", sanitizeInternalReturnPath(data.redirect));
}
if (method) params.set("method", method);
return params;
}

Expand Down Expand Up @@ -276,7 +303,7 @@ export const doAuth = createServerFn({ method: "POST" })
)
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient();
const params = buildAuthCallbackParams(data);
const params = buildAuthCallbackParams(data, data.provider);

const { data: authData, error } = await supabase.auth.signInWithOAuth({
provider: data.provider,
Expand Down Expand Up @@ -311,7 +338,7 @@ export const doSsoAuth = createServerFn({ method: "POST" })
}

const supabase = getSupabaseServerClient();
const params = buildAuthCallbackParams(data);
const params = buildAuthCallbackParams(data, "sso");

const { data: authData, error } = await supabase.auth.signInWithSSO({
domain,
Expand Down Expand Up @@ -339,7 +366,7 @@ export const doMagicLinkAuth = createServerFn({ method: "POST" })
if (blocked) {
return blocked;
}
const params = buildAuthCallbackParams(data);
const params = buildAuthCallbackParams(data, "email");

const { error } = await supabase.auth.signInWithOtp({
email: data.email,
Expand Down Expand Up @@ -402,6 +429,17 @@ export const exchangeOAuthCode = createServerFn({ method: "POST" })
z.object({
code: z.string(),
flow: z.enum(["desktop", "web"]).default("web"),
type: z
.enum([
"email",
"recovery",
"magiclink",
"signup",
"invite",
"email_change",
])
.optional(),
method: authSignInMethodSchema.optional(),
}),
)
.handler(async ({ data }) => {
Expand Down Expand Up @@ -432,9 +470,13 @@ export const exchangeOAuthCode = createServerFn({ method: "POST" })
session: trial.session,
});
const response = toSuccessTokenResponse(tokens, authData.session.user.id);
return response.success
? { ...response, newAccount: trial.needsTrialCheckout }
: response;
if (!response.success) {
return response;
}
if (!data.type || shouldRememberOtpSignIn(data.type)) {
rememberSessionSignInMethod(authData.session, data.method);
}
return { ...response, newAccount: trial.needsTrialCheckout };
});

export const doPasswordSignUp = createServerFn({ method: "POST" })
Expand All @@ -451,7 +493,7 @@ export const doPasswordSignUp = createServerFn({ method: "POST" })
if (blocked) {
return blocked;
}
const params = buildAuthCallbackParams(data);
const params = buildAuthCallbackParams(data, "email");

const { data: authData, error } = await supabase.auth.signUp({
email: data.email,
Expand Down Expand Up @@ -485,9 +527,11 @@ export const doPasswordSignUp = createServerFn({ method: "POST" })
tokens,
authData.session.user.id,
);
return response.success
? { ...response, newAccount: trial.needsTrialCheckout }
: response;
if (!response.success) {
return response;
}
rememberSessionSignInMethod(authData.session, "email");
return { ...response, newAccount: trial.needsTrialCheckout };
}

return {
Expand Down Expand Up @@ -529,7 +573,11 @@ export const doPasswordSignIn = createServerFn({ method: "POST" })
session: authData.session,
email: data.email,
});
return toMutationTokenResponse(tokens, authData.session.user.id);
const response = toMutationTokenResponse(tokens, authData.session.user.id);
if (response.success) {
rememberSessionSignInMethod(authData.session, "email");
}
return response;
});

export const exchangeOtpToken = createServerFn({ method: "POST" })
Expand Down Expand Up @@ -588,9 +636,13 @@ export const exchangeOtpToken = createServerFn({ method: "POST" })
session: trial.session,
});
const response = toSuccessTokenResponse(tokens, authData.session.user.id);
return response.success
? { ...response, newAccount: trial.needsTrialCheckout }
: response;
if (!response.success) {
return response;
}
if (shouldRememberOtpSignIn(data.type)) {
rememberSessionSignInMethod(authData.session, "email");
}
return { ...response, newAccount: trial.needsTrialCheckout };
});

export const createDesktopSession = createServerFn({ method: "POST" }).handler(
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/lib/auth-last-sign-in-method.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
parseAuthSignInMethod,
resolveSignInMethod,
shouldRememberOtpSignIn,
} from "./auth-last-sign-in-method.ts";

test("accepts only supported sign-in methods", () => {
for (const method of ["apple", "google", "azure", "github", "email", "sso"]) {
assert.equal(parseAuthSignInMethod(method), method);
}

assert.equal(parseAuthSignInMethod("password"), null);
assert.equal(parseAuthSignInMethod(null), null);
});

test("prefers the completed sign-in method over the account's original provider", () => {
assert.equal(
resolveSignInMethod({
attemptedMethod: "email",
provider: "google",
usesSso: false,
}),
"email",
);
assert.equal(
resolveSignInMethod({
attemptedMethod: "google",
provider: "email",
usesSso: false,
}),
"google",
);
});

test("falls back to authenticated session metadata for legacy callbacks", () => {
assert.equal(
resolveSignInMethod({ provider: "email", usesSso: true }),
"sso",
);
assert.equal(
resolveSignInMethod({ provider: "google", usesSso: false }),
"google",
);
});

test("does not replace the last sign-in method during account maintenance", () => {
assert.equal(shouldRememberOtpSignIn("magiclink"), true);
assert.equal(shouldRememberOtpSignIn("signup"), true);
assert.equal(shouldRememberOtpSignIn("recovery"), false);
assert.equal(shouldRememberOtpSignIn("email_change"), false);
});
40 changes: 40 additions & 0 deletions apps/web/src/lib/auth-last-sign-in-method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const authSignInMethods = [
"apple",
"google",
"azure",
"github",
"email",
"sso",
] as const;

export type AuthSignInMethod = (typeof authSignInMethods)[number];

export function parseAuthSignInMethod(value: unknown): AuthSignInMethod | null {
switch (value) {
case "apple":
case "google":
case "azure":
case "github":
case "email":
case "sso":
return value;
default:
return null;
}
}

export function resolveSignInMethod({
attemptedMethod,
provider,
usesSso,
}: {
attemptedMethod?: AuthSignInMethod;
provider: unknown;
usesSso: boolean;
}): AuthSignInMethod | null {
return attemptedMethod ?? (usesSso ? "sso" : parseAuthSignInMethod(provider));
}

export function shouldRememberOtpSignIn(type: string) {
return type !== "recovery" && type !== "email_change";
}
9 changes: 8 additions & 1 deletion apps/web/src/routes/_view/callback/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
resolveAuthFlowContext,
toAuthFlowSearch,
} from "@/lib/auth-flow-context";
import { authSignInMethods } from "@/lib/auth-last-sign-in-method";
import {
buildPostAuthDestination,
sanitizeInternalReturnPath,
Expand Down Expand Up @@ -46,6 +47,7 @@ const validateSearch = z.object({
"email_change",
])
.optional(),
method: z.enum(authSignInMethods).optional(),
flow: z.enum(["desktop", "web"]).default("web"),
scheme: desktopSchemeSchema.catch(DEFAULT_DESKTOP_SCHEME),
redirect: z.string().optional(),
Expand All @@ -69,7 +71,12 @@ export const Route = createFileRoute("/_view/callback/auth")({

if (search.code) {
const result = await exchangeOAuthCode({
data: { code: search.code, flow: search.flow },
data: {
code: search.code,
flow: search.flow,
type: search.type,
method: search.method,
},
});

if (!result.success) {
Expand Down
Loading
Loading