178 lines
6.6 KiB
TypeScript
178 lines
6.6 KiB
TypeScript
import type { SessionStorage } from "@remix-run/server-runtime";
|
|
import type { AuthenticateOptions, Authenticator } from "remix-auth";
|
|
import { Strategy } from "remix-auth";
|
|
import { tryCatch } from "@trigger.dev/core/v3";
|
|
import type { SsoFlow, SsoProfile } from "@trigger.dev/plugins";
|
|
import { prisma } from "~/db.server";
|
|
import { ensureOrgMember } from "~/models/orgMember.server";
|
|
import { findOrCreateSsoUser } from "~/models/user.server";
|
|
import type { AuthUser } from "./authUser";
|
|
import { logger } from "./logger.server";
|
|
import { postAuthentication } from "./postAuth.server";
|
|
import { ssoController } from "./sso.server";
|
|
|
|
export type SsoVerifyParams = {
|
|
profile: SsoProfile;
|
|
flow: SsoFlow;
|
|
};
|
|
|
|
// Hybrid remix-auth strategy. The strategy is invoked by the callback
|
|
// route AFTER it has performed the SSO code exchange via the plugin —
|
|
// the route passes the verified profile + flow through
|
|
// `authenticator.authenticate("sso", request, { context })`. The
|
|
// strategy reads that context and runs the user-resolution side of the
|
|
// flow (plugin identity lookups + host-side User/OrgMember writes).
|
|
//
|
|
// In an OSS deployment with no SSO plugin installed, the plugin's
|
|
// `resolveSsoIdentity` returns `feature_disabled` from the fallback,
|
|
// which propagates here as a failure. That's the expected behaviour:
|
|
// without the plugin there is no callback route invoking the strategy
|
|
// in the first place.
|
|
class SsoStrategy extends Strategy<AuthUser, SsoVerifyParams> {
|
|
name = "sso";
|
|
|
|
async authenticate(
|
|
request: Request,
|
|
sessionStorage: SessionStorage,
|
|
options: AuthenticateOptions
|
|
): Promise<AuthUser> {
|
|
const ctx = (options.context ?? undefined) as SsoVerifyParams | undefined;
|
|
if (!ctx?.profile || !ctx?.flow) {
|
|
return this.failure(
|
|
"SSO strategy invoked without profile context",
|
|
request,
|
|
sessionStorage,
|
|
options
|
|
);
|
|
}
|
|
const [error, user] = await tryCatch(this.verify(ctx));
|
|
if (error) {
|
|
const cause = error instanceof Error ? error : new Error(String(error));
|
|
return this.failure(cause.message, request, sessionStorage, options, cause);
|
|
}
|
|
return this.success(user, request, sessionStorage, options);
|
|
}
|
|
}
|
|
|
|
// Resolve the host User for a verified SSO profile, creating one on the
|
|
// `create_new_user` decision. Throws on an errored decision — this surfaces
|
|
// "feature_disabled" in OSS deployments, which the callback route's error
|
|
// path translates into a generic sign-in-failed user-facing message.
|
|
async function resolveSsoUserId(
|
|
profile: SsoProfile
|
|
): Promise<{ userId: string; isNewUser: boolean }> {
|
|
const decision = await ssoController.resolveSsoIdentity({ profile });
|
|
if (decision.isErr()) {
|
|
throw new Error(`SSO resolve failed: ${decision.error}`);
|
|
}
|
|
|
|
if (decision.value.kind === "create_new_user") {
|
|
const created = await findOrCreateSsoUser({
|
|
authenticationMethod: "SSO",
|
|
email: profile.email,
|
|
firstName: profile.firstName,
|
|
lastName: profile.lastName,
|
|
});
|
|
return { userId: created.user.id, isNewUser: created.isNewUser };
|
|
}
|
|
|
|
return { userId: decision.value.userId, isNewUser: false };
|
|
}
|
|
|
|
// Best-effort: attaching the IdP identity row is an optimisation for the
|
|
// next login (it lets resolveSsoIdentity take the existing_user_by_idp fast
|
|
// path instead of falling back to linked_by_email). The user is already
|
|
// authenticated by this point, so we log and continue rather than failing
|
|
// the sign-in; a later successful login will write the row.
|
|
async function attachSsoIdentityBestEffort(
|
|
userId: string,
|
|
profile: SsoProfile,
|
|
flow: SsoFlow
|
|
): Promise<void> {
|
|
const attach = await ssoController.attachSsoIdentity({ userId, profile });
|
|
if (attach.isErr()) {
|
|
logger.warn("SSO attachSsoIdentity failed", { reason: attach.error, userId, flow });
|
|
}
|
|
}
|
|
|
|
// Best-effort JIT org provisioning. Like attachSsoIdentity above, a failure
|
|
// must not block an otherwise-valid sign-in: the user simply isn't
|
|
// provisioned this time and a later login retries. "feature_disabled" is the
|
|
// expected OSS-fallback result, so it's swallowed silently.
|
|
async function provisionJitMembershipBestEffort(
|
|
userId: string,
|
|
profile: SsoProfile,
|
|
flow: SsoFlow
|
|
): Promise<void> {
|
|
const jit = await ssoController.evaluateJit({ userId, idpOrgId: profile.idpOrgId });
|
|
if (jit.isErr()) {
|
|
if (jit.error !== "feature_disabled") {
|
|
logger.warn("SSO evaluateJit failed", { reason: jit.error, userId, flow });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!jit.value.shouldProvision) return;
|
|
|
|
const [provisionError, result] = await tryCatch(
|
|
ensureOrgMember({
|
|
userId,
|
|
organizationId: jit.value.organizationId,
|
|
roleId: jit.value.roleId,
|
|
source: "sso_jit",
|
|
})
|
|
);
|
|
if (provisionError) {
|
|
// e.g. the RBAC role couldn't be applied, so ensureOrgMember rolled back
|
|
// the membership.
|
|
logger.warn("SSO JIT provisioning failed", {
|
|
reason: provisionError instanceof Error ? provisionError.message : String(provisionError),
|
|
userId,
|
|
organizationId: jit.value.organizationId,
|
|
flow,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!result.created) {
|
|
logger.info("SSO JIT skipped — membership already exists", {
|
|
userId,
|
|
organizationId: jit.value.organizationId,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function runPostAuthentication(userId: string, isNewUser: boolean): Promise<void> {
|
|
const user = await prisma.user.findFirst({ where: { id: userId } });
|
|
if (!user) {
|
|
// The user was just resolved or created above, so a null here means it
|
|
// was hard-deleted mid-flow (or a DB inconsistency). Fail closed — throw
|
|
// rather than skipping postAuthentication and still returning a valid
|
|
// AuthUser, which would mint a session for a user we can't confirm.
|
|
throw new Error(`SSO user not found after resolution: ${userId}`);
|
|
}
|
|
await postAuthentication({ user, isNewUser, loginMethod: "SSO" });
|
|
}
|
|
|
|
export function addSsoStrategy(authenticator: Authenticator<AuthUser>) {
|
|
authenticator.use(
|
|
new SsoStrategy(async ({ profile, flow }) => {
|
|
const { userId, isNewUser } = await resolveSsoUserId(profile);
|
|
|
|
await attachSsoIdentityBestEffort(userId, profile, flow);
|
|
await provisionJitMembershipBestEffort(userId, profile, flow);
|
|
await runPostAuthentication(userId, isNewUser);
|
|
|
|
// Carry the SSO marker on the returned AuthUser so the session is
|
|
// self-describing — `revalidateSsoSession()` keys off `AuthUser.sso`,
|
|
// and relying on the callback route to re-attach it would silently
|
|
// disable revalidation for any other caller of this strategy.
|
|
return {
|
|
userId,
|
|
sso: { idpOrgId: profile.idpOrgId, connectionId: profile.idpConnectionId },
|
|
};
|
|
}),
|
|
"sso"
|
|
);
|
|
}
|