Skip to content
Merged
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
23 changes: 22 additions & 1 deletion .devcontainer/start-daytona-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,19 @@ GRANT ALL PRIVILEGES ON openwork_den.* TO 'openwork_den'@'$COHOST_IP';
FLUSH PRIVILEGES;
SQL
else
# MySQL 8 snapshots create root@localhost with auth_socket, which ignores a
# password; name a password plugin so Den can connect over TCP. MariaDB keeps
# the plain form.
if "${MYSQL_ROOT_CMD[@]}" -N -e "SELECT VERSION()" 2>/dev/null | grep -qi mariadb; then
root_password_sql="ALTER USER 'root'@'localhost' IDENTIFIED BY 'password';"
else
root_password_sql="ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY 'password';"
fi
"${MYSQL_ROOT_CMD[@]}" -e "$root_password_sql"
# Socket authentication no longer applies once root has a password.
MYSQL_ROOT_CMD=(mysql -uroot -ppassword)
"${MYSQL_ROOT_CMD[@]}" <<'SQL'
CREATE DATABASE IF NOT EXISTS openwork_den;
ALTER USER 'root'@'localhost' IDENTIFIED BY 'password';
CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost' WITH GRANT OPTION;
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;
Expand All @@ -433,6 +443,17 @@ else
echo "==> Skipping pnpm install (node_modules present and lockfile unchanged)."
fi

# Next's own type declarations import "react" through pnpm's hidden hoist. A
# full install over the snapshot's filtered one can hoist the landing site's
# @types/react 18 there, and Den Web's type check then fails on ReactNode.
hoisted_react_types=node_modules/.pnpm/node_modules/@types/react
den_web_react_types="$(readlink -f ee/apps/den-web/node_modules/@types/react 2>/dev/null || true)"
if [ -n "$den_web_react_types" ] && [ -L "$hoisted_react_types" ] \
&& [ "$(readlink -f "$hoisted_react_types")" != "$den_web_react_types" ]; then
echo "==> Pointing hoisted @types/react at Den Web's version..."
ln -sfn "$den_web_react_types" "$hoisted_react_types"
fi

# Content keys for the sandbox-invariant builds baked into the snapshot.
# A build is skipped only when the git trees that feed it and the lockfile
# both match what the snapshot (or a previous boot) built. Snapshots without
Expand Down
123 changes: 123 additions & 0 deletions docs/features/multiple-sso-connections/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# SSO: several connections per organization

- Feature: `multipleSsoConnections`
- Deployments: cloud and self-hosted; default **off**
- Owner: `OmarMcAdam`
- Read on the organization that owns the connections.

## What changes

Off: an organization has one SSO connection with one email domain. The SSO page
edits that connection.

On:

