diff --git a/ee/apps/den-api/README.md b/ee/apps/den-api/README.md index 0412a7c68..35c81ec9e 100644 --- a/ee/apps/den-api/README.md +++ b/ee/apps/den-api/README.md @@ -20,6 +20,7 @@ pnpm --filter @openwork-ee/den-api dev:local - desktop handoff routes under `/v1/auth/*` - current user routes under `/v1/me*` - organization routes under `/v1/orgs*` +- active-organization SCIM management routes under `/v1/scim*` - admin routes under `/v1/admin*` - worker lifecycle and billing routes under `/v1/workers*` diff --git a/ee/apps/den-api/package.json b/ee/apps/den-api/package.json index 07da96ab1..b2c083b4e 100644 --- a/ee/apps/den-api/package.json +++ b/ee/apps/den-api/package.json @@ -11,13 +11,15 @@ }, "dependencies": { "@better-auth/api-key": "^1.5.6", + "@better-auth/scim": "^1.5.6", + "@better-auth/sso": "^1.5.6", "@daytonaio/sdk": "^0.150.0", "@hono/node-server": "^1.13.8", "@hono/standard-validator": "^0.2.2", "@hono/swagger-ui": "^0.6.1", - "@openwork/types": "workspace:*", "@openwork-ee/den-db": "workspace:*", "@openwork-ee/utils": "workspace:*", + "@openwork/types": "workspace:*", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@standard-schema/spec": "^1.1.0", diff --git a/ee/apps/den-api/src/app.ts b/ee/apps/den-api/src/app.ts index d1162c802..afbdc7c49 100644 --- a/ee/apps/den-api/src/app.ts +++ b/ee/apps/den-api/src/app.ts @@ -57,7 +57,7 @@ if (env.corsOrigins.length > 0) { origin: env.corsOrigins, credentials: true, allowHeaders: ["Content-Type", "Authorization", "X-Api-Key", "X-Request-Id"], - allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], exposeHeaders: ["Content-Length", "X-Request-Id"], maxAge: 600, }), @@ -147,6 +147,7 @@ app.get( { name: "Organizations", description: "Top-level organization creation and context routes." }, { name: "Invitations", description: "Invitation preview, acceptance, creation, and cancellation routes." }, { name: "API Keys", description: "Organization API key management routes." }, + { name: "SCIM", description: "Organization SCIM connector management routes." }, { name: "Members", description: "Organization member management routes." }, { name: "Roles", description: "Organization custom role management routes." }, { name: "Teams", description: "Organization team management routes." }, diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index c9cbf38b4..dbce7b957 100644 --- a/ee/apps/den-api/src/auth.ts +++ b/ee/apps/den-api/src/auth.ts @@ -19,9 +19,12 @@ import { seedDefaultOrganizationRoles } from "./orgs.js"; import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"; import * as schema from "@openwork-ee/den-db/schema"; import { apiKey } from "@better-auth/api-key"; +import { scim } from "@better-auth/scim"; +import { sso } from "@better-auth/sso"; import { APIError } from "better-call"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { and, eq } from "@openwork-ee/den-db/drizzle"; import { emailOTP, organization } from "better-auth/plugins"; const socialProviders = { @@ -51,6 +54,20 @@ function hasRole(roleValue: string, roleName: string) { .includes(roleName); } +function maybeString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function pickRemoteIdentity(userInfo: Record) { + return ( + maybeString(userInfo.sub) ?? + maybeString(userInfo.id) ?? + maybeString(userInfo.nameID) ?? + maybeString(userInfo.nameId) ?? + maybeString(userInfo.email) + ); +} + function getInvitationOrigin() { return ( env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? @@ -127,6 +144,14 @@ export const auth = betterAuth({ return createDenTypeId("teamMember"); case "organizationRole": return createDenTypeId("organizationRole"); + case "scimProvider": + return createDenTypeId("scimProvider"); + case "ssoProvider": + return createDenTypeId("ssoProvider"); + case "ssoConnection": + return createDenTypeId("ssoConnection"); + case "externalIdentity": + return createDenTypeId("externalIdentity"); default: return false; } @@ -242,6 +267,91 @@ export const auth = betterAuth({ }, }, }), + scim({ + beforeSCIMTokenGenerated: async ({ member }) => { + if (!member?.organizationId) { + throw new APIError("FORBIDDEN", { + message: "SCIM connections must belong to an organization.", + }); + } + + if (!hasRole(member.role, "owner") && !hasRole(member.role, "admin")) { + throw new APIError("FORBIDDEN", { + message: "Only workspace owners and admins can manage SCIM.", + }); + } + }, + }), + sso({ + providersLimit: 1000, + provisionUserOnEveryLogin: true, + domainVerification: { + enabled: true, + }, + organizationProvisioning: { + disabled: false, + defaultRole: "member", + }, + saml: { + enableInResponseToValidation: true, + allowIdpInitiated: true, + algorithms: { + onDeprecated: "warn", + }, + }, + provisionUser: async ({ user, userInfo, provider }) => { + if (!provider.organizationId) { + return; + } + + const existingRows = await db + .select() + .from(schema.ExternalIdentityTable) + .where(and( + eq(schema.ExternalIdentityTable.organizationId, normalizeDenTypeId("organization", provider.organizationId)), + eq(schema.ExternalIdentityTable.userId, normalizeDenTypeId("user", user.id)), + )) + .limit(1); + const now = new Date(); + const existing = existingRows[0] ?? null; + const remoteId = pickRemoteIdentity(userInfo); + const displayName = maybeString(userInfo.name) ?? maybeString(userInfo.displayName) ?? maybeString(user.name); + const email = maybeString(userInfo.email) ?? maybeString(user.email); + const payload = { + organizationId: normalizeDenTypeId("organization", provider.organizationId), + userId: normalizeDenTypeId("user", user.id), + source: existing?.scimProviderId ? "scim+sso" : "sso", + ssoProviderId: provider.providerId, + remoteId, + userName: maybeString(userInfo.preferred_username) ?? email, + email, + displayName, + attributesJson: userInfo, + active: true, + lastSsoLoginAt: now, + }; + + if (existing) { + await db + .update(schema.ExternalIdentityTable) + .set({ + ...payload, + scimProviderId: existing.scimProviderId, + externalId: existing.externalId, + nameJson: existing.nameJson, + emailsJson: existing.emailsJson, + lastScimSyncAt: existing.lastScimSyncAt, + }) + .where(eq(schema.ExternalIdentityTable.id, existing.id)); + return; + } + + await db.insert(schema.ExternalIdentityTable).values({ + id: createDenTypeId("externalIdentity"), + ...payload, + }); + }, + }), apiKey({ defaultPrefix: DEN_API_KEY_DEFAULT_PREFIX, enableMetadata: true, diff --git a/ee/apps/den-api/src/organization-limits.ts b/ee/apps/den-api/src/organization-limits.ts index 71728c541..3f241fcf7 100644 --- a/ee/apps/den-api/src/organization-limits.ts +++ b/ee/apps/den-api/src/organization-limits.ts @@ -19,6 +19,7 @@ type OrganizationId = typeof OrganizationTable.$inferSelect.id export type OrganizationMetadata = { limits: OrganizationLimits allowedDesktopVersions?: string[] + requireSso?: boolean } & Record type OrganizationMetadataInput = Record | string | null | undefined diff --git a/ee/apps/den-api/src/orgs.ts b/ee/apps/den-api/src/orgs.ts index 179622021..d3cd88e24 100644 --- a/ee/apps/den-api/src/orgs.ts +++ b/ee/apps/den-api/src/orgs.ts @@ -612,6 +612,7 @@ export async function updateOrganizationSettings(input: { allowedEmailDomains?: readonly string[] | null desktopAppRestrictions?: DesktopAppRestrictions allowedDesktopVersions?: readonly string[] | null + requireSso?: boolean }) { const nextName = typeof input.name === "string" ? input.name.trim() : null if (typeof input.name === "string" && !nextName) { @@ -628,7 +629,7 @@ export async function updateOrganizationSettings(input: { if (input.desktopAppRestrictions !== undefined) { updates.desktopAppRestrictions = normalizeDesktopAppRestrictions(input.desktopAppRestrictions) } - if (input.allowedDesktopVersions !== undefined) { + if (input.allowedDesktopVersions !== undefined || input.requireSso !== undefined) { const rows = await db .select({ metadata: OrganizationTable.metadata }) .from(OrganizationTable) @@ -644,10 +645,16 @@ export async function updateOrganizationSettings(input: { ...normalizeOrganizationMetadata(existingOrganization.metadata).metadata, } as Record - if (input.allowedDesktopVersions === null) { - delete nextMetadata.allowedDesktopVersions - } else { - nextMetadata.allowedDesktopVersions = input.allowedDesktopVersions + if (input.allowedDesktopVersions !== undefined) { + if (input.allowedDesktopVersions === null) { + delete nextMetadata.allowedDesktopVersions + } else { + nextMetadata.allowedDesktopVersions = input.allowedDesktopVersions + } + } + + if (input.requireSso !== undefined) { + nextMetadata.requireSso = input.requireSso } updates.metadata = normalizeOrganizationMetadata(nextMetadata).metadata diff --git a/ee/apps/den-api/src/routes/auth/index.ts b/ee/apps/den-api/src/routes/auth/index.ts index 86668bb1c..41ee2fcae 100644 --- a/ee/apps/den-api/src/routes/auth/index.ts +++ b/ee/apps/den-api/src/routes/auth/index.ts @@ -4,10 +4,12 @@ import { auth } from "../../auth.js" import { emptyResponse } from "../../openapi.js" import type { AuthContextVariables } from "../../session.js" import { registerDesktopAuthRoutes } from "./desktop-handoff.js" +import { registerScimAuthRoutes } from "./scim.js" export function registerAuthRoutes(app: Hono) { + registerScimAuthRoutes(app) app.on( - ["GET", "POST"], + ["GET", "POST", "PUT", "PATCH", "DELETE"], "/api/auth/*", describeRoute({ hide: true, diff --git a/ee/apps/den-api/src/routes/auth/scim.ts b/ee/apps/den-api/src/routes/auth/scim.ts new file mode 100644 index 000000000..c9e17516f --- /dev/null +++ b/ee/apps/den-api/src/routes/auth/scim.ts @@ -0,0 +1,232 @@ +import { describeRoute } from "hono-openapi" +import type { Hono } from "hono" +import { resolver } from "hono-openapi" +import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" +import { z } from "zod" +import { auth } from "../../auth.js" +import { deleteScimProvisionedAccess, syncExternalIdentityFromScimResource, syncExternalIdentityFromScimUserId } from "../../scim.js" +import type { AuthContextVariables } from "../../session.js" + +const scimErrorSchema = z.object({ + detail: z.string(), +}).meta({ ref: "ScimAuthRouteError" }) + +const scimManagementForbiddenSchema = z.object({ + error: z.literal("forbidden"), + message: z.string(), +}).meta({ ref: "ScimManagementForbiddenError" }) + +function readBearerToken(headers: Headers) { + const header = headers.get("authorization")?.trim() ?? "" + const match = header.match(/^Bearer\s+(.+)$/i) + return match?.[1]?.trim() ?? null +} + +async function syncScimMutationFromResponse(input: { + bearerToken: string + response: Response + fallbackUserId?: string +}) { + if (!input.response.ok) { + return + } + + if (input.response.status === 204 && input.fallbackUserId) { + try { + await syncExternalIdentityFromScimUserId({ + bearerToken: input.bearerToken, + userId: normalizeDenTypeId("user", input.fallbackUserId), + }) + } catch {} + return + } + + const payload = await input.response.clone().json().catch(() => null) as Record | null + if (!payload) { + return + } + + await syncExternalIdentityFromScimResource({ + bearerToken: input.bearerToken, + resource: payload, + }) +} + +export function registerScimAuthRoutes(app: Hono) { + const rejectManagementRoute = (c: { + get: (key: "user") => AuthContextVariables["user"] + json: (object: unknown, status?: number | { status: number }) => Response + }) => { + const user = c.get("user") + if (!user?.id) { + return c.json({ error: "unauthorized" }, 401) + } + + return c.json({ + error: "forbidden", + message: "Use the organization SCIM endpoints instead of the raw Better Auth management routes.", + }, 403) + } + + app.post( + "/api/auth/scim/generate-token", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM token management", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.get( + "/api/auth/scim/list-provider-connections", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM provider listing", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.get( + "/api/auth/scim/get-provider-connection", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM provider lookup", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.post( + "/api/auth/scim/delete-provider-connection", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Block raw SCIM provider deletion", + description: "Direct SCIM management is disabled in favor of org-scoped Den routes.", + responses: { + 401: { description: "Unauthorized" }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver(scimManagementForbiddenSchema), + }, + }, + }, + }, + }), + (c) => rejectManagementRoute(c), + ) + + app.delete( + "/api/auth/scim/v2/Users/:userId", + describeRoute({ + hide: true, + tags: ["Authentication"], + summary: "Delete SCIM provisioned org access", + description: "Removes the organization membership and SCIM provider account without deleting the global app user.", + responses: { + 204: { + description: "SCIM provisioned org access deleted.", + }, + 401: { + description: "Invalid SCIM token.", + content: { + "application/json": { + schema: resolver(scimErrorSchema), + }, + }, + }, + 404: { + description: "User not found.", + content: { + "application/json": { + schema: resolver(scimErrorSchema), + }, + }, + }, + }, + }), + async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return c.json({ detail: "SCIM token is required" }, 401) + } + + let normalizedUserId + try { + normalizedUserId = normalizeDenTypeId("user", c.req.param("userId")) + } catch { + return c.json({ detail: "User not found" }, 404) + } + + const deleted = await deleteScimProvisionedAccess({ + bearerToken, + userId: normalizedUserId, + }) + + if (!deleted.ok) { + return c.json(deleted.body, { status: deleted.status as 401 | 404 }) + } + + return c.body(null, 204) + }, + ) + + const handleScimMutation = async (c: { req: { raw: Request; param: (key: string) => string } }) => { + const bearerToken = readBearerToken(c.req.raw.headers) + const response = await auth.handler(c.req.raw) + if (!bearerToken) { + return response + } + + await syncScimMutationFromResponse({ + bearerToken, + response, + fallbackUserId: c.req.param("userId") || undefined, + }) + return response + } + + app.post("/api/auth/scim/v2/Users", async (c) => handleScimMutation(c)) + app.put("/api/auth/scim/v2/Users/:userId", async (c) => handleScimMutation(c)) + app.patch("/api/auth/scim/v2/Users/:userId", async (c) => handleScimMutation(c)) +} diff --git a/ee/apps/den-api/src/routes/org/README.md b/ee/apps/den-api/src/routes/org/README.md index 9cd5504e6..3606cc2ff 100644 --- a/ee/apps/den-api/src/routes/org/README.md +++ b/ee/apps/den-api/src/routes/org/README.md @@ -9,6 +9,7 @@ This folder owns organization-facing Den API routes. - `invitations.ts`: invitation creation and cancellation - `members.ts`: member role updates and member removal - `roles.ts`: dynamic role CRUD +- `scim.ts`: active-organization SCIM connector metadata and token rotation - `templates.ts`: shared template CRUD - `shared.ts`: shared route-local helpers, param schemas, and guard helpers @@ -18,7 +19,7 @@ This folder owns organization-facing Den API routes. - New sessions should get an initial `activeOrganizationId` from Better Auth session creation hooks in `src/auth.ts`. - `GET /v1/org` returns the active organization from the current session, including a nested `organization.owner` object plus the current member and team context. - `POST /v1/org` creates a new organization and switches the session to it. `PATCH /v1/org` updates the active organization. -- Active-org scoped resources should prefer top-level routes like `/v1/templates`, `/v1/skills`, `/v1/teams`, `/v1/roles`, `/v1/api-keys`, `/v1/llm-providers`, and plugin-system `/v1/...` routes. They should not require `:orgId` or `:orgSlug` in the path. +- Active-org scoped resources should prefer top-level routes like `/v1/templates`, `/v1/skills`, `/v1/teams`, `/v1/roles`, `/v1/api-keys`, `/v1/llm-providers`, `/v1/scim`, and plugin-system `/v1/...` routes. They should not require `:orgId` or `:orgSlug` in the path. - Routes under `/v1/orgs/**` are reserved for cross-org flows that are not tied to the active workspace yet, such as invitation preview/accept. - If a client needs to change workspaces, it should call Better Auth set-active first, then use the active-org scoped `/v1/...` resource routes. diff --git a/ee/apps/den-api/src/routes/org/core.ts b/ee/apps/den-api/src/routes/org/core.ts index 3d9d5724a..c6c719db0 100644 --- a/ee/apps/den-api/src/routes/org/core.ts +++ b/ee/apps/den-api/src/routes/org/core.ts @@ -1,5 +1,5 @@ import { eq } from "@openwork-ee/den-db/drizzle" -import { OrganizationTable } from "@openwork-ee/den-db/schema" +import { OrganizationTable, SsoConnectionTable } from "@openwork-ee/den-db/schema" import { desktopAppRestrictionsSchema } from "@openwork/types/den/desktop-app-restrictions" import { normalizeDenTypeId, type DenTypeId } from "@openwork-ee/utils/typeid" import type { Hono } from "hono" @@ -33,10 +33,22 @@ const updateOrganizationSchema = z.object({ allowedEmailDomains: z.array(z.string().trim().min(1).max(255)).max(100).nullable().optional(), desktopAppRestrictions: desktopAppRestrictionsSchema.optional(), allowedDesktopVersions: z.array(z.string().trim().min(1).max(32)).max(200).nullable().optional(), -}).refine((value) => value.name !== undefined || value.allowedEmailDomains !== undefined || value.desktopAppRestrictions !== undefined || value.allowedDesktopVersions !== undefined, { + requireSso: z.boolean().optional(), +}).refine((value) => value.name !== undefined || value.allowedEmailDomains !== undefined || value.desktopAppRestrictions !== undefined || value.allowedDesktopVersions !== undefined || value.requireSso !== undefined, { message: "Provide at least one organization field to update.", }) +const resolveSsoByEmailQuerySchema = z.object({ + email: z.string().trim().email(), +}) + +const resolveSsoByEmailResponseSchema = z.object({ + requireSso: z.boolean(), + organizationSlug: z.string(), + signInPath: z.string(), + signInUrl: z.string().url(), +}).meta({ ref: "ResolveOrganizationSsoByEmailResponse" }) + const invitationPreviewQuerySchema = z.object({ id: denTypeIdSchema("invitation"), }) @@ -345,6 +357,7 @@ export function registerOrgCoreRoutes { + const query = c.req.valid("query") + const domain = query.email.slice(query.email.lastIndexOf("@") + 1).toLowerCase() + const matches = await db + .select({ + slug: OrganizationTable.slug, + metadata: OrganizationTable.metadata, + signInPath: SsoConnectionTable.signInPath, + domain: SsoConnectionTable.domain, + }) + .from(OrganizationTable) + .innerJoin(SsoConnectionTable, eq(OrganizationTable.id, SsoConnectionTable.organizationId)) + + const match = matches.find((row) => { + const metadata = row.metadata && typeof row.metadata === "object" ? row.metadata as Record : {} + return metadata.requireSso === true && row.domain.toLowerCase() === domain + }) + + if (!match) { + return c.body(null, 204) + } + + return c.json({ + requireSso: true, + organizationSlug: match.slug, + signInPath: match.signInPath, + signInUrl: new URL(match.signInPath, env.betterAuthTrustedOrigins[0] ?? env.betterAuthUrl).toString(), + }) + }, + ) + app.get( "/v1/org", describeRoute({ diff --git a/ee/apps/den-api/src/routes/org/index.ts b/ee/apps/den-api/src/routes/org/index.ts index 8958483b8..8bd959d43 100644 --- a/ee/apps/den-api/src/routes/org/index.ts +++ b/ee/apps/den-api/src/routes/org/index.ts @@ -8,6 +8,8 @@ import { registerOrgLlmProviderRoutes } from "./llm-providers.js" import { registerOrgMemberRoutes } from "./members.js" import { registerPluginArchRoutes } from "./plugin-system/routes.js" import { registerOrgRoleRoutes } from "./roles.js" +import { registerOrgScimRoutes } from "./scim.js" +import { registerOrgSsoRoutes } from "./sso.js" import { registerOrgSkillRoutes } from "./skills.js" import { registerOrgTeamRoutes } from "./teams.js" import { registerOrgTemplateRoutes } from "./templates.js" @@ -41,6 +43,8 @@ function extractLegacyOrgProxyTarget(pathname: string) { export function registerOrgRoutes(app: Hono) { registerOrgCoreRoutes(app) registerOrgApiKeyRoutes(app) + registerOrgScimRoutes(app) + registerOrgSsoRoutes(app) registerOrgInvitationRoutes(app) registerOrgLlmProviderRoutes(app) registerOrgMemberRoutes(app) diff --git a/ee/apps/den-api/src/routes/org/scim.ts b/ee/apps/den-api/src/routes/org/scim.ts new file mode 100644 index 000000000..349b44a71 --- /dev/null +++ b/ee/apps/den-api/src/routes/org/scim.ts @@ -0,0 +1,259 @@ +import type { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { z } from "zod" +import { deleteOrganizationScimConnection, getOrganizationScimConnection, getScimBaseUrl, rotateOrganizationScimToken } from "../../scim.js" +import { requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" +import type { OrgRouteVariables } from "./shared.js" +import { ensureScimManager } from "./shared.js" + +const invalidRequestSchema = z.object({ + error: z.literal("invalid_request"), + details: z.array(z.object({ + message: z.string(), + path: z.array(z.union([z.string(), z.number()])).optional(), + }).passthrough()), +}).meta({ ref: "ScimInvalidRequestError" }) + +const unauthorizedSchema = z.object({ + error: z.literal("unauthorized"), +}).meta({ ref: "ScimUnauthorizedError" }) + +const organizationNotFoundSchema = z.object({ + error: z.literal("organization_not_found"), +}).meta({ ref: "ScimOrganizationNotFoundError" }) + +const forbiddenSchema = z.object({ + error: z.literal("forbidden"), + message: z.string(), +}).meta({ ref: "ScimForbiddenError" }) + +const scimConnectionSchema = z.object({ + id: z.string(), + providerId: z.string(), + organizationId: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).meta({ ref: "OrganizationScimConnection" }) + +const scimConnectionResponseSchema = z.object({ + baseUrl: z.string().url(), + connection: scimConnectionSchema.nullable(), +}).meta({ ref: "OrganizationScimConnectionResponse" }) + +const rotateScimTokenResponseSchema = z.object({ + baseUrl: z.string().url(), + connection: scimConnectionSchema, + scimToken: z.string().min(1), +}).meta({ ref: "RotateOrganizationScimTokenResponse" }) + +function serializeConnection(connection: NonNullable>>) { + return { + id: connection.id, + providerId: connection.providerId, + organizationId: connection.organizationId, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + } +} + +export function registerOrgScimRoutes(app: Hono) { + app.get( + "/v1/scim", + describeRoute({ + tags: ["SCIM"], + summary: "Get organization SCIM connection", + description: "Returns the SCIM base URL and current connector metadata for the selected organization.", + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: "Organization SCIM configuration", + content: { + "application/json": { + schema: resolver(scimConnectionResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(invalidRequestSchema), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(unauthorizedSchema), + }, + }, + }, + 403: { + description: "Only workspace owners and admins can manage SCIM.", + content: { + "application/json": { + schema: resolver(forbiddenSchema), + }, + }, + }, + 404: { + description: "Organization not found", + content: { + "application/json": { + schema: resolver(organizationNotFoundSchema), + }, + }, + }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureScimManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationScimConnection(payload.organization.id) + + return c.json({ + baseUrl: getScimBaseUrl(), + connection: connection ? serializeConnection(connection) : null, + }) + }, + ) + + app.post( + "/v1/scim/token", + describeRoute({ + tags: ["SCIM"], + summary: "Create or rotate an organization SCIM token", + description: "Creates the organization SCIM connector if needed and returns a freshly rotated bearer token.", + hide: process.env.NODE_ENV === "production", + security: [{ bearerAuth: [] }], + responses: { + 201: { + description: "Organization SCIM token created", + content: { + "application/json": { + schema: resolver(rotateScimTokenResponseSchema), + }, + }, + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(invalidRequestSchema), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(unauthorizedSchema), + }, + }, + }, + 403: { + description: "Only workspace owners and admins can manage SCIM.", + content: { + "application/json": { + schema: resolver(forbiddenSchema), + }, + }, + }, + 404: { + description: "Organization not found", + content: { + "application/json": { + schema: resolver(organizationNotFoundSchema), + }, + }, + }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureScimManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const rotated = await rotateOrganizationScimToken({ + organizationId: payload.organization.id, + headers: c.req.raw.headers, + }) + + return c.json({ + baseUrl: getScimBaseUrl(), + connection: serializeConnection(rotated.connection), + scimToken: rotated.scimToken, + }, 201) + }, + ) + + app.delete( + "/v1/scim", + describeRoute({ + tags: ["SCIM"], + summary: "Delete an organization SCIM connection", + description: "Deletes the organization SCIM connection and invalidates the current bearer token.", + security: [{ bearerAuth: [] }], + responses: { + 204: { + description: "Organization SCIM connection deleted", + }, + 400: { + description: "Invalid request", + content: { + "application/json": { + schema: resolver(invalidRequestSchema), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver(unauthorizedSchema), + }, + }, + }, + 403: { + description: "Only workspace owners and admins can manage SCIM.", + content: { + "application/json": { + schema: resolver(forbiddenSchema), + }, + }, + }, + 404: { + description: "Organization not found", + content: { + "application/json": { + schema: resolver(organizationNotFoundSchema), + }, + }, + }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureScimManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + await deleteOrganizationScimConnection(payload.organization.id) + return c.body(null, 204) + }, + ) +} diff --git a/ee/apps/den-api/src/routes/org/shared.ts b/ee/apps/den-api/src/routes/org/shared.ts index c561d756a..cf2641ab1 100644 --- a/ee/apps/den-api/src/routes/org/shared.ts +++ b/ee/apps/den-api/src/routes/org/shared.ts @@ -11,6 +11,10 @@ export type OrgRouteVariables = & Partial & Partial +export const orgIdParamSchema = z.object({ + orgId: denTypeIdSchema("organization"), +}) + export function idParamSchema(key: K, typeName?: DenTypeIdName) { if (!typeName) { return z.object({ @@ -155,6 +159,54 @@ export function ensureApiKeyManager(c: { get: (key: "organizationContext") => Or } } +export function ensureScimManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) { + const payload = c.get("organizationContext") + if (!payload) { + return { + ok: false as const, + response: { + error: "organization_not_found", + }, + } + } + + if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) { + return { ok: true as const } + } + + return { + ok: false as const, + response: { + error: "forbidden", + message: "Only workspace owners and admins can manage SCIM.", + }, + } +} + +export function ensureSsoManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) { + const payload = c.get("organizationContext") + if (!payload) { + return { + ok: false as const, + response: { + error: "organization_not_found", + }, + } + } + + if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) { + return { ok: true as const } + } + + return { + ok: false as const, + response: { + error: "forbidden", + message: "Only workspace owners and admins can manage SSO.", + }, + } +} + export function createInvitationId() { return createDenTypeId("invitation") } diff --git a/ee/apps/den-api/src/routes/org/sso.ts b/ee/apps/den-api/src/routes/org/sso.ts new file mode 100644 index 000000000..78cd8c1e0 --- /dev/null +++ b/ee/apps/den-api/src/routes/org/sso.ts @@ -0,0 +1,439 @@ +import type { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { z } from "zod" +import { auth } from "../../auth.js" +import { env } from "../../env.js" +import { + deleteOrganizationSsoConnection, + getOrganizationSsoConnection, + getOrganizationSsoSignInPath, + getSsoAcsUrl, + getSsoMetadataUrl, + getSsoOidcRedirectUrl, + getSsoProviderForConnection, + registerOrganizationSsoConnection, +} from "../../sso.js" +import { requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" +import type { OrgRouteVariables } from "./shared.js" +import { ensureSsoManager } from "./shared.js" + +const invalidRequestSchema = z.object({ + error: z.literal("invalid_request"), + details: z.array(z.object({ + message: z.string(), + path: z.array(z.union([z.string(), z.number()])).optional(), + }).passthrough()), +}).meta({ ref: "SsoInvalidRequestError" }) + +const unauthorizedSchema = z.object({ + error: z.literal("unauthorized"), +}).meta({ ref: "SsoUnauthorizedError" }) + +const organizationNotFoundSchema = z.object({ + error: z.literal("organization_not_found"), +}).meta({ ref: "SsoOrganizationNotFoundError" }) + +const forbiddenSchema = z.object({ + error: z.literal("forbidden"), + message: z.string(), +}).meta({ ref: "SsoForbiddenError" }) + +const ssoConnectionSchema = z.object({ + id: z.string(), + providerId: z.string(), + kind: z.enum(["oidc", "saml"]), + issuer: z.string().url(), + domain: z.string(), + status: z.string(), + signInPath: z.string(), + signInUrl: z.string().url(), + redirectUrl: z.string().url(), + acsUrl: z.string().url().nullable(), + metadataUrl: z.string().url().nullable(), + domainVerified: z.boolean(), + lastTestedAt: z.string().datetime().nullable(), + lastError: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}).meta({ ref: "OrganizationSsoConnection" }) + +const ssoConnectionResponseSchema = z.object({ + connection: ssoConnectionSchema.nullable(), +}).meta({ ref: "OrganizationSsoConnectionResponse" }) + +const baseRegistrationSchema = z.object({ + issuer: z.string().url(), + domain: z.string().min(1), +}) + +const samlRegistrationSchema = baseRegistrationSchema.extend({ + entryPoint: z.string().url(), + cert: z.string().min(1), + audience: z.string().url().optional(), + wantAssertionsSigned: z.boolean().optional(), + authnRequestsSigned: z.boolean().optional(), +}).meta({ ref: "RegisterOrganizationSamlSsoBody" }) + +const oidcRegistrationSchema = baseRegistrationSchema.extend({ + clientId: z.string().min(1), + clientSecret: z.string().min(1), + scopes: z.array(z.string()).optional(), + skipDiscovery: z.boolean().optional(), + authorizationEndpoint: z.string().url().optional(), + tokenEndpoint: z.string().url().optional(), + jwksEndpoint: z.string().url().optional(), + userInfoEndpoint: z.string().url().optional(), +}).meta({ ref: "RegisterOrganizationOidcSsoBody" }) + +const metadataQuerySchema = z.object({ + format: z.enum(["xml", "json"]).default("xml"), +}).meta({ ref: "OrganizationSsoMetadataQuery" }) + +const domainVerificationResponseSchema = z.object({ + domainVerificationToken: z.string().min(1), +}).meta({ ref: "OrganizationSsoDomainVerificationResponse" }) + +function serializeConnection(input: { + connection: NonNullable>> + signInUrl: string + redirectUrl: string + acsUrl: string | null + metadataUrl: string | null + domainVerified: boolean +}) { + const { connection, signInUrl, redirectUrl, acsUrl, metadataUrl, domainVerified } = input + return { + id: connection.id, + providerId: connection.providerId, + kind: connection.kind === "saml" ? "saml" : "oidc", + issuer: connection.issuer, + domain: connection.domain, + status: connection.status, + signInPath: connection.signInPath, + signInUrl, + redirectUrl, + acsUrl, + metadataUrl, + domainVerified, + lastTestedAt: connection.lastTestedAt ? connection.lastTestedAt.toISOString() : null, + lastError: connection.lastError, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + } +} + +async function buildConnectionPayload(connection: NonNullable>>, origin: string) { + const provider = await getSsoProviderForConnection(connection) + const signInUrl = new URL(connection.signInPath || getOrganizationSsoSignInPath(""), env.betterAuthUrl).toString() + const redirectUrl = getSsoOidcRedirectUrl(connection.providerId) + const acsUrl = connection.kind === "saml" ? getSsoAcsUrl(connection.providerId) : null + const metadataUrl = connection.kind === "saml" ? getSsoMetadataUrl(connection.providerId) : null + return serializeConnection({ + connection, + signInUrl, + redirectUrl, + acsUrl, + metadataUrl, + domainVerified: provider?.domainVerified ?? false, + }) +} + +export function registerOrgSsoRoutes(app: Hono) { + app.get( + "/v1/sso", + describeRoute({ + tags: ["SSO"], + summary: "Get organization SSO connection", + description: "Returns the current organization SSO connection and setup URLs.", + security: [{ bearerAuth: [] }], + responses: { + 200: { description: "Organization SSO configuration", content: { "application/json": { schema: resolver(ssoConnectionResponseSchema) } } }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationSsoConnection(payload.organization.id) + if (!connection) { + return c.json({ connection: null }) + } + + return c.json({ + connection: await buildConnectionPayload(connection, c.req.url), + }) + }, + ) + + app.post( + "/v1/sso/saml", + describeRoute({ + tags: ["SSO"], + summary: "Register organization SAML SSO", + description: "Registers or replaces the active organization SAML SSO provider.", + security: [{ bearerAuth: [] }], + responses: { + 201: { description: "Organization SSO connection created", content: { "application/json": { schema: resolver(ssoConnectionResponseSchema) } } }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const parsed = samlRegistrationSchema.safeParse(await c.req.json()) + if (!parsed.success) { + return c.json({ + error: "invalid_request", + details: parsed.error.issues.map((issue) => ({ message: issue.message, path: issue.path })), + }, 400) + } + + const payload = c.get("organizationContext") + const connection = await registerOrganizationSsoConnection({ + kind: "saml", + organizationId: payload.organization.id, + organizationSlug: payload.organization.slug, + headers: c.req.raw.headers, + ...parsed.data, + }) + + return c.json({ connection: await buildConnectionPayload(connection, c.req.url) }, 201) + }, + ) + + app.post( + "/v1/sso/oidc", + describeRoute({ + tags: ["SSO"], + summary: "Register organization OIDC SSO", + description: "Registers or replaces the active organization OIDC SSO provider.", + security: [{ bearerAuth: [] }], + responses: { + 201: { description: "Organization SSO connection created", content: { "application/json": { schema: resolver(ssoConnectionResponseSchema) } } }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const parsed = oidcRegistrationSchema.safeParse(await c.req.json()) + if (!parsed.success) { + return c.json({ + error: "invalid_request", + details: parsed.error.issues.map((issue) => ({ message: issue.message, path: issue.path })), + }, 400) + } + + const payload = c.get("organizationContext") + const connection = await registerOrganizationSsoConnection({ + kind: "oidc", + organizationId: payload.organization.id, + organizationSlug: payload.organization.slug, + headers: c.req.raw.headers, + ...parsed.data, + }) + + return c.json({ connection: await buildConnectionPayload(connection, c.req.url) }, 201) + }, + ) + + app.delete( + "/v1/sso", + describeRoute({ + tags: ["SSO"], + summary: "Delete organization SSO connection", + description: "Deletes the active organization SSO connection.", + security: [{ bearerAuth: [] }], + responses: { + 204: { description: "Organization SSO connection deleted" }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + await deleteOrganizationSsoConnection(payload.organization.id) + return c.body(null, 204) + }, + ) + + app.get( + "/v1/sso/metadata", + describeRoute({ + tags: ["SSO"], + summary: "Get organization SAML SP metadata", + description: "Returns the generated Service Provider metadata for the current organization's SAML connection.", + security: [{ bearerAuth: [] }], + responses: { + 200: { description: "SAML metadata document" }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const parsed = metadataQuerySchema.safeParse(c.req.query()) + if (!parsed.success) { + return c.json({ + error: "invalid_request", + details: parsed.error.issues.map((issue) => ({ message: issue.message, path: issue.path })), + }, 400) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationSsoConnection(payload.organization.id) + if (!connection || connection.kind !== "saml") { + return c.json({ error: "organization_not_found" }, 404) + } + + const response = await auth.api.spMetadata({ + query: { + providerId: connection.providerId, + format: parsed.data.format, + }, + }) + + return response + }, + ) + + app.post( + "/v1/sso/request-domain-verification", + describeRoute({ + tags: ["SSO"], + summary: "Request an SSO domain verification token", + description: "Returns the DNS TXT verification token for the current organization's SSO provider.", + security: [{ bearerAuth: [] }], + responses: { + 201: { description: "Domain verification token returned", content: { "application/json": { schema: resolver(domainVerificationResponseSchema) } } }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationSsoConnection(payload.organization.id) + if (!connection) { + return c.json({ error: "organization_not_found" }, 404) + } + + let body: { domainVerificationToken?: string } | null = null + try { + body = await auth.api.requestDomainVerification({ + body: { providerId: connection.providerId }, + headers: c.req.raw.headers, + }) + } catch (error) { + return c.json({ + error: "invalid_request", + details: [{ message: error instanceof Error ? error.message : "Could not request a domain verification token." }], + }, 400) + } + + if (!body?.domainVerificationToken) { + return c.json({ + error: "invalid_request", + details: [{ message: "Could not request a domain verification token." }], + }, 400) + } + + return c.json({ domainVerificationToken: body.domainVerificationToken }, 201) + }, + ) + + app.post( + "/v1/sso/verify-domain", + describeRoute({ + tags: ["SSO"], + summary: "Verify the organization SSO domain", + description: "Checks the provider's DNS TXT record and marks the domain as verified when present.", + security: [{ bearerAuth: [] }], + responses: { + 204: { description: "Organization SSO domain verified" }, + 400: { description: "Invalid request", content: { "application/json": { schema: resolver(invalidRequestSchema) } } }, + 401: { description: "Unauthorized", content: { "application/json": { schema: resolver(unauthorizedSchema) } } }, + 403: { description: "Only workspace owners and admins can manage SSO.", content: { "application/json": { schema: resolver(forbiddenSchema) } } }, + 404: { description: "Organization not found", content: { "application/json": { schema: resolver(organizationNotFoundSchema) } } }, + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const access = ensureSsoManager(c) + if (!access.ok) { + return c.json(access.response, access.response.error === "forbidden" ? 403 : 404) + } + + const payload = c.get("organizationContext") + const connection = await getOrganizationSsoConnection(payload.organization.id) + if (!connection) { + return c.json({ error: "organization_not_found" }, 404) + } + + try { + await auth.api.verifyDomain({ + body: { providerId: connection.providerId }, + headers: c.req.raw.headers, + }) + } catch (error) { + return c.json({ + error: "invalid_request", + details: [{ message: error instanceof Error ? error.message : "Could not verify the SSO domain." }], + }, 400) + } + + return c.body(null, 204) + }, + ) +} diff --git a/ee/apps/den-api/src/scim.ts b/ee/apps/den-api/src/scim.ts new file mode 100644 index 000000000..2bc35c9d9 --- /dev/null +++ b/ee/apps/den-api/src/scim.ts @@ -0,0 +1,297 @@ +import { Buffer } from "node:buffer" +import { and, eq } from "@openwork-ee/den-db/drizzle" +import { AuthAccountTable, AuthUserTable, ExternalIdentityTable, MemberTable, ScimProviderTable } from "@openwork-ee/den-db/schema" +import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" +import { auth } from "./auth.js" +import { db } from "./db.js" +import { env } from "./env.js" +import { removeOrganizationMember } from "./orgs.js" + +type OrganizationId = typeof MemberTable.$inferSelect.organizationId +type UserId = typeof MemberTable.$inferSelect.userId + +type ScimUserResource = { + id?: unknown + externalId?: unknown + userName?: unknown + displayName?: unknown + name?: unknown + emails?: unknown + active?: unknown +} + +function decodeBase64Url(value: string) { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)) + return Buffer.from(`${normalized}${padding}`, "base64").toString("utf8") +} + +export function buildOrganizationScimProviderId(organizationId: OrganizationId) { + return `openwork-scim-${organizationId}` +} + +function maybeString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? value as Record : null +} + +function asArray(value: unknown): unknown[] | null { + return Array.isArray(value) ? value : null +} + +async function resolveScimProviderFromBearerToken(bearerToken: string) { + let decoded: string + try { + decoded = decodeBase64Url(bearerToken) + } catch { + return null + } + + const [rawToken, providerId, ...organizationParts] = decoded.split(":") + const organizationId = organizationParts.join(":") + if (!rawToken || !providerId || !organizationId) { + return null + } + + const providerRows = await db + .select() + .from(ScimProviderTable) + .where(and(eq(ScimProviderTable.providerId, providerId), eq(ScimProviderTable.organizationId, organizationId as OrganizationId))) + .limit(1) + + const provider = providerRows[0] ?? null + if (!provider || provider.scimToken !== rawToken) { + return null + } + + return provider +} + +export async function syncExternalIdentityFromScimResource(input: { + bearerToken: string + resource: ScimUserResource +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return false + } + + const userIdRaw = maybeString(input.resource.id) + if (!userIdRaw) { + return false + } + + let userId: UserId + try { + userId = normalizeDenTypeId("user", userIdRaw) + } catch { + return false + } + + const existingRows = await db + .select() + .from(ExternalIdentityTable) + .where(and(eq(ExternalIdentityTable.organizationId, provider.organizationId), eq(ExternalIdentityTable.userId, userId))) + .limit(1) + + const existing = existingRows[0] ?? null + const now = new Date() + const payload = { + organizationId: provider.organizationId, + userId, + source: existing?.ssoProviderId ? "scim+sso" : "scim", + scimProviderId: provider.providerId, + ssoProviderId: existing?.ssoProviderId ?? null, + remoteId: existing?.remoteId ?? null, + externalId: maybeString(input.resource.externalId), + userName: maybeString(input.resource.userName), + email: maybeString(asRecord(asArray(input.resource.emails)?.[0])?.value), + displayName: maybeString(input.resource.displayName) ?? maybeString(asRecord(input.resource.name)?.formatted), + nameJson: asRecord(input.resource.name), + emailsJson: asArray(input.resource.emails), + attributesJson: existing?.attributesJson ?? null, + active: input.resource.active === false ? false : true, + lastScimSyncAt: now, + lastSsoLoginAt: existing?.lastSsoLoginAt ?? null, + } + + if (existing) { + await db + .update(ExternalIdentityTable) + .set(payload) + .where(eq(ExternalIdentityTable.id, existing.id)) + return true + } + + await db.insert(ExternalIdentityTable).values({ + id: createDenTypeId("externalIdentity"), + ...payload, + }) + return true +} + +export async function syncExternalIdentityFromScimUserId(input: { + bearerToken: string + userId: UserId +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return false + } + + const userRows = await db + .select() + .from(AuthUserTable) + .where(eq(AuthUserTable.id, input.userId)) + .limit(1) + const user = userRows[0] ?? null + if (!user) { + return false + } + + const accountRows = await db + .select() + .from(AuthAccountTable) + .where(and(eq(AuthAccountTable.userId, input.userId), eq(AuthAccountTable.providerId, provider.providerId))) + .limit(1) + const account = accountRows[0] ?? null + + return syncExternalIdentityFromScimResource({ + bearerToken: input.bearerToken, + resource: { + id: user.id, + externalId: account?.accountId ?? null, + userName: user.email, + displayName: user.name, + name: { formatted: user.name }, + emails: [{ value: user.email, primary: true }], + active: true, + }, + }) +} + +export async function deactivateExternalIdentityForScimUser(input: { + bearerToken: string + userId: UserId +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return false + } + + const rows = await db + .select() + .from(ExternalIdentityTable) + .where(and(eq(ExternalIdentityTable.organizationId, provider.organizationId), eq(ExternalIdentityTable.userId, input.userId))) + .limit(1) + const existing = rows[0] ?? null + if (!existing) { + return false + } + + await db + .update(ExternalIdentityTable) + .set({ + active: false, + source: existing.ssoProviderId ? "scim+sso" : "scim", + scimProviderId: provider.providerId, + lastScimSyncAt: new Date(), + }) + .where(eq(ExternalIdentityTable.id, existing.id)) + return true +} + +export function getScimBaseUrl() { + return `${env.betterAuthUrl}/api/auth/scim/v2` +} + +export async function getOrganizationScimConnection(organizationId: OrganizationId) { + const rows = await db + .select() + .from(ScimProviderTable) + .where(eq(ScimProviderTable.organizationId, organizationId)) + .limit(1) + + return rows[0] ?? null +} + +export async function rotateOrganizationScimToken(input: { + organizationId: OrganizationId + headers: Headers +}) { + const existing = await getOrganizationScimConnection(input.organizationId) + const providerId = buildOrganizationScimProviderId(input.organizationId) + + if (existing && existing.providerId !== providerId) { + await db.delete(ScimProviderTable).where(eq(ScimProviderTable.id, existing.id)) + } + + const generated = await auth.api.generateSCIMToken({ + body: { + providerId, + organizationId: input.organizationId, + }, + headers: input.headers, + }) + + const connection = await getOrganizationScimConnection(input.organizationId) + if (!connection) { + throw new Error("SCIM connection was created, but could not be loaded.") + } + + return { + connection, + scimToken: generated.scimToken, + } +} + +export async function deleteOrganizationScimConnection(organizationId: OrganizationId) { + const connection = await getOrganizationScimConnection(organizationId) + if (!connection) { + return false + } + + await db.delete(ScimProviderTable).where(eq(ScimProviderTable.id, connection.id)) + return true +} + +export async function deleteScimProvisionedAccess(input: { + bearerToken: string + userId: UserId +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return { ok: false as const, status: 401, body: { detail: "Invalid SCIM token" } } + } + + const accountRows = await db + .select() + .from(AuthAccountTable) + .where(and(eq(AuthAccountTable.userId, input.userId), eq(AuthAccountTable.providerId, provider.providerId))) + .limit(1) + + const memberRows = await db + .select() + .from(MemberTable) + .where(and(eq(MemberTable.userId, input.userId), eq(MemberTable.organizationId, provider.organizationId))) + .limit(1) + + const account = accountRows[0] ?? null + const member = memberRows[0] ?? null + if (!account || !member) { + return { ok: false as const, status: 404, body: { detail: "User not found" } } + } + + await removeOrganizationMember({ + organizationId: provider.organizationId, + memberId: member.id, + }) + + await db.delete(AuthAccountTable).where(eq(AuthAccountTable.id, account.id)) + await deactivateExternalIdentityForScimUser({ bearerToken: input.bearerToken, userId: input.userId }) + + return { ok: true as const } +} diff --git a/ee/apps/den-api/src/sso.ts b/ee/apps/den-api/src/sso.ts new file mode 100644 index 000000000..1d09ff00c --- /dev/null +++ b/ee/apps/den-api/src/sso.ts @@ -0,0 +1,198 @@ +import { and, eq } from "@openwork-ee/den-db/drizzle" +import { SsoConnectionTable, SsoProviderTable } from "@openwork-ee/den-db/schema" +import { createDenTypeId } from "@openwork-ee/utils/typeid" +import { auth } from "./auth.js" +import { db } from "./db.js" +import { env } from "./env.js" + +type SsoConnection = typeof SsoConnectionTable.$inferSelect +type OrganizationId = SsoConnection["organizationId"] + +type SamlRegistrationInput = { + kind: "saml" + issuer: string + domain: string + entryPoint: string + cert: string + audience?: string | null + wantAssertionsSigned?: boolean | null + authnRequestsSigned?: boolean | null +} + +type OidcRegistrationInput = { + kind: "oidc" + issuer: string + domain: string + clientId: string + clientSecret: string + scopes?: string[] | null + skipDiscovery?: boolean | null + authorizationEndpoint?: string | null + tokenEndpoint?: string | null + jwksEndpoint?: string | null + userInfoEndpoint?: string | null +} + +export type OrganizationSsoRegistrationInput = (SamlRegistrationInput | OidcRegistrationInput) & { + organizationId: OrganizationId + organizationSlug: string + headers: Headers +} + +export function buildOrganizationSsoProviderId(organizationId: OrganizationId) { + return `openwork-sso-${organizationId}` +} + +export function getOrganizationSsoSignInPath(organizationSlug: string) { + return `/sso/${encodeURIComponent(organizationSlug)}` +} + +export function getSsoAcsUrl(providerId: string) { + return `${env.betterAuthUrl}/api/auth/sso/saml2/sp/acs/${encodeURIComponent(providerId)}` +} + +export function getSsoMetadataUrl(providerId: string) { + return `${env.betterAuthUrl}/api/auth/sso/saml2/sp/metadata?providerId=${encodeURIComponent(providerId)}` +} + +export function getSsoOidcRedirectUrl(providerId: string) { + return `${env.betterAuthUrl}/api/auth/sso/callback/${encodeURIComponent(providerId)}` +} + +export async function getOrganizationSsoConnection(organizationId: OrganizationId) { + const rows = await db + .select() + .from(SsoConnectionTable) + .where(eq(SsoConnectionTable.organizationId, organizationId)) + .limit(1) + + return rows[0] ?? null +} + +export async function deleteOrganizationSsoConnection(organizationId: OrganizationId) { + const connection = await getOrganizationSsoConnection(organizationId) + if (!connection) { + return false + } + + await db.delete(SsoConnectionTable).where(eq(SsoConnectionTable.id, connection.id)) + await db.delete(SsoProviderTable).where(eq(SsoProviderTable.providerId, connection.providerId)) + return true +} + +export async function registerOrganizationSsoConnection(input: OrganizationSsoRegistrationInput) { + const providerId = buildOrganizationSsoProviderId(input.organizationId) + await deleteOrganizationSsoConnection(input.organizationId) + + const common = { + providerId, + issuer: input.issuer, + domain: input.domain, + organizationId: input.organizationId, + } + + if (input.kind === "saml") { + await auth.api.registerSSOProvider({ + body: { + ...common, + samlConfig: { + entryPoint: input.entryPoint, + cert: input.cert, + callbackUrl: getSsoAcsUrl(providerId), + audience: input.audience || env.betterAuthUrl, + wantAssertionsSigned: input.wantAssertionsSigned ?? true, + authnRequestsSigned: input.authnRequestsSigned ?? false, + spMetadata: {}, + mapping: { + id: "nameID", + email: "email", + name: "displayName", + extraFields: { + department: "department", + role: "role", + groups: "groups", + }, + }, + }, + }, + headers: input.headers, + }) + } else { + await auth.api.registerSSOProvider({ + body: { + ...common, + oidcConfig: { + clientId: input.clientId, + clientSecret: input.clientSecret, + skipDiscovery: input.skipDiscovery ?? false, + authorizationEndpoint: input.authorizationEndpoint ?? undefined, + tokenEndpoint: input.tokenEndpoint ?? undefined, + jwksEndpoint: input.jwksEndpoint ?? undefined, + userInfoEndpoint: input.userInfoEndpoint ?? undefined, + scopes: input.scopes ?? ["openid", "email", "profile"], + pkce: true, + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "name", + image: "picture", + extraFields: { + department: "department", + role: "role", + groups: "groups", + }, + }, + }, + }, + headers: input.headers, + }) + } + + await db.insert(SsoConnectionTable).values({ + id: createDenTypeId("ssoConnection"), + organizationId: input.organizationId, + providerId, + kind: input.kind, + issuer: input.issuer, + domain: input.domain, + status: "enabled", + signInPath: getOrganizationSsoSignInPath(input.organizationSlug), + lastTestedAt: new Date(), + lastError: null, + }) + + const connection = await getOrganizationSsoConnection(input.organizationId) + if (!connection) { + throw new Error("SSO connection was created, but could not be loaded.") + } + + return connection +} + +export async function startOrganizationSsoSignIn(input: { + organizationSlug: string + callbackURL: string + loginHint?: string | null +}) { + return auth.api.signInSSO({ + body: { + organizationSlug: input.organizationSlug, + callbackURL: input.callbackURL, + loginHint: input.loginHint || undefined, + }, + }) +} + +export async function getSsoProviderForConnection(connection: SsoConnection) { + const rows = await db + .select() + .from(SsoProviderTable) + .where(and( + eq(SsoProviderTable.providerId, connection.providerId), + eq(SsoProviderTable.organizationId, connection.organizationId), + )) + .limit(1) + + return rows[0] ?? null +} diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts index 0eae106ba..345215cd1 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-org.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -105,6 +105,33 @@ export type DenOrgApiKey = { }; }; +export type DenOrgScimConnection = { + id: string; + providerId: string; + organizationId: string; + createdAt: string | null; + updatedAt: string | null; +}; + +export type DenOrgSsoConnection = { + id: string; + providerId: string; + kind: "oidc" | "saml"; + issuer: string; + domain: string; + status: string; + signInPath: string; + signInUrl: string; + redirectUrl: string; + acsUrl: string | null; + metadataUrl: string | null; + domainVerified: boolean; + lastTestedAt: string | null; + lastError: string | null; + createdAt: string | null; + updatedAt: string | null; +}; + export type DenOrgContext = { organization: { id: string; @@ -140,6 +167,7 @@ export type DenOrgContext = { export type DenOrganizationMetadata = { allowedDesktopVersions?: string[]; + requireSso?: boolean; } & Record; export const DEN_ROLE_PERMISSION_OPTIONS = { @@ -207,6 +235,11 @@ export function getAllowedDesktopVersionsFromMetadata(metadata: string | null): return [...new Set(values.map((entry) => normalizeDesktopVersionString(entry)).filter((entry): entry is string => Boolean(entry)))]; } +export function getRequireSsoFromMetadata(metadata: string | null): boolean { + const parsed = parseOrganizationMetadata(metadata); + return parsed?.requireSso === true; +} + function asDesktopAppRestrictions(value: unknown): DenDesktopAppRestrictions { return normalizeDesktopAppRestrictions(value); } @@ -246,6 +279,8 @@ export function getOrgAccessFlags(roleValue: string, isOwner: boolean) { canManageRoles: isOwner, canManageTeams: isAdmin, canManageApiKeys: isAdmin, + canManageScim: isAdmin, + canManageSso: isAdmin, }; } @@ -313,6 +348,14 @@ export function getApiKeysRoute(orgSlug?: string | null): string { return `${getOrgDashboardRoute(orgSlug)}/api-keys`; } +export function getScimRoute(orgSlug?: string | null): string { + return `${getOrgDashboardRoute(orgSlug)}/scim`; +} + +export function getSsoRoute(orgSlug?: string | null): string { + return `${getOrgDashboardRoute(orgSlug)}/sso`; +} + export function getSkillHubsRoute(orgSlug?: string | null): string { return `${getOrgDashboardRoute(orgSlug)}/skill-hubs`; } @@ -712,3 +755,88 @@ export function parseOrgApiKeysPayload(payload: unknown): DenOrgApiKey[] { }) .filter((entry): entry is DenOrgApiKey => entry !== null); } + +export function parseOrgScimPayload(payload: unknown): { + baseUrl: string | null; + connection: DenOrgScimConnection | null; + scimToken: string | null; +} { + if (!isRecord(payload)) { + return { baseUrl: null, connection: null, scimToken: null }; + } + + const rawConnection = isRecord(payload.connection) ? payload.connection : null; + const connection = rawConnection + ? (() => { + const id = asString(rawConnection.id); + const providerId = asString(rawConnection.providerId); + const organizationId = asString(rawConnection.organizationId); + + if (!id || !providerId || !organizationId) { + return null; + } + + return { + id, + providerId, + organizationId, + createdAt: asIsoString(rawConnection.createdAt), + updatedAt: asIsoString(rawConnection.updatedAt), + } satisfies DenOrgScimConnection; + })() + : null; + + return { + baseUrl: asString(payload.baseUrl), + connection, + scimToken: asString(payload.scimToken), + }; +} + +export function parseOrgSsoPayload(payload: unknown): { + connection: DenOrgSsoConnection | null; +} { + if (!isRecord(payload)) { + return { connection: null }; + } + + const rawConnection = isRecord(payload.connection) ? payload.connection : null; + const connection = rawConnection + ? (() => { + const id = asString(rawConnection.id); + const providerId = asString(rawConnection.providerId); + const kind = asString(rawConnection.kind); + const issuer = asString(rawConnection.issuer); + const domain = asString(rawConnection.domain); + const status = asString(rawConnection.status); + const signInPath = asString(rawConnection.signInPath); + const signInUrl = asString(rawConnection.signInUrl); + const redirectUrl = asString(rawConnection.redirectUrl); + + if (!id || !providerId || !issuer || !domain || !status || !signInPath || !signInUrl || !redirectUrl || (kind !== "oidc" && kind !== "saml")) { + return null; + } + + return { + id, + providerId, + kind, + issuer, + domain, + status, + signInPath, + signInUrl, + redirectUrl, + acsUrl: asString(rawConnection.acsUrl), + metadataUrl: asString(rawConnection.metadataUrl), + domainVerified: asBoolean(rawConnection.domainVerified), + lastTestedAt: asIsoString(rawConnection.lastTestedAt), + lastError: asString(rawConnection.lastError), + createdAt: asIsoString(rawConnection.createdAt), + updatedAt: asIsoString(rawConnection.updatedAt), + } satisfies DenOrgSsoConnection; + })() + : null; + + return { connection }; +} diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index f19e202cd..ff9b1f9a4 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -352,6 +352,31 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { setAuthError(null); } + async function redirectToRequiredSso(trimmedEmail: string) { + const { response, payload } = await requestJson(`/v1/orgs/sso/resolve?email=${encodeURIComponent(trimmedEmail)}`, { method: "GET" }, 12000); + + if (response.status === 204) { + return false; + } + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Could not resolve workspace SSO (${response.status}).`)); + } + + const signInUrl = typeof (payload as { signInUrl?: unknown } | null)?.signInUrl === "string" + ? (payload as { signInUrl: string }).signInUrl + : ""; + if (!signInUrl) { + return false; + } + + const nextUrl = new URL(signInUrl, window.location.origin); + nextUrl.searchParams.set("callbackURL", getSocialCallbackUrl()); + nextUrl.searchParams.set("loginHint", trimmedEmail); + window.location.assign(nextUrl.toString()); + return true; + } + async function finalizeEmailPasswordSignIn( nextMode: AuthMode, trimmedEmail: string, @@ -1068,6 +1093,9 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { try { const endpoint = authMode === "sign-up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"; const trimmedEmail = email.trim(); + if (trimmedEmail && await redirectToRequiredSso(trimmedEmail)) { + return null; + } const body = authMode === "sign-up" ? { @@ -1144,6 +1172,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { }); try { + const trimmedEmail = email.trim(); + if (trimmedEmail && await redirectToRequiredSso(trimmedEmail)) { + return; + } const callbackURL = getSocialCallbackUrl(); const { response, payload } = await requestJson("/api/auth/sign-in/social", { method: "POST", diff --git a/ee/apps/den-web/app/(den)/dashboard/scim/page.tsx b/ee/apps/den-web/app/(den)/dashboard/scim/page.tsx new file mode 100644 index 000000000..7252d0c83 --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/scim/page.tsx @@ -0,0 +1 @@ +export { default } from "../../o/[orgSlug]/dashboard/scim/page"; diff --git a/ee/apps/den-web/app/(den)/dashboard/sso/page.tsx b/ee/apps/den-web/app/(den)/dashboard/sso/page.tsx new file mode 100644 index 000000000..6b89b518d --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/sso/page.tsx @@ -0,0 +1 @@ +export { default } from "../../o/[orgSlug]/dashboard/sso/page"; diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx index 135e8cb65..a3d24a3a6 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx @@ -16,6 +16,7 @@ import { MessageSquare, Puzzle, Share2, + Shield, SlidersHorizontal, Store, Users, @@ -34,6 +35,8 @@ import { getOrgSettingsRoute, getMarketplacesRoute, getPluginsRoute, + getSsoRoute, + getScimRoute, getSharedSetupsRoute, getSkillHubsRoute, } from "../../../../_lib/den-org"; @@ -109,6 +112,12 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) { if (pathname.startsWith(getApiKeysRoute(orgSlug))) { return "API Keys"; } + if (pathname.startsWith(getScimRoute(orgSlug))) { + return "SCIM"; + } + if (pathname.startsWith(getSsoRoute(orgSlug))) { + return "SSO"; + } if (pathname.startsWith(getBackgroundAgentsRoute(orgSlug))) { return "Shared Workspaces"; } @@ -218,6 +227,20 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) { icon: KeyRound, }] : []), + ...(access.canManageScim + ? [{ + href: activeOrg ? getScimRoute(activeOrg.slug) : "#", + label: "SCIM", + icon: Shield, + }] + : []), + ...(access.canManageSso + ? [{ + href: activeOrg ? getSsoRoute(activeOrg.slug) : "#", + label: "SSO", + icon: Shield, + }] + : []), { href: activeOrg ? getBillingRoute(activeOrg.slug) : "/checkout", label: "Billing", diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-settings-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-settings-screen.tsx index 780dcf99a..dfa6d6076 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-settings-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-settings-screen.tsx @@ -3,7 +3,7 @@ import { Check, Copy, Pencil, SlidersHorizontal } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; -import { getAllowedDesktopVersionsFromMetadata } from "../../../../_lib/den-org"; +import { getAllowedDesktopVersionsFromMetadata, getRequireSsoFromMetadata } from "../../../../_lib/den-org"; import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; import { DenButton } from "../../../../_components/ui/button"; import { DenCard } from "../../../../_components/ui/card"; @@ -162,6 +162,7 @@ export function OrgSettingsScreen() { const [allowZenModelEnabled, setAllowZenModelEnabled] = useState(true); const [allowMultipleWorkspacesEnabled, setAllowMultipleWorkspacesEnabled] = useState(true); + const [requireSsoEnabled, setRequireSsoEnabled] = useState(false); const [domainEditModeEnabled, setDomainEditModeEnabled] = useState(false); const [desktopVersionOptions, setDesktopVersionOptions] = useState( [], @@ -228,6 +229,7 @@ export function OrgSettingsScreen() { orgContext.organization.desktopAppRestrictions.blockMultipleWorkspaces !== true, ); + setRequireSsoEnabled(getRequireSsoFromMetadata(orgContext.organization.metadata)); setDomainEditModeEnabled(false); }, [orgContext]); @@ -410,6 +412,7 @@ export function OrgSettingsScreen() { ), } : {}), + requireSso: requireSsoEnabled, }); setDomainEditModeEnabled(false); setPageSuccess("Workspace settings updated."); @@ -575,6 +578,35 @@ export function OrgSettingsScreen() { ) : null} + +
+

+ Authentication +

+

+ Single sign-on requirement +

+

+ Require members to use the workspace SSO entrypoint when their email domain matches this organization. +

+
+ +
+
+

Require SSO for matching domains

+

+ Email/password sign-in will redirect users to the org SSO flow when their email domain matches the configured SSO connection. +

+
+ +
+
+

diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/scim-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/scim-screen.tsx new file mode 100644 index 000000000..5d31ea44f --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/scim-screen.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { Copy, RefreshCw, Shield, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; +import { DenButton } from "../../../../_components/ui/button"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { + type DenOrgScimConnection, + getOrgAccessFlags, + parseOrgScimPayload, +} from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +function formatDateTime(value: string | null) { + if (!value) { + return "Not configured"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Not configured"; + } + + return date.toLocaleString(); +} + +export function ScimScreen() { + const { orgId, orgContext } = useOrgDashboard(); + const [baseUrl, setBaseUrl] = useState(null); + const [connection, setConnection] = useState(null); + const [visibleToken, setVisibleToken] = useState(null); + const [busy, setBusy] = useState(false); + const [rotating, setRotating] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + const [copiedValue, setCopiedValue] = useState<"base-url" | "token" | null>(null); + + const access = useMemo( + () => + getOrgAccessFlags( + orgContext?.currentMember.role ?? "member", + orgContext?.currentMember.isOwner ?? false, + ), + [orgContext?.currentMember.isOwner, orgContext?.currentMember.role], + ); + + async function loadScimConfig() { + if (!orgId || !access.canManageScim) { + setBaseUrl(null); + setConnection(null); + return; + } + + setBusy(true); + setError(null); + try { + const { response, payload } = await requestJson( + "/v1/scim", + { method: "GET" }, + 12000, + ); + + if (!response.ok) { + throw new Error( + getErrorMessage(payload, `Failed to load SCIM settings (${response.status}).`), + ); + } + + const parsed = parseOrgScimPayload(payload); + setBaseUrl(parsed.baseUrl); + setConnection(parsed.connection); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to load SCIM settings.", + ); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void loadScimConfig(); + }, [orgId, access.canManageScim]); + + useEffect(() => { + if (!copiedValue) { + return; + } + + const timeout = window.setTimeout(() => setCopiedValue(null), 1500); + return () => window.clearTimeout(timeout); + }, [copiedValue]); + + async function copyValue(value: string | null, kind: "base-url" | "token") { + if (!value) { + return; + } + + try { + await navigator.clipboard.writeText(value); + setCopiedValue(kind); + } catch { + setError(`Could not copy the ${kind === "token" ? "SCIM token" : "SCIM base URL"}.`); + } + } + + async function handleRotateToken() { + if (!orgId) { + setError("Organization not found."); + return; + } + + setRotating(true); + setError(null); + setVisibleToken(null); + try { + const { response, payload } = await requestJson( + "/v1/scim/token", + { method: "POST", body: JSON.stringify({}) }, + 12000, + ); + + if (!response.ok) { + throw new Error( + getErrorMessage(payload, `Failed to rotate SCIM token (${response.status}).`), + ); + } + + const parsed = parseOrgScimPayload(payload); + if (!parsed.baseUrl || !parsed.connection || !parsed.scimToken) { + throw new Error("SCIM token rotation succeeded, but the response was incomplete."); + } + + setBaseUrl(parsed.baseUrl); + setConnection(parsed.connection); + setVisibleToken(parsed.scimToken); + setCopiedValue(null); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to rotate SCIM token.", + ); + } finally { + setRotating(false); + } + } + + async function handleDeleteConnection() { + if ( + !orgId || + !window.confirm( + "Delete this SCIM connection? The current bearer token will stop working immediately.", + ) + ) { + return; + } + + setDeleting(true); + setError(null); + try { + const { response, payload } = await requestJson( + "/v1/scim", + { method: "DELETE" }, + 12000, + ); + + if (response.status !== 204 && !response.ok) { + throw new Error( + getErrorMessage(payload, `Failed to delete SCIM connection (${response.status}).`), + ); + } + + setConnection(null); + setVisibleToken(null); + setCopiedValue(null); + await loadScimConfig(); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to delete SCIM connection.", + ); + } finally { + setDeleting(false); + } + } + + if (!orgContext) { + return ( + +

+ Loading organization details... +
+ + ); + } + + return ( + + {!access.canManageScim ? ( +
+ Only organization owners and admins can manage SCIM. +
+ ) : ( + <> + {error ? ( +
+ {error} +
+ ) : null} + +
+
+
+

+ SCIM base URL +

+

+ Use this URL when your identity provider asks for the SCIM endpoint. +

+
+ void copyValue(baseUrl, "base-url")} + disabled={!baseUrl} + > + {copiedValue === "base-url" ? "Copied" : "Copy URL"} + +
+ +
+ + {baseUrl ?? (busy ? "Loading..." : "Not available")} + +
+
+ +
+
+
+

+ Connector token +

+

+ {connection + ? "Rotate the bearer token whenever your identity provider needs a fresh secret." + : "Create the workspace SCIM connector and generate its first bearer token."} +

+
+ +
+ void handleRotateToken()} loading={rotating}> + {connection ? "Rotate token" : "Create connector"} + + {connection ? ( + void handleDeleteConnection()} + loading={deleting} + > + Delete connector + + ) : null} +
+
+ +
+
+

+ Status +

+

+ {busy ? "Loading..." : connection ? "Connected" : "Not configured"} +

+

+ Last rotated {formatDateTime(connection?.updatedAt ?? null)} +

+ {connection ? ( +

+ Internal provider id: {connection.providerId} +

+ ) : null} +
+ +
+ SCIM deprovisioning removes workspace access and the SCIM provider account, but it does not blindly delete the global OpenWork user record. +
+
+ + {visibleToken ? ( +
+
+
+

+ Your SCIM bearer token is ready +

+

+ Copy it now. This value is only shown immediately after creation or rotation. +

+
+ void copyValue(visibleToken, "token")} + > + {copiedValue === "token" ? "Copied" : "Copy token"} + +
+ +
+ + {visibleToken} + +
+
+ ) : null} +
+ + )} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/sso-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/sso-screen.tsx new file mode 100644 index 000000000..e166968bf --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/sso-screen.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { Copy, KeyRound, RefreshCw, Shield, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; +import { DenButton } from "../../../../_components/ui/button"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { getOrgAccessFlags, parseOrgSsoPayload, type DenOrgSsoConnection } from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +function formatDateTime(value: string | null) { + if (!value) return "Not configured"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Not configured"; + return date.toLocaleString(); +} + +type FormMode = "saml" | "oidc"; + +export function SsoScreen() { + const { orgId, orgContext } = useOrgDashboard(); + const [connection, setConnection] = useState(null); + const [busy, setBusy] = useState(false); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + const [copiedValue, setCopiedValue] = useState(null); + const [domainVerificationToken, setDomainVerificationToken] = useState(null); + const [requestingDomainToken, setRequestingDomainToken] = useState(false); + const [verifyingDomain, setVerifyingDomain] = useState(false); + const [formMode, setFormMode] = useState("saml"); + const [issuer, setIssuer] = useState(""); + const [domain, setDomain] = useState(""); + const [entryPoint, setEntryPoint] = useState(""); + const [cert, setCert] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + + const access = useMemo( + () => getOrgAccessFlags(orgContext?.currentMember.role ?? "member", orgContext?.currentMember.isOwner ?? false), + [orgContext?.currentMember.isOwner, orgContext?.currentMember.role], + ); + + async function loadSsoConfig() { + if (!orgId || !access.canManageSso) { + setConnection(null); + return; + } + + setBusy(true); + setError(null); + try { + const { response, payload } = await requestJson("/v1/sso", { method: "GET" }, 12000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load SSO settings (${response.status}).`)); + } + + const parsed = parseOrgSsoPayload(payload); + setConnection(parsed.connection); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to load SSO settings."); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void loadSsoConfig(); + }, [orgId, access.canManageSso]); + + useEffect(() => { + if (!copiedValue) return; + const timeout = window.setTimeout(() => setCopiedValue(null), 1500); + return () => window.clearTimeout(timeout); + }, [copiedValue]); + + async function copyValue(value: string | null, key: string) { + if (!value) return; + try { + await navigator.clipboard.writeText(value); + setCopiedValue(key); + } catch { + setError("Could not copy that SSO value."); + } + } + + async function handleSave() { + if (!orgId) { + setError("Organization not found."); + return; + } + + setSaving(true); + setError(null); + try { + const path = formMode === "saml" ? "/v1/sso/saml" : "/v1/sso/oidc"; + const body = formMode === "saml" + ? { issuer, domain, entryPoint, cert } + : { issuer, domain, clientId, clientSecret }; + + const { response, payload } = await requestJson(path, { method: "POST", body: JSON.stringify(body) }, 20000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to save SSO settings (${response.status}).`)); + } + + const parsed = parseOrgSsoPayload(payload); + setConnection(parsed.connection); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to save SSO settings."); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!orgId || !window.confirm("Delete this SSO connection?")) { + return; + } + + setDeleting(true); + setError(null); + try { + const { response, payload } = await requestJson("/v1/sso", { method: "DELETE" }, 12000); + if (response.status !== 204 && !response.ok) { + throw new Error(getErrorMessage(payload, `Failed to delete SSO settings (${response.status}).`)); + } + setConnection(null); + await loadSsoConfig(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to delete SSO settings."); + } finally { + setDeleting(false); + } + } + + async function handleRequestDomainToken() { + if (!orgId || !connection) return; + setRequestingDomainToken(true); + setError(null); + try { + const { response, payload } = await requestJson("/v1/sso/request-domain-verification", { method: "POST", body: JSON.stringify({}) }, 12000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to request domain verification (${response.status}).`)); + } + + const token = typeof (payload as { domainVerificationToken?: unknown } | null)?.domainVerificationToken === "string" + ? (payload as { domainVerificationToken: string }).domainVerificationToken + : ""; + if (!token) { + throw new Error("SSO domain verification token was missing from the response."); + } + setDomainVerificationToken(token); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to request domain verification."); + } finally { + setRequestingDomainToken(false); + } + } + + async function handleVerifyDomain() { + if (!orgId || !connection) return; + setVerifyingDomain(true); + setError(null); + try { + const { response, payload } = await requestJson("/v1/sso/verify-domain", { method: "POST", body: JSON.stringify({}) }, 12000); + if (response.status !== 204 && !response.ok) { + throw new Error(getErrorMessage(payload, `Failed to verify domain (${response.status}).`)); + } + setDomainVerificationToken(null); + await loadSsoConfig(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to verify the SSO domain."); + } finally { + setVerifyingDomain(false); + } + } + + if (!orgContext) { + return ( + +
Loading organization details...
+
+ ); + } + + return ( + + {!access.canManageSso ? ( +
Only organization owners and admins can manage SSO.
+ ) : ( + <> + {error ?
{error}
: null} + +
+
+ setFormMode("saml")}>SAML + setFormMode("oidc")}>OIDC +
+ +
+ + + {formMode === "saml" ? ( + <> + +