Files
2026-07-13 13:32:57 +08:00

125 lines
5.3 KiB
TypeScript

import { redirect, type LoaderFunctionArgs } from "@remix-run/node";
import type { SsoFlow, SsoProfile } from "@trigger.dev/plugins";
import type { AuthUser } from "~/services/authUser";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
import { logger } from "~/services/logger.server";
import { ssoController } from "~/services/sso.server";
import { commitAuthenticatedSession } from "~/services/sessionDuration.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { trackAndClearReferralSource } from "~/services/referralSource.server";
import { sanitizeRedirectPath } from "~/utils";
// Resolve the SSO completion for either the SP-initiated (state present)
// or IdP-initiated (no state) flow. Throws a redirect to the error page
// on failure, letting the caller stay on the happy path. Returning a
// single shape here is what lets the loader use a plain destructure
// rather than three conditionally-assigned `let`s.
async function resolveSsoCompletion(
code: string,
state: string | null
): Promise<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }> {
if (state) {
const completion = await ssoController.completeAuthorization({ code, state });
if (completion.isErr()) {
logger.warn("SSO callback failed", { reason: completion.error, idpInitiated: false });
throw redirect(`/login/sso?error=sso_failed`);
}
return completion.value;
}
const completion = await ssoController.completeIdpInitiatedAuthorization({ code });
if (completion.isErr()) {
logger.warn("SSO callback failed", { reason: completion.error, idpInitiated: true });
throw redirect(`/login/sso?error=sso_failed`);
}
return { ...completion.value, flow: "idp_initiated" };
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
if (!code) {
return redirect(`/login/sso?error=missing_code`);
}
const state = url.searchParams.get("state");
const { profile, redirectTo: rawRedirectTo, flow } = await resolveSsoCompletion(code, state);
// Sanitize at the exit point regardless of flow. SP-initiated values were
// sanitized on the way in (auth.sso.ts) and signed into the state token, but
// IdP-initiated `redirectTo` originates from the IdP's relay-state and never
// passed through the host — without this an IdP admin could craft an open
// redirect. Mirrors every other auth callback. A rejected value falls back
// to "/". The Vercel resume URL (`/vercel/onboarding?...`) is navigable and
// survives.
const redirectTo = sanitizeRedirectPath(rawRedirectTo);
// `throwOnError` makes the SSO strategy's verify-callback failures
// (resolveSsoIdentity errors, DB failures in findOrCreateSsoUser,
// ensureOrgMember) surface as a thrown AuthorizationError rather than a
// redirect. Without this catch they'd 500; mirror the GitHub/Google
// callbacks and redirect back to the SSO error page instead. remix-auth
// signals its own redirects by throwing Responses — pass those through.
let auth: AuthUser;
try {
auth = await authenticator.authenticate("sso", request, {
throwOnError: true,
context: { profile, flow },
});
} catch (thrown) {
if (thrown instanceof Response) throw thrown;
logger.warn("SSO authentication failed", { error: thrown });
return redirect(`/login/sso?error=sso_failed`);
}
const session = await getUserSession(request);
const userRecord = await prisma.user.findFirst({
where: { id: auth.userId },
select: { id: true, mfaEnabledAt: true },
});
if (!userRecord) {
return redirectWithErrorMessage(
"/login",
request,
"Could not find your account. Please contact support."
);
}
if (userRecord.mfaEnabledAt) {
session.set("pending-mfa-user-id", userRecord.id);
session.set("pending-mfa-redirect-to", redirectTo);
// Carry the SSO marker through the MFA hop so the final session is
// revalidated against the IdP exactly like a non-MFA SSO session.
session.set("pending-sso", {
idpOrgId: profile.idpOrgId,
connectionId: profile.idpConnectionId,
});
const headers = new Headers();
headers.append("Set-Cookie", await commitSession(session));
headers.append("Set-Cookie", await setLastAuthMethodHeader("sso"));
return redirect("/login/mfa", { headers });
}
// Mark the session as SSO-established so the periodic re-validation
// hook knows to check it against the IdP. The marker is signed into
// the cookie (tamper-proof).
session.set(authenticator.sessionKey, {
...auth,
sso: { idpOrgId: profile.idpOrgId, connectionId: profile.idpConnectionId },
});
const headers = new Headers();
headers.append("Set-Cookie", await commitAuthenticatedSession(session, auth.userId));
headers.append("Set-Cookie", await setLastAuthMethodHeader("sso"));
// Attribute the referral source on the final session creation, like the
// other non-MFA auth callbacks. The MFA path defers this to `completeLogin`.
await trackAndClearReferralSource(request, auth.userId, headers);
return redirect(redirectTo, { headers });
}