- **Several connections.** The SSO page lists every connection with its state
(On, Verify domain, Not tested, Test failed, Ready to enable) and one row per
connection. **Add SSO connection** adds another; opening a row manages that
connection alone: edit, verify its domains, test, enable, disable, delete.
The other connections keep signing people in meanwhile.
- **Several domains per connection.** A connection lists one or more email
domains (`example.com, example.org`). One DNS TXT value verifies them all;
it is published at each domain's record name, and every domain must have it.
- **Several identity providers per domain.** Two connections may cover the same
domain (for example Okta for employees and Microsoft Entra ID after an
acquisition). A connection whose domains another connection of the
organization already verified needs no new DNS record.
- **A name per connection** ("Okta"), shown on sign-in buttons. Without one,
Den names it after its identity provider (Okta, Microsoft Entra ID, Google
Workspace, ...) or its issuer's host. Renaming never disables a connection.
- **Sign-in shows one button per identity provider** for the email:
`GET /v1/auth/login-options` returns `ssoProviders` (enabled connections on a
verified domain that cover the email's domain, oldest first), and Den Web
shows "Continue with Okta", "Continue with Microsoft Entra ID". The
organization's SSO link (`/sso/<slug>`) does the same from
`GET /v1/auth/sso-options`. With one provider both go straight to it, as
before.
- **Any of the organization's providers counts** for "Require SSO" and "Ask for
SSO again": SSO proof is recorded per organization, whichever connection
signed the person in.

![Every connection with its state](./connections-list.png)

![One connection](./connection-detail.png)

![A connection waiting for its DNS check](./connection-verify-domain.png)

![Adding a connection](./add-connection.png)

![Sign-in with two identity providers for the domain](./sign-in-providers.png)

![The organization's SSO link with two identity providers](./sso-page-picker.png)

## API

New, behind the flag (404 `feature_disabled` when off):

| Route | Does |
|---|---|
| `GET /v1/sso/connections` | Every connection, oldest first |
| `POST /v1/sso/connections/saml` · `/oidc` | Add a connection (`name`, `domains`) |
| `GET` · `PATCH` (`{ name }`) · `DELETE /v1/sso/connections/:id` | Read, rename, delete one |
| `POST /v1/sso/connections/:id/saml` · `/oidc` | Replace one's configuration |
| `POST /v1/sso/connections/:id/test` · `/enable` · `/disable` | Lifecycle of one |
| `POST /v1/sso/connections/:id/request-domain-verification` · `/verify-domain`, `GET .../metadata` | Domain proof, SAML SP metadata |

Public: `GET /v1/auth/sso-options?organizationSlug=` (rate limited per IP).

Unchanged: `/v1/sso/*` keeps managing the organization's **oldest**
connection, so older clients keep working. Test start and cancel stay keyed by
the test's own id (`/v1/sso/test/:intentId/*`).

## How it is built

- `sso_connection.organization_id` is no longer unique (migration
`0183_multiple_sso_connections`, which also adds `name`). The new
organization index is created before the unique one is dropped.
- The first connection keeps the provider id `openwork-sso-<organizationId>`.
A connection added next to others uses its own id (`ssc_...`) as its Better
Auth provider id: the organization-derived id plus a suffix would exceed the
63-character DNS label Better Auth uses for verification records.
- Domains are stored lower case, comma separated, on `sso_connection.domain` and
the provider's `domain` (Better Auth's multi-domain format, which its sign-in,
email checks and DNS verification already read). Den matches them with
`FIND_IN_SET` as exact domains, as before.
- A Better Auth `/sign-in/sso` that names only an organization, a domain or an
email gets the provider Den chose added (`providerId`): the oldest ready
connection, preferring one that covers the email's domain. Better Auth alone
would pick any connection of the organization, including a disabled one.
- Audit: adding, replacing, enabling, disabling and deleting a connection emit
the existing `sso_connection.*` events per connection; renaming emits
`sso_connection.renamed`. Connection snapshots now include `name`.

## How to try it

1. Platform admin › Organizations › the organization › Features: turn on
**SSO: several connections per organization**.
2. Settings › SSO: **Add SSO connection** twice, for two identity providers on
the same domain. Verify, test and enable each.
3. Sign out and enter an email on the domain: one button per identity
provider. Open `/sso/<organization slug>`: the same choice.

## Turning it off

The kill switch or the organization override hides the connection list and the
connection routes on the next request. Nothing is deleted:

- Every enabled connection **keeps signing people in**: sign-in, login options
and `/sso/<slug>` read what is configured, not the flag, so nobody is locked
out.
- The SSO page goes back to the single-connection screen, which manages the
oldest connection. The others can only be managed again with the flag on
(or deleted by an operator).
- Names and multi-domain values stay stored and keep working.

## What to watch

- 404 `feature_disabled` on `/v1/sso/connections*` for organizations that
should have the flag.
- `/v1/auth/sso-options` 429s (limit 120 a minute per client address): the SSO
link then falls back to the organization's oldest ready connection.
- Support reports of a sign-in going to the wrong identity provider: check
which connections cover the email's domain (`sso_connection.domain`) and which
are enabled and verified.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions ee/apps/den-api/src/audit/domain/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const domainChangeEmitters: Readonly<Record<string, string>> = {
"member.role_updated": "audit/domain/members.ts", "member.ownership_transferred": "audit/domain/members.ts", "member.removed": "audit/domain/members.ts",
"scim_connection.token_rotated": "audit/domain/scim.ts", "scim_connection.deleted": "audit/domain/scim.ts", "scim_connection.reconciled": "audit/domain/scim.ts", "scim_group_mapping.updated": "audit/domain/scim.ts",
"sso_connection.registered": "audit/domain/sso.ts", "sso_connection.enabled": "audit/domain/sso.ts", "sso_connection.disabled": "audit/domain/sso.ts", "sso_connection.deleted": "audit/domain/sso.ts",
"sso_connection.renamed": "audit/domain/sso.ts",
"organization.complimentary_access.granted": "audit/domain/organization-settings.ts", "organization.complimentary_access.revoked": "audit/domain/organization-settings.ts",
"organization.dpa_signed.updated": "audit/domain/organization-settings.ts",
"web_origin.approved": "audit/domain/web-origins.ts", "web_origin.removed": "audit/domain/web-origins.ts",
Expand Down
12 changes: 10 additions & 2 deletions ee/apps/den-api/src/audit/domain/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import { auditChangeEvent, auditText, auditTime, organizationParent, targetResou
// A re-registration replaces the IdP configuration: recorded as the opaque
// "configuration" marker, never its contents.

export type SsoConnectionAuditRow = Pick<typeof SsoConnectionTable.$inferSelect, "id" | "providerId" | "kind" | "issuer" | "domain" | "status" | "signInPath" | "testStatus" | "lastTestedAt" | "createdAt">
export type SsoConnectionAuditRow = Pick<typeof SsoConnectionTable.$inferSelect, "id" | "providerId" | "name" | "kind" | "issuer" | "domain" | "status" | "signInPath" | "testStatus" | "lastTestedAt" | "createdAt">
export type SsoProviderAuditRow = Pick<typeof SsoProviderTable.$inferSelect, "domainVerified">

export function serializeSsoConnection(row: SsoConnectionAuditRow, provider: SsoProviderAuditRow | null): AuditSnapshot {
return {
id: row.id, providerId: auditText(row.providerId), kind: auditText(row.kind, 16), issuer: auditText(row.issuer, 2048), domain: auditText(row.domain),
id: row.id, providerId: auditText(row.providerId), name: auditText(row.name, 120), kind: auditText(row.kind, 16), issuer: auditText(row.issuer, 2048), domain: auditText(row.domain),
status: auditText(row.status, 32), signInPath: auditText(row.signInPath, 2048), testStatus: auditText(row.testStatus, 32),
lastTestedAt: auditTime(row.lastTestedAt), domainVerified: provider ? provider.domainVerified : null, createdAt: auditTime(row.createdAt),
}
Expand Down Expand Up @@ -45,3 +45,11 @@ export function ssoConnectionStatusEvent(organizationId: string, before: SsoConn
export function ssoConnectionDeletedEvent(organizationId: string, before: SsoConnectionAuditState): AuditChangeEventInput | null {
return auditChangeEvent({ action: "sso_connection.deleted", resources: resources(organizationId, before.connection), before: serializeSsoConnection(before.connection, before.provider), after: null })
}

/** multipleSsoConnections: a connection's display name changed (its provider is untouched). */
export function ssoConnectionRenamedEvent(organizationId: string, before: SsoConnectionAuditState, after: SsoConnectionAuditState): AuditChangeEventInput | null {
return auditChangeEvent({
action: "sso_connection.renamed", resources: resources(organizationId, after.connection),
before: serializeSsoConnection(before.connection, before.provider), after: serializeSsoConnection(after.connection, after.provider),
})
}
15 changes: 15 additions & 0 deletions ee/apps/den-api/src/audit/routes/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ export const orgAuditRoutes: readonly AuditRouteDeclaration[] = [
member("POST", "/v1/sso/test", "tenant_change", "sso_test.create", "sso.configuration", "sso_test_intent", null),
member("POST", "/v1/sso/test/:intentId/start", "tenant_external", "sso_test.start", "sso.configuration", "sso_test_intent", "intentId", { external: "sso_idp (better-auth signInSSO; OIDC discovery)" }),
member("POST", "/v1/sso/test/:intentId/cancel", "tenant_change", "sso_test.cancel", "sso.configuration", "sso_test_intent", "intentId"),
// multipleSsoConnections: the same actions on one connection by id.
member("GET", "/v1/sso/connections", "tenant_read", "sso_connection.list", "sso.configuration", "sso_connection", null),
member("GET", "/v1/sso/connections/:connectionId", "tenant_read", "sso_connection.read_one", "sso.configuration", "sso_connection", "connectionId"),
member("POST", "/v1/sso/connections/saml", "tenant_change", "sso_connection.saml.add", "sso.configuration", "sso_connection", null, { changeEvidence: DOMAIN("sso"), notes: "Legacy organization.sso.connection_registered (kind saml); better-auth registerSSOProvider write for a new connection." }),
member("POST", "/v1/sso/connections/oidc", "tenant_external", "sso_connection.oidc.add", "sso.configuration", "sso_connection", null, { changeEvidence: DOMAIN("sso"), external: "oidc_idp (issuer discovery)", notes: "Legacy organization.sso.connection_registered (kind oidc); better-auth registerSSOProvider write for a new connection." }),
member("POST", "/v1/sso/connections/:connectionId/saml", "tenant_change", "sso_connection.saml.replace", "sso.configuration", "sso_connection", "connectionId", { changeEvidence: DOMAIN("sso"), notes: "Legacy organization.sso.connection_registered (kind saml)." }),
member("POST", "/v1/sso/connections/:connectionId/oidc", "tenant_external", "sso_connection.oidc.replace", "sso.configuration", "sso_connection", "connectionId", { changeEvidence: DOMAIN("sso"), external: "oidc_idp (issuer discovery)", notes: "Legacy organization.sso.connection_registered (kind oidc)." }),
member("PATCH", "/v1/sso/connections/:connectionId", "tenant_change", "sso_connection.rename", "sso.configuration", "sso_connection", "connectionId", { changeEvidence: DOMAIN("sso"), notes: "sso_connection.renamed; no legacy action." }),
member("DELETE", "/v1/sso/connections/:connectionId", "tenant_change", "sso_connection.delete_one", "sso.configuration", "sso_connection", "connectionId", { changeEvidence: DOMAIN("sso"), notes: "Legacy organization.sso.connection_deleted." }),
member("POST", "/v1/sso/connections/:connectionId/test", "tenant_change", "sso_test.create_one", "sso.configuration", "sso_test_intent", null),
member("POST", "/v1/sso/connections/:connectionId/enable", "tenant_change", "sso_connection.enable_one", "sso.configuration", "sso_connection", "connectionId", { changeEvidence: DOMAIN("sso"), notes: "Legacy organization.sso.connection_enabled." }),
member("POST", "/v1/sso/connections/:connectionId/disable", "tenant_change", "sso_connection.disable_one", "sso.configuration", "sso_connection", "connectionId", { changeEvidence: DOMAIN("sso"), notes: "Legacy organization.sso.connection_disabled." }),
member("GET", "/v1/sso/connections/:connectionId/metadata", "tenant_read", "sso_connection.metadata.read_one", "sso.configuration", "sso_connection", "connectionId"),
member("POST", "/v1/sso/connections/:connectionId/request-domain-verification", "tenant_change", "sso_domain.verification.request_one", "sso.configuration", "sso_connection", "connectionId", { notes: "Returns the DNS TXT verification value (not a credential)." }),
member("POST", "/v1/sso/connections/:connectionId/verify-domain", "tenant_external", "sso_domain.verify_one", "sso.configuration", "sso_connection", "connectionId", { external: "dns (TXT lookup)" }),

// Billing
member("GET", "/v1/billing", "tenant_access", "billing.read", "billing.management", "billing", null, { notes: "includePortalUrl creates a Stripe billing portal session and returns its URL (billing.manage)." }),
Expand Down
1 change: 1 addition & 0 deletions ee/apps/den-api/src/audit/routes/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const platformAuditRoutes: readonly AuditRouteDeclaration[] = [

// Auth helpers
platform("GET", "/v1/auth/login-options", "auth.login_options.read", "platform.auth", "login_options", null, "Public, bot-protected; reveals account sign-in methods."),
platform("GET", "/v1/auth/sso-options", "auth.sso_options.read", "platform.auth", "login_options", null, "Public, rate limited; lists an organization's enabled SSO identity providers for its /sso/<slug> page."),
platform("GET", "/v1/orgs/sso/resolve", "auth.sso.resolve", "platform.auth", "login_options", null, "Public, bot-protected email -> SSO routing."),
handler("GET", "/v1/orgs/invitations/preview", "tenant_read", "invitation.preview", "invitation.read", "invitation", "Public preview by invitation id/token. The token proves the invitation's organization: once the invitation is found the handler attributes the served read there (category read, off by default), actor unknown. The token is never recorded (no resource id). Unknown tokens (404) stay platform evidence."),
platform("POST", "/v1/auth/bootstrap/verify", "auth.bootstrap.verify", "platform.auth", "deployment", null, "One-time deployment bootstrap code."),
Expand Down
10 changes: 10 additions & 0 deletions ee/apps/den-api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,10 @@ async function denyBetterAuthTeamMutation() {
throw new APIError("FORBIDDEN", { message: "Use the Den teams API to manage teams and their membership." });
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function readStringProperty(value: unknown, propertyName: string) {
if (!value || typeof value !== "object") {
return null;
Expand Down Expand Up @@ -1310,6 +1314,12 @@ export const auth = betterAuth({
if (!authorization.ok) {
throw new APIError("FORBIDDEN", { message: authorization.message });
}
// An organization or domain can match several connections
// (multipleSsoConnections). Better Auth would take any one of them,
// so name the provider Den chose: the ready one.
if (authorization.providerId && !readStringProperty(ctx.body, "providerId") && isRecord(ctx.body)) {
return { context: { body: { ...ctx.body, providerId: authorization.providerId } } };
}
}
}

Expand Down
5 changes: 3 additions & 2 deletions ee/apps/den-api/src/enterprise-auth-requirement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SsoProviderTable,
} from "@openwork-ee/den-db/schema"
import { db } from "./db.js"
import { ssoDomainColumnCovers } from "./sso-domains.js"

type EnterpriseAuthRequirementRow = {
organizationId: string
Expand Down Expand Up @@ -75,12 +76,12 @@ export async function findEnterpriseAuthRequirementForEmailDomain(email: string)
.innerJoin(SsoConnectionTable, and(
eq(OrganizationTable.id, SsoConnectionTable.organizationId),
eq(SsoConnectionTable.status, "enabled"),
eq(SsoConnectionTable.domain, domain),
ssoDomainColumnCovers(SsoConnectionTable.domain, domain),
))
.innerJoin(SsoProviderTable, and(
eq(SsoConnectionTable.providerId, SsoProviderTable.providerId),
eq(OrganizationTable.id, SsoProviderTable.organizationId),
eq(SsoProviderTable.domain, domain),
ssoDomainColumnCovers(SsoProviderTable.domain, domain),
eq(SsoProviderTable.domainVerified, true),
))

Expand Down
Loading
Loading