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
completeverifies, or throwsSsoVerificationError. Tampered response, wrongstate/nonce and the provider’s ownerrorall reject.subjectis stable and never the email.emailVerifiedreports what the provider claimed, as a boolean.authorizecarries the core’sstateand never the code verifier.descriptor()is stable and has a label.- Implement
verifyLogoutTokenonly if you can verify. Without it the back-channel route answers400 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_identitiesrow. - 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.