# How to Set Up SSO Authentication with OpenWork Den Organization Management

> Learn how to set up SSO authentication with OpenWork Den organization management using Better Auth's SSO plugin. Integrate seamlessly and provision users automatically.

- Repository: [Different AI/openwork](https://github.com/different-ai/openwork)
- Tags: how-to-guide
- Published: 2026-08-15

---

**Use Better Auth's SSO plugin through OpenWork Den's `/v1/sso/*` API routes, which wrap provider registration, generate org-scoped sign-in URLs like `/sso/:orgSlug`, and automatically provision users via the `provisionUser` hook into a unified `external_identity` table.**

OpenWork Den provides enterprise-ready **SSO authentication** for organization management by layering its own API and identity logic on top of Better Auth's protocol handling. This architecture separates security-critical operations (SAML/OIDC flows) from org-specific business rules like domain verification, JIT provisioning, and SCIM integration. This guide walks you through the complete setup flow as implemented in the `different-ai/openwork` repository.

---

## Prerequisites

Before configuring SSO, ensure you have:

- **Organization admin access** to your Den organization
- **Better Auth SSO plugin** enabled (already configured in [`dev/ee/apps/den-api/src/auth.ts`](https://github.com/different-ai/openwork/blob/main/dev/ee/apps/den-api/src/auth.ts))
- **IdP credentials** from your identity provider (Microsoft Entra, Okta, Google Workspace, etc.)

The Den API guards all SSO operations—direct calls to Better Auth's `auth.api.registerSSOProvider` are blocked by the mutation denial list in [`auth.ts`](https://github.com/different-ai/openwork/blob/main/auth.ts).

---

## Step 1: Register an SSO Provider via Den API

Organization admins register providers through Den's REST API rather than calling Better Auth directly. This ensures the provider ID follows Den's naming convention and metadata is safely stored.

### Register an OIDC Provider

```bash
curl -X POST https://api.openworklabs.com/v1/sso/oidc \
  -H "Authorization: Bearer <org-admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0",
        "clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "clientSecret": "*****",
        "redirectUris": ["https://app.openworklabs.com/callback"],
        "organizationId": "org_acme_corp_id"
      }'

```

### Register a SAML Provider

```bash
curl -X POST https://api.openworklabs.com/v1/sso/saml \
  -H "Authorization: Bearer <org-admin-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "metadataUrl": "https://<tenant>.okta.com/app/.../sso/saml/metadata",
        "organizationId": "org_acme_corp_id"
      }'

```

Den generates a **deterministic provider ID** in the format `openwork-sso-<organizationId>`. This ID is used for all subsequent operations and callback routing.

---

## Step 2: Store Provider Metadata Securely

When you register a provider, Den performs two storage operations:

1. **Private Better Auth storage** — Raw protocol configuration (certificates, secrets) goes to Better Auth's internal `ssoProvider` table
2. **Public Den metadata** — Sanitized connection info persists in Den's `sso_connection` table, generated via the `ssoConnection` type ID

This split ensures sensitive credentials never leak through Den's API while still allowing org admins to view connection status and ACS URLs.

---

## Step 3: Configure Domain Verification

Enable **domain verification** to prevent unauthorized SSO attempts. Den surfaces Better Auth's verification token in the admin UI:

```bash
curl -H "Authorization: Bearer <org-admin-token>" \
  https://api.openworklabs.com/v1/sso

```

Response includes the verification token:

```json
{
  "providerId": "openwork-sso-org_acme_corp_id",
  "signInUrl": "https://app.openworklabs.com/sso/acme",
  "domainVerification": {
    "enabled": true,
    "token": "openwork-verification=abc123def456",
    "verifiedDomains": ["acme.com"]
  }
}

```

Add the verification token to your DNS TXT records to complete domain ownership proof.

---

## Step 4: Distribute the Org-Specific Sign-In URL

Each organization receives a **stable, branded sign-in URL**:

```

https://app.openworklabs.com/sso/:orgSlug

```

When a user visits this URL:

1. Den resolves the organization from `:orgSlug`
2. Verifies an enabled SSO connection exists
3. Calls `auth.api.signInSSO` with the org's slug and generated provider ID

Better Auth handles the actual IdP redirect. The callback route (`/api/auth/sso/callback/:providerId`) processes the protocol response before Den takes over for user provisioning.

---

## Step 5: Handle User Provisioning with the `provisionUser` Hook

Every successful SSO login triggers the **`provisionUser` hook** defined in [`dev/ee/apps/den-api/src/auth.ts`](https://github.com/different-ai/openwork/blob/main/dev/ee/apps/den-api/src/auth.ts). This hook creates or updates records in the unified `external_identity` table.

### Key Provisioning Logic

```ts
// Excerpt from dev/ee/apps/den-api/src/auth.ts
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid";

sso({
  provisionUser: async ({ user, userInfo, provider }) => {
    if (!provider.organizationId) return;
    
    const remoteId = pickRemoteIdentity(userInfo);
    const orgId = normalizeDenTypeId("organization", provider.organizationId);
    
    const payload = {
      organizationId: orgId,
      userId: normalizeDenTypeId("user", user.id),
      source: "sso",
      ssoProviderId: provider.providerId,
      remoteId,
      email: maybeString(userInfo.email) ?? maybeString(user.email),
      displayName: maybeString(userInfo.name) ?? maybeString(user.name),
      attributesJson: userInfo,
      active: true,
      lastSsoLoginAt: new Date(),
    };
    
    await db.insert(schema.ExternalIdentityTable).values({
      id: createDenTypeId("externalIdentity"),
      ...payload,
    }).onDuplicateKeyUpdate({
      set: {
        // Merge with existing SCIM data if present
        source: sql<string>`case when ${schema.ExternalIdentityTable.scimProviderId} is null then 'sso' else 'scim+sso' end`,
        ...payload,
      },
    });
  },
}),

```

The `source` field handles **SCIM and SSO integration**: when both are enabled, SCIM remains the source of truth for membership lifecycle while SSO provides just-in-time login and attribute refresh.

---

## Architecture Overview

| Layer | Responsibility | Key File |
|-------|---------------|----------|
| **Better Auth** | SAML/OIDC protocol handling, raw provider storage, authentication redirects | [`auth.ts`](https://github.com/different-ai/openwork/blob/main/auth.ts) SSO plugin config |
| **Den API** | Org access validation, provider ID generation (`openwork-sso-<orgId>`), sanitized metadata storage, admin endpoints | [`dev/ee/apps/den-api/src/auth.ts`](https://github.com/different-ai/openwork/blob/main/dev/ee/apps/den-api/src/auth.ts) |
| **Den UI** | SSO configuration page, ACS/redirect URL display, org-specific sign-in link presentation | `dev/packages/docs/cloud/sso-microsoft-entra.mdx` |
| **Identity Store** | Unified `external_identity` table combining SCIM and SSO attributes with policy-driven precedence | `provisionUser` hook implementation |

---

## Enabling SSO-Only Mode

Organizations can disable password and social login options:

```bash
curl -X PATCH https://api.openworklabs.com/v1/organizations/acme \
  -H "Authorization: Bearer <org-admin-token>" \
  -H "Content-Type: application/json" \
  -d '{"ssoOnly": true}'

```

When **SSO-only mode** is active, unauthenticated users are automatically redirected to the organization's SSO sign-in URL.

---

## Complete User Authentication Flow

```

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   User      │────▶│ /sso/acme   │────▶│  Den API    │
│  Browser    │     │  (Den UI)   │     │             │
└─────────────┘     └─────────────┘     └──────┬──────┘
                                               │
                                               ▼
                                        ┌─────────────┐
                                        │auth.api.    │
                                        │signInSSO()  │
                                        │(Better Auth)│
                                        └──────┬──────┘
                                               │
                                               ▼
                                        ┌─────────────┐
                                        │    IdP      │
                                        │  Redirect   │
                                        └──────┬──────┘
                                               │
                                               ▼
                                        ┌─────────────┐
                                        │/api/auth/   │
                                        │sso/callback │
                                        │(Better Auth)│
                                        └──────┬──────┘
                                               │
                                               ▼
                                        ┌─────────────┐
                                        │provisionUser│
                                        │   Hook      │──▶ external_identity
                                        │  (Den API)  │    table updated
                                        └─────────────┘

```

---

## Summary

- **Register providers via Den's `/v1/sso/*` API** — never call Better Auth directly due to mutation denials in [`auth.ts`](https://github.com/different-ai/openwork/blob/main/auth.ts)
- **Provider IDs follow the pattern `openwork-sso-<organizationId>`** — generated deterministically by Den
- **Use `/sso/:orgSlug` for user-facing sign-in** — Den resolves the org and initiates protocol flow
- **The `provisionUser` hook unifies SCIM and SSO identities** — stores records in `external_identity` with source-appropriate precedence
- **Enable SSO-only mode** to enforce organizational authentication policies

---

## Frequently Asked Questions

### Can I register multiple SSO providers for one organization?

**No.** OpenWork Den enforces **one active SSO provider per organization** by default. The provider ID is deterministically generated as `openwork-sso-<organizationId>`, ensuring a single configuration slot. If you need to migrate providers, delete the existing connection before registering a new one.

### How does Den handle the SSO callback from the identity provider?

Better Auth owns the protocol callback routes at `/api/auth/sso/callback/:providerId`, but Den intercepts the final redirect through the **`provisionUser` hook**. This hook creates or updates the user's `external_identity` record and then redirects to the organization's dashboard. The split ensures protocol correctness while allowing custom provisioning logic.

### What happens when both SCIM and SSO are enabled?

**SCIM remains the source of truth for membership lifecycle** — including provisioning and de-provisioning — while **SSO provides just-in-time login and attribute refresh**. The `provisionUser` hook detects existing SCIM records via the `scimProviderId` field and updates the `source` column to `'scim+sso'` to reflect the dual integration.

### Where are SSO credentials actually stored?

Raw protocol credentials (SAML certificates, OIDC client secrets) reside in **Better Auth's private `ssoProvider` table**, inaccessible through Den's API. Den maintains its own **`sso_connection` table** with sanitized metadata, verification tokens, and org relationships. This architecture prevents credential exposure while preserving operational visibility.