feat(plugins): add SSO plugin contract to @trigger.dev/plugins (#3949)
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:*"
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"neverthrow": "^8.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user