Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Writing an SSO provider

Identity plugin @orthacms/identity-server@orthacms/identity-admin

Reaching an identity provider Ortha ships no adapter for.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A new identity provider is one factory returning an SsoProvider, plus one spec that runs the conformance kit.

Check first: the OIDC adapter reaches every OpenID Connect provider. Write a package only when the wire differs, as GitHub and SAML do.

The port

From @orthacms/identity-domain, which has no dependencies at all:

interface SsoProvider {
    descriptor(): SsoProviderDescriptor;
    authorize(request: SsoAuthorizeRequest): Promise<SsoAuthorizeRedirect>;
    complete(callback: SsoCallback): Promise<SsoProfile>;
    logoutUrl?(request: SsoLogoutRequest): string | null;
    verifyLogoutToken?(token: string): Promise<SsoLogoutNotice>;
}

interface SsoProviderDescriptor {
    kind: 'oidc' | 'oauth2' | 'saml';
    label: string;                          // the sign-in button
    callbackMethod: 'GET' | 'POST';         // POST mounts the form-post callback
}

interface SsoAuthorizeRequest {             // everything here is minted by the core
    redirectUri: string;
    state: string;
    nonce: string;
    codeVerifier: string;                   // send only its S256 challenge
    scopes?: readonly string[];
}
interface SsoAuthorizeRedirect { url: string }

interface SsoCallback {
    params: Readonly<Record<string, string>>;  // query (GET) or form (POST)
    state: string;                             // what this attempt stored
    nonce: string;
    codeVerifier: string;
    redirectUri: string;
}

interface SsoProfile {
    subject: string;          // stable, issuer-scoped, never the email
    email: string;
    emailVerified: boolean;   // what the provider claimed — gates account linking
    name?: string | null;
    groups?: readonly string[];
    sessionId?: string | null;
}

interface SsoLogoutRequest { returnTo: string; sessionId?: string | null }
interface SsoLogoutNotice { sessionId?: string | null; subject?: string | null }

Throw SsoVerificationError(reason) for every refusal. The person always sees the same sign-in error; the reason goes to the log as SSO sign-in refused: <reason>.

The contract

  1. complete verifies, or throws SsoVerificationError. Tampered response, wrong state/nonce and the provider’s own error all reject.
  2. subject is stable and never the email.
  3. emailVerified reports what the provider claimed, as a boolean.
  4. authorize carries the core’s state and never the code verifier.
  5. descriptor() is stable and has a label.
  6. Implement verifyLogoutToken only if you can verify. Without it the back-channel route answers 400 invalid_request.

A complete provider

For a hypothetical OAuth2 provider “Acme” with PKCE and a userinfo endpoint:

// packages/identity-provider-acme/src/acme-provider.ts
import { createHash } from 'node:crypto';
import {
    SsoVerificationError,
    type SsoAuthorizeRedirect,
    type SsoAuthorizeRequest,
    type SsoCallback,
    type SsoProfile,
    type SsoProvider,
    type SsoProviderDescriptor
} from '@orthacms/identity-domain';

export interface AcmeProviderConfig {
    baseUrl: string;              // e.g. https://login.acme.example
    clientId: string;
    clientSecret: string;
    label?: string;
    scopes?: readonly string[];
    fetch?: typeof globalThis.fetch;
}

interface AcmeUser {
    id: number;
    email: string;
    email_verified: boolean;
    name?: string;
    groups?: string[];
}

export function createAcmeProvider(config: AcmeProviderConfig): SsoProvider {
    const base = config.baseUrl.replace(/\/+$/, '');
    const doFetch = config.fetch ?? globalThis.fetch;
    const descriptor: SsoProviderDescriptor = Object.freeze({
        kind: 'oauth2',
        label: config.label ?? 'Acme',
        callbackMethod: 'GET'
    });

    return {
        descriptor: () => descriptor,

        async authorize(request: SsoAuthorizeRequest): Promise<SsoAuthorizeRedirect> {
            const url = new URL(`${base}/oauth/authorize`);
            url.searchParams.set('response_type', 'code');
            url.searchParams.set('client_id', config.clientId);
            url.searchParams.set('redirect_uri', request.redirectUri);
            url.searchParams.set('scope', (request.scopes ?? config.scopes ?? ['profile', 'email']).join(' '));
            url.searchParams.set('state', request.state);
            // The challenge, never the verifier.
            url.searchParams.set(
                'code_challenge',
                createHash('sha256').update(request.codeVerifier).digest('base64url')
            );
            url.searchParams.set('code_challenge_method', 'S256');
            return { url: url.toString() };
        },

        async complete(callback: SsoCallback): Promise<SsoProfile> {
            const { params } = callback;
            if (params['error']) {
                throw new SsoVerificationError(`the provider answered error=${params['error']}`);
            }
            if (params['state'] !== callback.state) {
                // No nonce on plain OAuth2, so state is the replay defence.
                throw new SsoVerificationError('the echoed state is not the one this attempt stored');
            }
            const code = params['code'];
            if (!code) {
                throw new SsoVerificationError('the response carried no authorization code');
            }

            // Spending the code over TLS is the verification.
            const tokenResponse = await doFetch(`${base}/oauth/token`, {
                method: 'POST',
                headers: {
                    accept: 'application/json',
                    'content-type': 'application/x-www-form-urlencoded'
                },
                body: new URLSearchParams({
                    grant_type: 'authorization_code',
                    code,
                    redirect_uri: callback.redirectUri,
                    client_id: config.clientId,
                    client_secret: config.clientSecret,
                    code_verifier: callback.codeVerifier
                })
            });
            const token = (await tokenResponse.json().catch(() => ({}))) as {
                access_token?: string;
                error?: string;
            };
            if (!tokenResponse.ok || !token.access_token) {
                throw new SsoVerificationError(`the code was refused (${token.error ?? tokenResponse.status})`);
            }

            const userResponse = await doFetch(`${base}/api/userinfo`, {
                headers: { accept: 'application/json', authorization: `Bearer ${token.access_token}` }
            });
            if (!userResponse.ok) {
                throw new SsoVerificationError(`the profile read failed (${userResponse.status})`);
            }
            const user = (await userResponse.json()) as AcmeUser;

            return {
                subject: String(user.id),                 // the immutable id, not the login or email
                email: user.email,
                emailVerified: user.email_verified === true,
                name: user.name ?? null,
                ...(user.groups ? { groups: user.groups } : {})
            };
        }
    };
}

