feat(plugins): add SSO plugin contract to @trigger.dev/plugins (#3949)

This commit is contained in:
Oskar Otwinowski
2026-06-15 16:08:40 +01:00
committed by GitHub
parent 3b919994c1
commit 545ecf7beb
4 changed files with 258 additions and 1 deletions
+2 -1
View File
@@ -14,7 +14,8 @@
"dist"
],
"dependencies": {
"@trigger.dev/core": "workspace:*"
"@trigger.dev/core": "workspace:*",
"neverthrow": "^8.2.0"
},
"scripts": {
"clean": "rimraf dist .turbo",
+23
View File
@@ -18,3 +18,26 @@ export type {
} from "./rbac.js";
export { buildJwtAbility } from "./rbac.js";
export type {
SsoPlugin,
SsoController,
OrgSsoStatus,
SsoRouteDecision,
SsoFlow,
SsoProfile,
SsoConnectionState,
SsoDomainState,
SsoDomainStatus,
SsoResolutionDecision,
SsoDecisionError,
SsoBeginError,
SsoCompleteError,
SsoMutationError,
SsoPortalError,
SsoValidateError,
SsoWebhookError,
SsoWebhookEvent,
} from "./sso.js";
export { SSO_FLOWS } from "./sso.js";
+230
View File
@@ -0,0 +1,230 @@
import type { ResultAsync } from "neverthrow";
// === Domain types ===
export type SsoConnectionState = "active" | "inactive";
export type SsoDomainState = "pending" | "verified" | "failed";
export type SsoDomainStatus = {
domain: string;
verified: boolean;
state: SsoDomainState;
// Vendor-supplied reason code present when state === "failed".
// Plugin keeps it opaque; the host UI surfaces it to the admin so
// they know which knob to turn before retrying verification.
verificationFailedReason: string | null;
};
export type OrgSsoStatus = {
hasIdpOrg: boolean;
enforced: boolean;
jitProvisioningEnabled: boolean;
jitDefaultRoleId: string | null;
idpOrgId: string | null;
primaryConnectionId: string | null;
domains: ReadonlyArray<SsoDomainStatus>;
connections: ReadonlyArray<{
id: string;
name: string | null;
connectionType: string;
state: SsoConnectionState;
}>;
};
export type SsoRouteDecision =
| { kind: "no_sso" }
| { kind: "sso_required"; idpOrgId: string };
export const SSO_FLOWS = [
"user_initiated",
"auto_discovery_magic",
"auto_discovery_oauth",
"auto_discovery_vercel",
"idp_initiated",
] as const;
export type SsoFlow = (typeof SSO_FLOWS)[number];
export type SsoProfile = {
// Lowercase-normalized at the plugin / host boundary.
email: string;
firstName: string | null;
lastName: string | null;
idpSubjectId: string;
idpOrgId: string;
idpConnectionId: string;
};
export type SsoResolutionDecision =
| { kind: "existing_user_by_idp"; userId: string }
| { kind: "linked_by_email"; userId: string }
| { kind: "create_new_user"; profile: SsoProfile };
// === Errors ===
export type SsoDecisionError = "internal";
export type SsoBeginError =
| "no_org_for_domain"
| "no_active_connection"
| "feature_disabled";
export type SsoCompleteError =
| "state_replayed_or_expired"
| "state_invalid_signature"
| "code_exchange_failed"
| "org_mismatch"
| "email_mismatch"
| "connection_unknown";
export type SsoMutationError = "feature_disabled" | "rbac_role_invalid" | "internal";
// Vendor-neutral name for "the identity-provider organisation isn't available".
export type SsoPortalError = "idp_org_unavailable" | "internal";
// The only failure a session re-validation can report is "internal" —
// callers MUST treat it as fail-open (keep the session). An invalid
// session is NOT an error: it's a successful result of `{ valid: false }`.
export type SsoValidateError = "internal";
// Inbound webhook handling. `invalid_signature` → reject (4xx, no retry);
// `feature_disabled` → no plugin installed (host returns 404); `internal`
// → transient, the host returns 5xx so the provider retries.
export type SsoWebhookError = "invalid_signature" | "feature_disabled" | "internal";
// A verified, JSON-serializable inbound event. Vendor-neutral envelope —
// `event` is the provider's event-type string, `data` its opaque payload.
export type SsoWebhookEvent = { id: string; event: string; data: unknown };
// === Controller ===
export interface SsoController {
// True when a real SSO plugin is loaded. Hosts gate behaviour that's
// only meaningful when the plugin is present (e.g. rendering the
// settings tab, registering the SSO strategy actively).
isUsingPlugin(): Promise<boolean>;
// --- Provisioning + admin UI ---
getStatus(organizationId: string): ResultAsync<OrgSsoStatus, SsoDecisionError>;
// Returns an admin-portal link the customer's IT admin uses to
// configure their identity provider. First call also performs any lazy
// initialization the plugin needs (no separate enable() method).
generatePortalLink(params: {
organizationId: string;
userId: string;
intent: "sso" | "domain_verification";
returnUrl: string;
}): ResultAsync<{ url: string }, SsoPortalError>;
setEnforced(params: {
organizationId: string;
enforced: boolean;
}): ResultAsync<void, SsoMutationError>;
setJitProvisioningEnabled(params: {
organizationId: string;
enabled: boolean;
}): ResultAsync<void, SsoMutationError>;
setJitDefaultRole(params: {
organizationId: string;
roleId: string | null;
}): ResultAsync<void, SsoMutationError>;
// --- Auth flow ---
// Called by every login entry point BEFORE the strategy proceeds.
// Composite gate (plan tier + feature flags + config + enforced) is
// implemented here. Fail-open: returns no_sso on internal error so a
// plugin outage doesn't lock users out.
decideRouteForEmail(email: string): ResultAsync<SsoRouteDecision, SsoDecisionError>;
// Returns the URL the user should be redirected to in order to
// authenticate with their identity provider. Internally mints a
// single-use signed state token; the implementation is opaque to
// OSS callers. Email is lowercase-normalized before lookup.
beginAuthorization(params: {
email: string;
redirectTo: string;
flow: SsoFlow;
}): ResultAsync<{ url: string }, SsoBeginError>;
// SP-initiated callback. Verifies and consumes the signed state token
// single-use, exchanges the code with the SSO provider, cross-checks
// the returned profile against the state claims. Returns profile +
// state-carried redirectTo + flow.
completeAuthorization(params: {
code: string;
state: string;
}): ResultAsync<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }, SsoCompleteError>;
// IdP-initiated callback (no state). Validates the returned connection
// identifier is one of ours. Default redirectTo is "/".
completeIdpInitiatedAuthorization(params: {
code: string;
}): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError>;
// Re-validate a live SSO session against the IdP. Called periodically
// (throttled by the host) for sessions that were established via SSO.
// The available signal is whether the user's identity-provider
// connection is still active, so `valid` reflects that. Returns an
// `internal` error on any infrastructure failure (e.g. the identity
// provider is unreachable) — the host MUST fail-open on the error and
// only invalidate the session on an explicit `{ valid: false }`.
validateSession(params: {
userId: string;
idpOrgId: string;
connectionId: string;
}): ResultAsync<{ valid: boolean }, SsoValidateError>;
// Look up an existing identity by IdP subject, or by lowercased email.
// Returns a decision the OSS callback handler uses to drive
// User/OrgMember writes. The plugin DOES NOT write to OSS public.*
// tables — those writes are the host's responsibility.
resolveSsoIdentity(params: {
profile: SsoProfile;
}): ResultAsync<SsoResolutionDecision, SsoMutationError>;
// After the host has created/found the User row, the plugin attaches
// the IdP identity row in its own storage.
attachSsoIdentity(params: {
userId: string;
profile: SsoProfile;
}): ResultAsync<void, SsoMutationError>;
// Returns whether JIT should provision a membership for the given
// (userId, idpOrgId), and the resolved roleId to assign (the org's
// JIT default role, or null when no RBAC plugin is installed).
// The host performs the actual OrgMember insert.
evaluateJit(params: {
userId: string;
idpOrgId: string;
}): ResultAsync<
{ shouldProvision: boolean; organizationId: string; roleId: string | null },
SsoMutationError
>;
// --- Inbound webhooks ---
// Verify the signature of a raw inbound webhook request and return the
// parsed, JSON-serializable event. The host forwards the raw body +
// headers from a thin proxy route; the plugin owns the vendor-specific
// signature scheme. The host enqueues the returned event for async
// processing (it never enqueues an unverified request).
verifyWebhook(params: {
rawBody: string;
headers: Record<string, string>;
}): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError>;
// Process a previously-verified webhook event (the host's background
// worker calls this). Performs the plugin's own state writes; throws
// nothing — failures surface as `internal` so the worker retries.
processWebhookEvent(event: SsoWebhookEvent): ResultAsync<void, SsoWebhookError>;
}
export interface SsoPlugin {
create(): SsoController | Promise<SsoController>;
}
+3
View File
@@ -1952,6 +1952,9 @@ importers:
'@trigger.dev/core':
specifier: workspace:*
version: link:../core
neverthrow:
specifier: ^8.2.0
version: 8.2.0
devDependencies:
'@types/node':
specifier: 20.14.14