Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ee/apps/den-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*`

Expand Down
4 changes: 3 additions & 1 deletion ee/apps/den-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion ee/apps/den-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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." },
Expand Down
110 changes: 110 additions & 0 deletions ee/apps/den-api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string, unknown>) {
return (
maybeString(userInfo.sub) ??
maybeString(userInfo.id) ??
maybeString(userInfo.nameID) ??
maybeString(userInfo.nameId) ??
maybeString(userInfo.email)
);
}

function getInvitationOrigin() {
return (
env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ??
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions ee/apps/den-api/src/organization-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type OrganizationId = typeof OrganizationTable.$inferSelect.id
export type OrganizationMetadata = {
limits: OrganizationLimits
allowedDesktopVersions?: string[]
requireSso?: boolean
} & Record<string, unknown>

type OrganizationMetadataInput = Record<string, unknown> | string | null | undefined
Expand Down
17 changes: 12 additions & 5 deletions ee/apps/den-api/src/orgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -644,10 +645,16 @@ export async function updateOrganizationSettings(input: {
...normalizeOrganizationMetadata(existingOrganization.metadata).metadata,
} as Record<string, unknown>

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
Expand Down
4 changes: 3 additions & 1 deletion ee/apps/den-api/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { Variables: AuthContextVariables }>(app: Hono<T>) {
registerScimAuthRoutes(app)
app.on(
["GET", "POST"],
["GET", "POST", "PUT", "PATCH", "DELETE"],
"/api/auth/*",
describeRoute({
hide: true,
Expand Down
Loading
Loading