chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user