292 lines
9.6 KiB
TypeScript
292 lines
9.6 KiB
TypeScript
import type {
|
|
DirectorySyncEffect,
|
|
DirectorySyncStatus,
|
|
OrgSsoStatus,
|
|
PluginDatabaseConfig,
|
|
SsoBeginError,
|
|
SsoCompleteError,
|
|
SsoController,
|
|
SsoDecisionError,
|
|
SsoFlow,
|
|
SsoMutationError,
|
|
SsoPlugin,
|
|
SsoPortalError,
|
|
SsoProfile,
|
|
SsoResolutionDecision,
|
|
SsoRouteDecision,
|
|
SsoValidateError,
|
|
SsoWebhookError,
|
|
SsoWebhookEvent,
|
|
} from "@trigger.dev/plugins";
|
|
import type { PrismaClient } from "@trigger.dev/database";
|
|
import { ResultAsync } from "neverthrow";
|
|
import { SsoFallback } from "./fallback.js";
|
|
export type { SsoController } from "@trigger.dev/plugins";
|
|
|
|
export type SsoPrismaInput = PrismaClient | { primary: PrismaClient; replica: PrismaClient };
|
|
|
|
export type SsoCreateOptions = {
|
|
// When true, skip loading the plugin. Useful for tests and for
|
|
// contributors who don't have the cloud plugin installed.
|
|
forceFallback?: boolean;
|
|
// Override the dynamic importer. Lets tests inject a fake plugin
|
|
// module or a synthetic ERR_MODULE_NOT_FOUND failure without touching
|
|
// the real plugin install on disk.
|
|
importer?: (moduleName: string) => Promise<{ default: SsoPlugin }>;
|
|
// Writer/reader connection URLs + pool sizes for a plugin that owns its
|
|
// own database client, resolved by the host from its env so the plugin
|
|
// follows the host's writer/replica topology. The fallback ignores this —
|
|
// it queries through the Prisma clients passed as `SsoPrismaInput`.
|
|
database?: PluginDatabaseConfig;
|
|
};
|
|
|
|
// Loads the cloud plugin lazily; falls back to the OSS no-op
|
|
// implementation if not installed. Synchronous create() avoids
|
|
// top-level await (not supported in the webapp's CJS build).
|
|
export class LazyController implements SsoController {
|
|
private readonly _init: Promise<SsoController>;
|
|
|
|
constructor(prisma: SsoPrismaInput, options?: SsoCreateOptions) {
|
|
this._init = this.load(prisma, options);
|
|
}
|
|
|
|
private async load(prisma: SsoPrismaInput, options?: SsoCreateOptions): Promise<SsoController> {
|
|
if (options?.forceFallback) {
|
|
return new SsoFallback(prisma).create();
|
|
}
|
|
const moduleName = "@triggerdotdev/plugins/sso";
|
|
const importer =
|
|
options?.importer ?? ((m: string) => import(m) as Promise<{ default: SsoPlugin }>);
|
|
try {
|
|
const module = await importer(moduleName);
|
|
const plugin: SsoPlugin = module.default;
|
|
console.log("SSO: using plugin implementation");
|
|
return plugin.create({ database: options?.database });
|
|
} catch (err) {
|
|
// Distinguish the two failure modes the dynamic import can hit:
|
|
//
|
|
// 1. The plugin itself is absent (no install) — expected on OSS
|
|
// deployments. Quiet by default; logged when SSO_LOG_FALLBACK=1
|
|
// so contributors can opt into a visible signal locally.
|
|
// 2. The plugin module loaded but its initialization failed
|
|
// (transitive dep missing, syntax error, …). Always logged
|
|
// loudly because this indicates a real bug.
|
|
//
|
|
// Node throws ERR_MODULE_NOT_FOUND for both cases, so we
|
|
// disambiguate by checking whether the missing specifier is the
|
|
// plugin's own module name.
|
|
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const isModuleNotFound = code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
|
|
const isPluginItselfMissing = isModuleNotFound && message.includes(moduleName);
|
|
|
|
if (!isPluginItselfMissing) {
|
|
console.error(
|
|
"SSO: plugin found but failed to load; falling back to default implementation",
|
|
err
|
|
);
|
|
} else if (process.env.SSO_LOG_FALLBACK === "1" || process.env.SSO_LOG_FALLBACK === "true") {
|
|
console.log("SSO: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback");
|
|
}
|
|
return new SsoFallback(prisma).create();
|
|
}
|
|
}
|
|
|
|
private c(): Promise<SsoController> {
|
|
return this._init;
|
|
}
|
|
|
|
// Bridges a Promise<ResultAsync<T,E>> back into a ResultAsync<T,E>.
|
|
// The `load()` method above always resolves (it catches and falls
|
|
// back), so `this.c()` is safe to lift via fromSafePromise. Inner
|
|
// controller methods are expected to never throw — they return
|
|
// errors via the Result instead — so the .andThen flatten is total.
|
|
private call<T, E>(factory: (c: SsoController) => ResultAsync<T, E>): ResultAsync<T, E> {
|
|
return ResultAsync.fromSafePromise(this.c().then(factory)).andThen((r) => r);
|
|
}
|
|
|
|
async isUsingPlugin(): Promise<boolean> {
|
|
return (await this.c()).isUsingPlugin();
|
|
}
|
|
|
|
getStatus(organizationId: string): ResultAsync<OrgSsoStatus, SsoDecisionError> {
|
|
return this.call((c) => c.getStatus(organizationId));
|
|
}
|
|
|
|
generatePortalLink(params: {
|
|
organizationId: string;
|
|
userId: string;
|
|
intent: "sso" | "domain_verification" | "dsync";
|
|
returnUrl: string;
|
|
}): ResultAsync<{ url: string }, SsoPortalError> {
|
|
return this.call((c) => c.generatePortalLink(params));
|
|
}
|
|
|
|
setEnforced(params: {
|
|
organizationId: string;
|
|
enforced: boolean;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.setEnforced(params));
|
|
}
|
|
|
|
setJitProvisioningEnabled(params: {
|
|
organizationId: string;
|
|
enabled: boolean;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.setJitProvisioningEnabled(params));
|
|
}
|
|
|
|
setJitDefaultRole(params: {
|
|
organizationId: string;
|
|
roleId: string | null;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.setJitDefaultRole(params));
|
|
}
|
|
|
|
updateConfig(params: {
|
|
organizationId: string;
|
|
enforced: boolean;
|
|
jitProvisioningEnabled: boolean;
|
|
jitDefaultRoleId: string | null;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.updateConfig(params));
|
|
}
|
|
|
|
getDirectorySyncStatus(
|
|
organizationId: string
|
|
): ResultAsync<DirectorySyncStatus, SsoDecisionError> {
|
|
return this.call((c) => c.getDirectorySyncStatus(organizationId));
|
|
}
|
|
|
|
setDirectoryGroupRole(params: {
|
|
organizationId: string;
|
|
groupId: string;
|
|
roleId: string | null;
|
|
}): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError> {
|
|
return this.call((c) => c.setDirectoryGroupRole(params));
|
|
}
|
|
|
|
setDirectoryDefaultRole(params: {
|
|
organizationId: string;
|
|
roleId: string | null;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.setDirectoryDefaultRole(params));
|
|
}
|
|
|
|
setAllowExternalDomainSync(params: {
|
|
organizationId: string;
|
|
allowed: boolean;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.setAllowExternalDomainSync(params));
|
|
}
|
|
|
|
getMembershipPolicy(
|
|
organizationId: string
|
|
): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError> {
|
|
return this.call((c) => c.getMembershipPolicy(organizationId));
|
|
}
|
|
|
|
setAllowManualMembership(params: {
|
|
organizationId: string;
|
|
allowed: boolean;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.setAllowManualMembership(params));
|
|
}
|
|
|
|
recordMembershipRemoval(params: {
|
|
organizationId: string;
|
|
userId: string;
|
|
reason: "manual_removal" | "self_leave";
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.recordMembershipRemoval(params));
|
|
}
|
|
|
|
clearMembershipRemoval(params: {
|
|
organizationId: string;
|
|
userId: string;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.clearMembershipRemoval(params));
|
|
}
|
|
|
|
decideRouteForEmail(email: string): ResultAsync<SsoRouteDecision, SsoDecisionError> {
|
|
return this.call((c) => c.decideRouteForEmail(email));
|
|
}
|
|
|
|
beginAuthorization(params: {
|
|
email: string;
|
|
redirectTo: string;
|
|
flow: SsoFlow;
|
|
}): ResultAsync<{ url: string }, SsoBeginError> {
|
|
return this.call((c) => c.beginAuthorization(params));
|
|
}
|
|
|
|
completeAuthorization(params: {
|
|
code: string;
|
|
state: string;
|
|
}): ResultAsync<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }, SsoCompleteError> {
|
|
return this.call((c) => c.completeAuthorization(params));
|
|
}
|
|
|
|
completeIdpInitiatedAuthorization(params: {
|
|
code: string;
|
|
}): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError> {
|
|
return this.call((c) => c.completeIdpInitiatedAuthorization(params));
|
|
}
|
|
|
|
validateSession(params: {
|
|
userId: string;
|
|
idpOrgId: string;
|
|
connectionId: string;
|
|
}): ResultAsync<{ valid: boolean }, SsoValidateError> {
|
|
return this.call((c) => c.validateSession(params));
|
|
}
|
|
|
|
resolveSsoIdentity(params: {
|
|
profile: SsoProfile;
|
|
}): ResultAsync<SsoResolutionDecision, SsoMutationError> {
|
|
return this.call((c) => c.resolveSsoIdentity(params));
|
|
}
|
|
|
|
attachSsoIdentity(params: {
|
|
userId: string;
|
|
profile: SsoProfile;
|
|
}): ResultAsync<void, SsoMutationError> {
|
|
return this.call((c) => c.attachSsoIdentity(params));
|
|
}
|
|
|
|
evaluateJit(params: {
|
|
userId: string;
|
|
idpOrgId: string;
|
|
}): ResultAsync<
|
|
{ shouldProvision: boolean; organizationId: string; roleId: string | null },
|
|
SsoMutationError
|
|
> {
|
|
return this.call((c) => c.evaluateJit(params));
|
|
}
|
|
|
|
verifyWebhook(params: {
|
|
rawBody: string;
|
|
headers: Record<string, string>;
|
|
}): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError> {
|
|
return this.call((c) => c.verifyWebhook(params));
|
|
}
|
|
|
|
processWebhookEvent(
|
|
event: SsoWebhookEvent
|
|
): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError> {
|
|
return this.call((c) => c.processWebhookEvent(event));
|
|
}
|
|
}
|
|
|
|
class Sso {
|
|
// Synchronous — returns a lazy controller that resolves any installed
|
|
// plugin on first call.
|
|
create(prisma: SsoPrismaInput, options?: SsoCreateOptions): SsoController {
|
|
return new LazyController(prisma, options);
|
|
}
|
|
}
|
|
|
|
const loader = new Sso();
|
|
|
|
export default loader;
|