Dyrecteddyrected
Guides

Admin SSO

Configure SSO for the Dyrected Admin UI using external OIDC providers such as Okta, Azure AD, or Google.

Dyrected supports external Admin single sign-on (SSO) through adminAuth. This lets your editorial or operations team sign into the Admin UI with an OIDC (OpenID Connect, the standard identity layer built on OAuth) provider such as Okta, Azure AD, Google Workspace, or another standards-compliant identity system.

This guide is about Admin login, not your application's end-user auth. Your application can continue using its own auth collections and login flows independently.


What Admin SSO is for

Use Admin SSO when:

  • your team already signs into internal tools with an identity provider
  • you want Admin access governed separately from customer/member login
  • you need central control over who can access the dashboard
  • you want Just-in-Time provisioning or pre-approved Admin access rules

Do not use this guide for application-user login such as customers, members, or subscribers. That remains collection-level auth: true.


How the flow works

At a high level:

  1. The Admin UI discovers that adminAuth.mode is external.
  2. The user clicks the provider on the login screen.
  3. Dyrected redirects the browser to the OIDC provider.
  4. The provider redirects back to Dyrected at /api/admin/auth/:provider/callback.
  5. Dyrected verifies the identity claims.
  6. Dyrected resolves access using resolveAccess.
  7. Dyrected provisions or updates an Admin user in the configured Admin auth collection.
  8. Dyrected issues its own Admin bearer token and the dashboard continues normally.

This means the external provider controls identity, while Dyrected still controls its own Admin session and access rules.


Before you start

You need all of the following:

  1. A Dyrected app with an Admin UI route such as /admin
  2. An external identity provider that supports OpenID Connect
  3. An Admin auth collection in Dyrected
  4. A stable public callback URL reachable by the identity provider

The Admin auth collection is important. Even with external SSO, Dyrected still needs a collection to store the Admin-side user record and metadata such as:

  • email
  • name
  • roles
  • authProvider
  • externalSubject
  • lastLoginAt

Define an Admin auth collection

If you do not set adminAuth.collectionSlug, Dyrected prefers __admins. That is the cleanest default for most teams.

import { defineCollection } from '@dyrected/core'

export const Admins = defineCollection({
  slug: '__admins',
  auth: true,
  fields: [
    { name: 'name', label: 'Name', type: 'text' },
    {
      name: 'roles',
      label: 'Roles',
      type: 'multiSelect',
      options: ['admin', 'editor', 'publisher'],
    },
    { name: 'authProvider', label: 'Auth provider', type: 'text', admin: { hidden: true } },
    { name: 'externalSubject', label: 'External subject', type: 'text', admin: { hidden: true } },
    { name: 'lastLoginAt', label: 'Last login', type: 'datetime', admin: { hidden: true } },
  ],
})

If you are migrating from using a different Admin collection, read Separating Admin Auth first.


Configure adminAuth

Add an external Admin auth configuration to defineConfig.

import { defineConfig } from '@dyrected/core'
import { Admins } from './collections/admins'

export default defineConfig({
  collections: [Admins],
  globals: [],
  adminAuth: {
    mode: 'external',
    collectionSlug: '__admins',
    provisioningMode: 'jit_plus_membership_management',
    providers: [
      {
        id: 'okta',
        type: 'oidc',
        displayName: 'Okta',
        issuer: process.env.OKTA_ISSUER!,
        clientId: process.env.OKTA_CLIENT_ID!,
        clientSecret: process.env.OKTA_CLIENT_SECRET!,
        scopes: ['openid', 'profile', 'email'],
        autoRedirect: true,
      },
    ],
  },
})

Minimum OIDC provider fields:

  • id
  • type: 'oidc'
  • issuer
  • clientId
  • clientSecret

Optional but commonly useful:

  • displayName
  • redirectUri
  • scopes
  • claimMapping
  • autoRedirect

Register the callback URL in your identity provider

By default, Dyrected uses this callback pattern:

https://your-app.com/api/admin/auth/<providerId>/callback

Example:

https://cms.example.com/api/admin/auth/okta/callback

If you set redirectUri explicitly in the provider config, that exact value must be registered with the identity provider.

Important constraints:

  • the callback must be publicly reachable by the OIDC provider
  • the callback must resolve to the same Dyrected app that serves the Admin UI
  • in local development, use whatever callback URL your provider allows, or a local tunnel if necessary

Decide the provisioning mode

provisioningMode controls how Dyrected admits external identities into the Admin layer.

jit_only

Use this when:

  • anyone meeting resolveAccess rules should be created on first login
  • you do not want Dyrected to be the main source of truth for Admin membership

jit_plus_membership_management

Use this when:

  • you want first-login creation
  • you also want Dyrected-side membership records to exist and stay updated

This is the best default for most teams.

preprovisioned_only

Use this when:

  • accounts must be approved or created before first login
  • you cannot allow Dyrected to create new Admin users automatically

In this mode, a valid OIDC identity is still rejected if no matching Admin user has been provisioned already.


Map claims if your provider does not use the defaults

By default, Dyrected expects:

  • sub
  • email
  • name

If your provider stores roles or groups in custom claim names, map them explicitly.

adminAuth: {
  mode: 'external',
  collectionSlug: '__admins',
  provisioningMode: 'jit_plus_membership_management',
  providers: [
    {
      id: 'azure',
      type: 'oidc',
      displayName: 'Azure AD',
      issuer: process.env.AZURE_ISSUER!,
      clientId: process.env.AZURE_CLIENT_ID!,
      clientSecret: process.env.AZURE_CLIENT_SECRET!,
      claimMapping: {
        sub: 'sub',
        email: 'email',
        name: 'name',
        roles: 'roles',
        groups: 'groups',
      },
    },
  ],
}