The conformance spec

runSsoProviderConformance returns a report (null per passing check, otherwise a sentence). Turn each of the eleven SSO_PROVIDER_CONFORMANCE_CHECKS into a test:

// packages/identity-provider-acme/src/acme-provider.spec.ts
import {
    runSsoProviderConformance,
    SSO_PROVIDER_CONFORMANCE_CHECKS,
    type SsoCallback,
    type SsoProviderConformanceReport
} from '@orthacms/identity-domain';
import { createAcmeProvider } from './acme-provider';

const CORE = {
    redirectUri: 'https://cms.example.com/api/auth/sso/acme/callback',
    state: 'state-from-the-core',
    nonce: 'nonce-from-the-core',
    codeVerifier: 'verifier-that-must-never-appear-in-a-url'
};

// A stubbed provider: the token endpoint accepts only "good-code".
function armed() {
    const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
        const url = String(input);
        if (url.endsWith('/oauth/token')) {
            const body = new URLSearchParams(String(init?.body));
            return body.get('code') === 'good-code'
                ? Response.json({ access_token: 'at-1' })
                : Response.json({ error: 'invalid_grant' }, { status: 400 });
        }
        return Response.json({ id: 42, email: 'ada@example.com', email_verified: true, name: 'Ada' });
    }) as typeof globalThis.fetch;

    return createAcmeProvider({
        baseUrl: 'https://login.acme.example',
        clientId: 'client-1',
        clientSecret: 'secret-1',
        fetch
    });
}

const callbackWith = (params: Record<string, string>): SsoCallback => ({
    params: { code: 'good-code', state: CORE.state, ...params },
    state: CORE.state,
    nonce: CORE.nonce,
    codeVerifier: CORE.codeVerifier,
    redirectUri: CORE.redirectUri
});

describe('Acme SsoProvider conformance', () => {
    let report: SsoProviderConformanceReport;

    beforeAll(async () => {
        report = await runSsoProviderConformance({
            authorize: () => ({ provider: armed(), request: CORE }),
            happyPath: () => ({ provider: armed(), callback: callbackWith({}) }),
            tampered: () => ({ provider: armed(), callback: callbackWith({ code: 'forged-code' }) }),
            mismatchedNonce: () => ({ provider: armed(), callback: callbackWith({ state: 'another-attempt' }) }),
            providerError: () => ({ provider: armed(), callback: callbackWith({ error: 'access_denied' }) })
        });
    });

    it.each(SSO_PROVIDER_CONFORMANCE_CHECKS)('%s', (check) => {
        expect(report[check]).toBeNull();
    });
});

Where the protocol has signatures, make the stub sign real tokens with a generated key pair, so tampered fails on the signature check itself. The scripted provider is a short worked example.

Register it

// apps/server/src/plugins.ts
import { createAcmeProvider } from '@acme/identity-provider-acme';

IdentityPlugin(config.plugins.identity, {
    sso: {
        providers: [
            ...ssoProviders(config),
            ...(process.env.SSO_ACME_CLIENT_ID && process.env.SSO_ACME_CLIENT_SECRET
                ? [{
                      name: 'acme',
                      provider: createAcmeProvider({
                          baseUrl: 'https://login.acme.example',
                          clientId: process.env.SSO_ACME_CLIENT_ID,
                          clientSecret: process.env.SSO_ACME_CLIENT_SECRET
                      })
                  }]
                : [])
        ]
    }
});
  • The name is permanent. It is in the route and every sso_identities row.
  • Register the callback URL with the provider:
import { ssoCallbackUrl } from '@orthacms/identity-server';

ssoCallbackUrl(config.plugins.identity, 'acme');
// "https://cms.example.com/api/auth/sso/acme/callback"

Check it:

curl -s http://localhost:3000/api/auth/sso
# [..., {"name":"acme","label":"Acme","kind":"oauth2"}]
curl -si http://localhost:3000/api/auth/sso/acme/start | grep -i '^location'
# location: https://login.acme.example/oauth/authorize?response_type=code&client_id=client-1&…

See single sign-on for the handshake settings and what a sign-in may do to an account.