Common use cases:

  • roles claim from the provider should become Dyrected roles
  • groups claim helps resolveAccess decide whether the user should be admitted
  • tenant- or site-specific claims can be passed through custom mapping keys such as siteIds or workspaceIds

Write resolveAccess

resolveAccess is the most important part of the setup. It decides whether the authenticated identity should be allowed into the Admin UI and what Dyrected-side roles or extra data should be stored.

If you do not supply resolveAccess, Dyrected allows the external identity by default after successful authentication. That is too open for most real deployments.

Start with an explicit function.

adminAuth: {
  mode: 'external',
  collectionSlug: '__admins',
  provisioningMode: 'jit_plus_membership_management',
  providers: [
    {
      id: 'okta',
      type: 'oidc',
      issuer: process.env.OKTA_ISSUER!,
      clientId: process.env.OKTA_CLIENT_ID!,
      clientSecret: process.env.OKTA_CLIENT_SECRET!,
      claimMapping: {
        roles: 'roles',
        groups: 'groups',
      },
    },
  ],
  resolveAccess: ({ identity }) => {
    const groups = identity.groups ?? []
    const isCmsAdmin = groups.includes('cms-admins')
    const isCmsEditor = groups.includes('cms-editors')

    if (!isCmsAdmin && !isCmsEditor) {
      return { allowed: false }
    }

    return {
      allowed: true,
      roles: isCmsAdmin ? ['admin'] : ['editor'],
      data: {
        department: identity.rawClaims?.department,
      },
    }
  },
}

Use resolveAccess for:

  • group- or role-based allow/deny logic
  • tenant- or site-aware restrictions
  • mapping external groups to Dyrected roles
  • attaching extra fields to the Dyrected Admin user document

resolveAccess receives:

  • identity
  • providerId
  • siteId
  • workspaceId
  • request metadata
  • the current user record if one already exists

Test the full login flow

Do not stop after saving the config. Test the whole redirect chain.

Expected start endpoint

When the login page begins the external flow, it hits:

GET /api/admin/auth/:provider/start

Example:

GET /api/admin/auth/okta/start

Expected callback endpoint

The OIDC provider should redirect back to:

GET /api/admin/auth/:provider/callback

Example:

GET /api/admin/auth/okta/callback

What success looks like

After a successful callback:

  • the browser returns to the Admin UI
  • Dyrected exchanges the external identity for a Dyrected Admin token
  • the Admin UI loads as the provisioned user
  • /api/collections/__admins/me or your configured Admin collection /me resolves successfully

Example: Okta setup

import { defineConfig } from '@dyrected/core'
import { Admins } from './collections/admins'

export default defineConfig({
  collections: [Admins],
  globals: [],
  adminAuth: {
    mode: 'external',
    collectionSlug: '__admins',
    provisioningMode: 'jit_plus_membership_management',
    providers: [
      {
        id: 'okta',
        type: 'oidc',
        displayName: 'Okta',
        issuer: process.env.OKTA_ISSUER!,
        clientId: process.env.OKTA_CLIENT_ID!,
        clientSecret: process.env.OKTA_CLIENT_SECRET!,
        scopes: ['openid', 'profile', 'email'],
        claimMapping: {
          roles: 'roles',
          groups: 'groups',
        },
      },
    ],
    resolveAccess: ({ identity }) => {
      const groups = identity.groups ?? []

      if (!groups.includes('cms-admins') && !groups.includes('cms-editors')) {
        return { allowed: false }
      }

      return {
        allowed: true,
        roles: groups.includes('cms-admins') ? ['admin'] : ['editor'],
      }
    },
  },
})

Provider-side checklist:

  1. Create an OIDC application in Okta.
  2. Set the callback URL to https://your-app.com/api/admin/auth/okta/callback.
  3. Allow openid, profile, and email scopes.
  4. Ensure the required groups or roles claims are present in the token or userinfo response.
  5. Store issuer, client ID, and client secret in environment variables.

The same pattern applies to Azure AD, Google Workspace, Auth0, and other OIDC providers.


Multi-tenant note

If your deployment uses onSchemaFetch, adminAuth can also be resolved per site.

That is useful when:

  • different sites have different identity providers
  • different sites need different access rules
  • a platform builder stores site-specific admin auth settings in a control plane

In that model:

  • your request must resolve the correct siteId
  • onSchemaFetch(siteId) can return tenant-specific adminAuth
  • your callback URL still points to the Dyrected app, but access resolution can vary by site

Local development tips

Local SSO is usually harder than production because identity providers often reject arbitrary localhost callback URLs.

Practical advice:

  1. Prefer a provider that allows http://localhost callback registration for development.
  2. If not, use a tunnel or development hostname.
  3. Keep local and production client credentials separate.
  4. Test both the initial redirect and the callback exchange, not just the login button.

If your provider requires an explicit redirect URI, set redirectUri in the Dyrected provider config so the generated flow matches exactly.


Troubleshooting


If you want a conservative setup that works for most teams:

  1. use __admins as the Admin collection
  2. set adminAuth.mode to external
  3. use one OIDC provider
  4. use jit_plus_membership_management
  5. map groups
  6. gate access in resolveAccess
  7. store Dyrected-side roles in the Admin collection

That gives you a clean split between:

  • external identity and group ownership
  • Dyrected-side Admin sessions and content permissions

On this page