Skip to content

Connect your identity provider

Login origin company-sso Provider acme your hooks file Principals person:… routed to returns
Login origin company-sso Provider acme your hooks file Principals person:… routed to returns
The host configuration routes each login origin to one provider in your hooks file, and that provider returns the signed-in account’s principals.

This page connects IdentityFlow to the system that knows your people. Afterwards, a person who signs in gets principals from your own code, and dialogs and access entries can name them.

Before you start: a running IdentityFlow with an OIDC login, and a workflow project, such as the starter. For a small, complete hooks file with its test, see the recipe Map a login to principals first.

The main path has four steps:

  1. Write the hooks file with your provider.
  2. Let the host find the file.
  3. Route the login to your provider.
  4. Test the hooks file.

Change events from your directory and switching between a test directory and a live one are optional; add them when you need them.

The examples use the provider ID acme, a hook named acme-directory and a login origin named company-sso. Replace all three with names for your own system. The terms account, principal and provider are explained on Identity and access and in Concepts.

A hooks file is a TypeScript module that default-exports one defineHooks(name, register) definition. The host reads only export default; a file that matches the discovery patterns without a valid default export stops the start with Invalid hooks definition in <file>.

  • name identifies this hooks file. It becomes the <hook> segment of webhook URLs, so it must be 1–64 lowercase letters or digits with single hyphens between them, for example acme-directory. It is not the provider ID.
  • register(hooks) runs once when the host starts, never during workflow replay. It must be synchronous and return nothing. The callbacks you register inside it may be async (they return a Promise: a value that arrives later, which the host waits for) and may read your directory.

Inside register, hooks.identity.provider(id, provider) names your provider and says how to find the principals of a signed-in account:

hooks.identity.provider(PROVIDER_ID, {
async principals(account) {
const subjects = await directory.principalsFor(account);
// An unknown login is not an error: it simply holds no principal from this provider.
return (subjects ?? []).map((subject) => ({ providerId: PROVIDER_ID, subject }));
},
});

principals(account) receives the verified Account: its IdentityFlow id, the login origin, and the OIDC subject. It returns the complete current set of principals for that account, each as { providerId, subject }:

  • Return the whole set every time. It replaces the previous answer; it is not a patch.
  • Every principal must carry this provider’s ID. A principal for another provider makes the lookup fail.
  • A subject is your own vocabulary, 1–512 characters. IdentityFlow never parses a subject, but it compares subjects without regard to letter case: group:Admins and group:admins are the same principal. Provider IDs are compared exactly. If your system distinguishes subjects only by case, encode them so that case does not matter, for example in lower-case hex.
  • An unknown login is not an error. Return an empty list, and the account holds no principal from this provider.

IdentityFlow asks the provider when someone signs in, again for reads when its stored answer is older than principalCacheTtl (15 minutes by default), and always, fresh, when someone acts: starts a workflow, answers a dialog, or runs an operator command. If that fresh lookup fails, or takes longer than 5 seconds, the action is refused; see When principals are looked up.

A changed hooks file takes effect when the host restarts. Hooks run outside workflow replay: events already recorded keep what they recorded, and running instances are not invalidated. Pending dialogs are checked against the new principals the next time someone tries to answer them.

By default the host searches the same directory tree as for Workflow Definitions: the directory that contains the configuration file, unless workflows.cwd or workflows.path say otherwise. It loads every file that matches one of these patterns:

  • **/*.hooks.?(m)[jt]s, for example hooks/acme-directory.hooks.ts;
  • **/hooks/*.?(m)[jt]s, any .ts, .mts, .js or .mjs file directly inside a hooks/ directory.

Every file directly inside a hooks/ directory is loaded as a hooks file, so keep helper modules elsewhere. *.test.ts and *.spec.ts files are skipped. Hooks files are never deployed as workflows. To look elsewhere, set the hooks block in the host configuration. It accepts cwd, path, include and exclude. Setting include replaces both default patterns, and include is a HOCON keyword, so the key must be quoted:

hooks {
# Defaults: the workflow directory tree, files named *.hooks.ts or placed in hooks/.
path = ["."]
"include" = ["hooks/*.hooks.ts"]
}
"include" = ["hooks/*.hooks.ts"]
  1. quoted: a HOCON keyword
  2. replaces both defaults

Each file is checked when the host starts. These problems stop the start:

ProblemStart-up error
The default export is not a valid defineHooks definitionInvalid hooks definition in <file>
Two files use the same hook nameDuplicate hook "<name>" in <file>; already registered by <file>
Two files register the same provider IDDuplicate identity provider "<id>" in <file>; already registered by <file>
register returns a PromiseHooks registration in <file> cannot be asynchronous
A webhook uses the same header for signature and timestampWebhook "<name>" in <file> must use distinct signature and timestamp headers

The auth.oidc block names an origin: a label you choose for this login configuration. It is not the issuer URL. identity.originProviders maps that label to the provider ID your hooks file registers:

auth {
oidc {
issuer = ${IDENTITY_FLOW_OIDC_ISSUER}
clientId = identity-flow
clientSecret = ${IDENTITY_FLOW_OIDC_CLIENT_SECRET}
redirectUri = "https://flow.example.com/auth/callback"
scopes = [openid, profile, email]
authorizationTtl = "10 minutes"
sessionTtl = "8 hours"
# A name you choose for this login. It is not the issuer URL.
origin = company-sso
}
}
identity {
originProviders {
# Logins through company-sso get their principals from the provider "acme".
company-sso = acme
}
}

Each entry in originProviders maps one origin to one provider ID:

company-sso = acme
  1. the auth.oidc.origin
  2. ID your hooks file registers

The host configuration is written in HOCON, a JSON superset that allows comments, unquoted strings and ${ENV_VAR} references. The other auth.oidc keys are described in Configuration.

An origin is a routing input, not a substitute for the provider ID. The host refuses an origin that maps to nothing rather than guessing, and it checks both ends when it starts:

  • identity.originProviders must contain auth.oidc.origin "company-sso" means the configured origin has no entry in originProviders.
  • Origin "company-sso" routes to missing provider "acme" means no loaded hooks file registers the provider ID the origin points at. Check the file name against the discovery patterns and the ID passed to hooks.identity.provider().

Start the host once with your configuration before you try a browser login. A missing mapping fails at startup with one of the messages above; a mapping to the wrong provider only shows up later, as an account that can see nothing. Troubleshooting lists further symptoms.

To let IdentityFlow act on a membership change before the next sign-in, your directory reports the change by calling a webhook that the hooks file registers with hooks.http.webhook(name, definition). IdentityFlow turns each call into a change event: a small command identified by source and eventId.

You need change events when dialogs use assignee sources, or when a stored login must be looked up again before principalCacheTtl ends. The webhook body, endpoint, signature protocol and processing are in the collapsed section below.

Webhook body, endpoint, signature and processing

First, the JSON body your directory sends, as a Valibot schema (Valibot is the validation library the SDK re-exports as @identity-flow/sdk/valibot):

const EventId = v.pipe(v.string(), v.minLength(1), v.maxLength(512));
/** The JSON body your directory posts when something changes. */
const DirectoryChange = v.variant('type', [
v.strictObject({
type: v.literal('deputies-changed'),
eventId: EventId,
personId: v.string(),
version: v.string(),
}),
v.strictObject({
type: v.literal('login-changed'),
eventId: EventId,
origin: v.string(),
subject: v.string(),
}),
]);

Then the webhook itself:

hooks.http.webhook('directory-changed', {
body: DirectoryChange,
auth: {
type: 'hmac-sha256',
secret: () => requiredEnv('ACME_WEBHOOK_SECRET'),
signatureHeader: 'x-acme-signature',
timestampHeader: 'x-acme-timestamp',
maxClockSkew: '5 minutes',
},
map(change) {
const command =
change.type === 'deputies-changed'
? hooks.commands.assignees.sourceChanged({
providerId: PROVIDER_ID,
sourceKey: `deputies-of:${change.personId}`,
sourceVersion: change.version,
})
: hooks.commands.identity.invalidate({
providerId: PROVIDER_ID,
account: { origin: change.origin, subject: change.subject },
});
const envelope = { source: 'acme-directory', eventId: change.eventId, command };
return hooks.commands.envelope(envelope);
},
});

map turns the validated body into one change envelope, or an array of them. Build envelopes only with hooks.commands; there are two commands:

  • hooks.commands.assignees.sourceChanged({ providerId, sourceKey, sourceVersion? }) refreshes every pending dialog whose assignee source has this provider and source key. See Assignee sources.
  • hooks.commands.identity.invalidate({ providerId, account: { origin, subject } }) discards the stored principals of that login, so the next lookup asks your provider again.

A webhook may only send commands for a provider or resolver that the same hooks file registers. hooks.commands.envelope({ source, eventId, occurredAt?, command }) checks the envelope; occurredAt is an optional ISO timestamp.

The host serves the webhook at:

POST https://<your IdentityFlow host>/-hooks/<hook>/<webhook>

For the example that is /-hooks/acme-directory/directory-changed. Your directory must send two headers, named by the definition:

  • the timestamp header (x-acme-timestamp here): the current time in Unix seconds, as a decimal number;
  • the signature header (x-acme-signature here): sha256= followed by 64 lowercase hex characters.

The signature is HMAC-SHA256 with the shared secret, computed over this exact byte sequence, with a newline between the parts: identity-flow-webhook-v1, the timestamp, the hook name, the webhook name, and the raw request body.

SIGNED BYTES, NEWLINE-JOINED identity-flow-webhook-v1 Timestamp Unix seconds Hook name acme-directory Webhook name directory-changed Raw request body bytes as sent Signature sha256=<64 hex> Signature header x-acme-signature Timestamp header x-acme-timestamp HMAC-SHA256 + secret sent in also sent in
SIGNED BYTES, NEWLINE-JOINED identity-flow-webhook-v1 Timestamp Unix seconds Hook name acme-directory Webhook name directory-changed Raw request body bytes as sent Signature sha256=<64 hex> Signature header x-acme-signature Timestamp header x-acme-timestamp HMAC-SHA256 + secret sent in also sent in
The signature is HMAC-SHA256 with the shared secret over five parts joined with newlines; the timestamp is also sent in its own header.

The request is refused when the timestamp is further from the host’s clock than maxClockSkew. The host asks the secret callback for the secret on every request, so keep the secret in the environment or a secret store, never in the hooks file. This script signs and sends one event with openssl and curl:

#!/bin/sh
# Signs one change event and posts it to IdentityFlow.
# Usage: ACME_WEBHOOK_SECRET=… ./send-change-event.sh https://flow.example.com '<json body>'
set -eu
base_url=$1
body=$2
hook=acme-directory
webhook=directory-changed
timestamp=$(date +%s)
signature=$(
printf 'identity-flow-webhook-v1\n%s\n%s\n%s\n%s' "$timestamp" "$hook" "$webhook" "$body" |
openssl dgst -sha256 -hmac "$ACME_WEBHOOK_SECRET" -r | cut -d ' ' -f 1
)
curl --silent --show-error --request POST "$base_url/-hooks/$hook/$webhook" \
--header 'content-type: application/json' \
--header "x-acme-signature: sha256=$signature" \
--header "x-acme-timestamp: $timestamp" \
--data-binary "$body"

Use this script to test the endpoint. In production, compute the signature inside your directory’s own code, so the secret never appears in a process list or shell history.

IdentityFlow checks a call in this order and stops at the first failure:

  1. The caller’s address is within the rate limit: a burst of 8 requests, then 1 per second.
  2. The URL names a registered hook and webhook.
  3. Both headers are present.
  4. The body is at most 1,000,000 bytes, or the lower limits.bodyBytes of the definition.
  5. The signature matches and the timestamp is fresh.
  6. The body is JSON and matches the body schema.
  7. map returns at least one envelope, and no more than 1,000, or the lower limits.eventsPerRequest.
  8. Every envelope is stored. An envelope whose source and eventId were already stored with the same content counts as a duplicate; with different content, the whole request is refused.

The secret lookup, schema validation and map share a budget of 5 seconds per request. Each webhook handles at most 8 requests at the same time; further requests get 503.

Statuserror in the bodyMeaningWhat the sender does
202noneStored. The body is { results: [{ source, eventId, status }] }, with status accepted or duplicate for each envelope.Nothing.
400invalid_request_bodyNot JSON, fails the schema, maps to no envelope, or names a provider this hooks file does not register.Fix the body or the hooks file.
401authentication_requiredA header is missing.Send both headers.
403authentication_failedWrong signature, or a stale timestamp.Check the secret, the signed bytes and the clock; sign each attempt afresh.
404webhook_not_foundNo such hook or webhook.Check the URL.
409change_event_conflictAn eventId was reused for different content.Use a new eventId for new content.
413request_too_largeToo many bytes or too many envelopes.Split the request.
429rate_limit_exceededRate limit reached. Retry-After says how many seconds to wait.Retry later with the same body and eventId, signed again with the current time.
503change_ingress_unavailableThe secret callback, the schema or map threw or timed out, or the host is busy.Retry later with the same body and eventId, signed again with the current time.

Because a repeated eventId with the same content is a harmless duplicate, your directory can retry freely, as long as each attempt is signed with the current time. Give every new change a new eventId.

Sign every attempt with a fresh timestamp; a stored, already signed request is refused with 403 once it is older than maxClockSkew. Keep maxClockSkew short, a few minutes at most: together with the eventId, it is what stops a captured request from being replayed later.

maxClockSkew: '5 minutes',
  1. with eventId, stops replays

The 202 means the change event is stored in PostgreSQL. IdentityFlow applies it afterwards, in the background, and never during workflow replay. For a source change, that means refreshing each affected dialog. If a refresh fails, for example because your directory is unreachable, IdentityFlow tries up to 8 times, waiting 1 second before the first retry and doubling the wait up to 5 minutes. After the last attempt the refresh appears under Failed work for that dialog’s instance, where an account with operator access can replay it; see Operate running workflows. The changes block of the host configuration adjusts attempts and polling.

YOUR DIRECTORY IDENTITYFLOW HOST Directory sends a change Webhook checks, then map Change event stored in PostgreSQL Background worker applies it later Refresh dialogs calls your resolver Discard principals next lookup asks POST 202 stores picks up sourceChanged invalidate
YOUR DIRECTORY IDENTITYFLOW HOST Directory sends a change Webhook checks, then map Change event stored in PostgreSQL Background worker applies it later Refresh dialogs calls your resolver Discard principals next lookup asks POST 202 stores picks up sourceChanged invalidate
A 202 means the change event is stored; a background worker applies it afterwards, outside workflow replay.

A failed identity.invalidate is retried the same way. After the last attempt 0.3.0 shows it nowhere. Reads keep using the stored principals until they are older than principalCacheTtl, then ask the provider again; commands always ask the provider.

If your directory cannot call a webhook, hooks.lifecycle.startup(name, start) lets the hooks file start its own adapter when the host starts, for example one that polls or subscribes, and submit the same envelopes. See Hooks API.

Keep everything that talks to your directory behind one small interface, with two implementations: a fixed test directory for local runs and tests, and the live one. The hooks call only the interface, and the environment decides which implementation the host gets:

/** Everything the hooks need from your directory. */
export interface Directory {
/** Principal subjects for one login, or `null` when the directory does not know it. */
principalsFor(account: Account): Promise<readonly string[] | null>;
/** The people currently standing in for one person, and the directory's version of that list. */
deputiesOf(
personId: string,
signal: AbortSignal,
): Promise<{ readonly personIds: readonly string[]; readonly version: string }>;
}
/** A fixed in-memory directory for tests and local runs. */
export function createTestDirectory(
people: Readonly<Record<string, readonly string[]>>,
): Directory {
return {
principalsFor: (account) => Promise.resolve(people[account.subject] ?? null),
deputiesOf: () => Promise.resolve({ personIds: [], version: 'test' }),
};
}
/** The live directory, reached over its HTTP API. `baseUrl` must end with a slash. */
export function createHttpDirectory(baseUrl: string, token: string): Directory {
async function get<T>(path: string, signal?: AbortSignal): Promise<T | null> {
const response = await fetch(new URL(path, baseUrl), {
headers: { authorization: `Bearer ${token}` },
signal,
});
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Directory answered ${response.status} for ${path}`);
return (await response.json()) as T;
}
return {
async principalsFor(account) {
const login = await get<{ subjects: string[] }>(
`logins/${encodeURIComponent(account.subject)}`,
);
return login?.subjects ?? null;
},
async deputiesOf(personId, signal) {
const deputies = await get<{ personIds: string[]; version: string }>(
`people/${encodeURIComponent(personId)}/deputies`,
signal,
);
if (!deputies) throw new Error(`Directory does not know person ${personId}`);
return deputies;
},
};
}
/** Chooses the directory when the host loads this file. Anything but `test` or `live` stops the start. */
function directoryFromEnvironment(): Directory {
const mode = process.env.ACME_DIRECTORY;
if (mode === 'test') return createTestDirectory({ 'login-alex': ['person:p-2041'] });
if (mode === 'live') {
return createHttpDirectory(
requiredEnv('ACME_DIRECTORY_URL'),
requiredEnv('ACME_DIRECTORY_TOKEN'),
);
}
throw new Error(`ACME_DIRECTORY must be "test" or "live", not ${JSON.stringify(mode)}`);
}

An unset or misspelled ACME_DIRECTORY stops the host at startup instead of silently picking one. Credentials come from the environment, as with every secret in the host configuration.

Before you point the hooks at a live directory:

  • run the hooks tests against the test directory;
  • get credentials for a non-production directory from its administrator;
  • start the host with ACME_DIRECTORY=live and sign in with one test account;
  • check My access for the principals the directory reported.

Log only what you need to diagnose a problem: provider ID, source version, event ID, the resulting step state and timing. Keep credentials and full directory payloads out of logs and out of change envelopes.

createHookTestHost() from @identity-flow/testing runs your registrations in memory. It needs neither the Workflow Test distribution nor a database, so hook tests run under plain Vitest. This test covers the provider, the resolver, the relation, the webhook mapping, the live adapter with a stubbed fetch, and the refusal to start without a directory choice:

import type { Account } from '@identity-flow/sdk';
import { createHookTestHost } from '@identity-flow/testing';
import { afterEach, expect, test, vi } from 'vitest';
import hooks, {
type Directory,
createDirectoryHooks,
createHttpDirectory,
createTestDirectory,
} from './w2-directory.hooks';
const alex: Account = {
id: 'AZlKqAAAeACAAAAAAAAAAQ' as Account['id'],
origin: 'company-sso',
subject: 'login-alex',
};
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
test('maps a login to the principals the directory reports', async () => {
const host = createHookTestHost();
host.load(
createDirectoryHooks(() =>
createTestDirectory({ 'login-alex': ['person:p-2041', 'manager-of:p-2042'] }),
),
);
await expect(host.identity.resolve('acme', alex)).resolves.toEqual([
{ providerId: 'acme', subject: 'person:p-2041' },
{ providerId: 'acme', subject: 'manager-of:p-2042' },
]);
await expect(
host.identity.resolve('acme', { ...alex, subject: 'login-unknown' }),
).resolves.toEqual([]);
});
test('resolves the complete assignee set of a deputies source', async () => {
const directory: Directory = {
principalsFor: () => Promise.resolve(null),
deputiesOf: (personId) =>
Promise.resolve({ personIds: personId === 'p-1001' ? ['p-2050'] : [], version: '17' }),
};
const host = createHookTestHost();
host.load(createDirectoryHooks(() => directory));
await expect(
host.assignees.resolve('acme', {
source: {
providerId: 'acme',
sourceKey: 'deputies-of:p-1001',
context: { employeeId: 'p-2041', managerId: 'p-1001' },
},
signal: new AbortController().signal,
}),
).resolves.toEqual({
assignees: [
{ providerId: 'acme', subject: 'manager-of:p-2041' },
{ providerId: 'acme', subject: 'person:p-2050' },
],
sourceVersion: '17',
});
});
test('projects the manager of an instance recipient', async () => {
const host = createHookTestHost();
host.load(createDirectoryHooks(() => createTestDirectory({})));
await expect(
host.authorization.project('manager-of-recipient', {
kind: 'workflowInstanceRecipient',
instanceId: 'AZlKqAAAeACAAAAAAAAAAg' as never,
recipient: 'p-2041',
}),
).resolves.toEqual([{ providerId: 'acme', subject: 'manager-of:p-2041' }]);
});
test('maps each directory change to one change envelope', async () => {
const host = createHookTestHost();
host.load(createDirectoryHooks(() => createTestDirectory({})));
await expect(
host.http.map('acme-directory', 'directory-changed', {
type: 'deputies-changed',
eventId: 'evt-1001',
personId: 'p-1001',
version: '18',
}),
).resolves.toEqual({
source: 'acme-directory',
eventId: 'evt-1001',
command: {
type: 'assignees.sourceChanged',
version: 1,
providerId: 'acme',
sourceKey: 'deputies-of:p-1001',
sourceVersion: '18',
},
});
await expect(
host.http.map('acme-directory', 'directory-changed', {
type: 'login-changed',
eventId: 'evt-1002',
origin: 'company-sso',
subject: 'login-alex',
}),
).resolves.toMatchObject({
eventId: 'evt-1002',
command: {
type: 'identity.invalidate',
account: { origin: 'company-sso', subject: 'login-alex' },
},
});
await expect(
host.http.map('acme-directory', 'directory-changed', { type: 'unknown', eventId: 'evt-1003' }),
).rejects.toThrow();
});
test('reads the live directory over HTTP', async () => {
const fetch = vi.fn(() =>
Promise.resolve(Response.json({ subjects: ['person:p-2041'] }, { status: 200 })),
);
vi.stubGlobal('fetch', fetch);
const host = createHookTestHost();
host.load(
createDirectoryHooks(() => createHttpDirectory('https://directory.example.com/api/', 'token')),
);
await expect(host.identity.resolve('acme', alex)).resolves.toEqual([
{ providerId: 'acme', subject: 'person:p-2041' },
]);
expect(fetch).toHaveBeenCalledWith(
new URL('https://directory.example.com/api/logins/login-alex'),
expect.objectContaining({ headers: { authorization: 'Bearer token' } }),
);
});
test('refuses to load without an explicit directory choice', () => {
vi.stubEnv('ACME_DIRECTORY', '');
expect(() => createHookTestHost().load(hooks)).toThrow(
'ACME_DIRECTORY must be "test" or "live", not ""',
);
vi.stubEnv('ACME_DIRECTORY', 'test');
expect(() => createHookTestHost().load(hooks)).not.toThrow();
});

The test host checks names, webhook definitions, resolver answers, relation answers and envelopes the way the host does. It returns a provider’s principals as the provider returned them, and it does not test HTTP, headers or the HMAC signature: host.http.map starts after those checks. It also does not check that map only emits commands for providers this file registers, or that the two header names differ. Name hook tests *.test.ts and make sure your Vitest include pattern selects them; Test workflows describes the project setup.

The complete hooks file
import process from 'node:process';
import * as v from '@identity-flow/sdk/valibot';
import { type Account, type PrincipalRef, defineHooks } from '@identity-flow/sdk';
const PROVIDER_ID = 'acme';
// docs:adapter:start
/** Everything the hooks need from your directory. */
export interface Directory {
/** Principal subjects for one login, or `null` when the directory does not know it. */
principalsFor(account: Account): Promise<readonly string[] | null>;
/** The people currently standing in for one person, and the directory's version of that list. */
deputiesOf(
personId: string,
signal: AbortSignal,
): Promise<{ readonly personIds: readonly string[]; readonly version: string }>;
}
/** A fixed in-memory directory for tests and local runs. */
export function createTestDirectory(
people: Readonly<Record<string, readonly string[]>>,
): Directory {
return {
principalsFor: (account) => Promise.resolve(people[account.subject] ?? null),
deputiesOf: () => Promise.resolve({ personIds: [], version: 'test' }),
};
}
/** The live directory, reached over its HTTP API. `baseUrl` must end with a slash. */
export function createHttpDirectory(baseUrl: string, token: string): Directory {
async function get<T>(path: string, signal?: AbortSignal): Promise<T | null> {
const response = await fetch(new URL(path, baseUrl), {
headers: { authorization: `Bearer ${token}` },
signal,
});
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Directory answered ${response.status} for ${path}`);
return (await response.json()) as T;
}
return {
async principalsFor(account) {
const login = await get<{ subjects: string[] }>(
`logins/${encodeURIComponent(account.subject)}`,
);
return login?.subjects ?? null;
},
async deputiesOf(personId, signal) {
const deputies = await get<{ personIds: string[]; version: string }>(
`people/${encodeURIComponent(personId)}/deputies`,
signal,
);
if (!deputies) throw new Error(`Directory does not know person ${personId}`);
return deputies;
},
};
}
/** Chooses the directory when the host loads this file. Anything but `test` or `live` stops the start. */
function directoryFromEnvironment(): Directory {
const mode = process.env.ACME_DIRECTORY;
if (mode === 'test') return createTestDirectory({ 'login-alex': ['person:p-2041'] });
if (mode === 'live') {
return createHttpDirectory(
requiredEnv('ACME_DIRECTORY_URL'),
requiredEnv('ACME_DIRECTORY_TOKEN'),
);
}
throw new Error(`ACME_DIRECTORY must be "test" or "live", not ${JSON.stringify(mode)}`);
}
// docs:adapter:end
const person = (personId: string): PrincipalRef => ({
providerId: PROVIDER_ID,
subject: `person:${personId}`,
});
const managerOf = (personId: string): PrincipalRef => ({
providerId: PROVIDER_ID,
subject: `manager-of:${personId}`,
});
// docs:webhook-body:start
const EventId = v.pipe(v.string(), v.minLength(1), v.maxLength(512));
/** The JSON body your directory posts when something changes. */
const DirectoryChange = v.variant('type', [
v.strictObject({
type: v.literal('deputies-changed'),
eventId: EventId,
personId: v.string(),
version: v.string(),
}),
v.strictObject({
type: v.literal('login-changed'),
eventId: EventId,
origin: v.string(),
subject: v.string(),
}),
]);
// docs:webhook-body:end
export function createDirectoryHooks(selectDirectory: () => Directory) {
return defineHooks('acme-directory', (hooks) => {
const directory = selectDirectory();
// docs:provider:start
hooks.identity.provider(PROVIDER_ID, {
async principals(account) {
const subjects = await directory.principalsFor(account);
// An unknown login is not an error: it simply holds no principal from this provider.
return (subjects ?? []).map((subject) => ({ providerId: PROVIDER_ID, subject }));
},
});
// docs:provider:end
// docs:resolver:start
hooks.assignees.resolver(PROVIDER_ID, {
context: v.strictObject({ employeeId: v.string(), managerId: v.string() }),
async resolve({ source, signal }) {
const { employeeId, managerId } = source.context;
const { personIds, version } = await directory.deputiesOf(managerId, signal);
// The answer replaces the whole assignee set, so it names the manager as well.
const assignees = [managerOf(employeeId), ...personIds.map(person)];
return { assignees, sourceVersion: version };
},
});
// docs:resolver:end
// docs:relation:start
hooks.authorization.principalRelation('manager-of-recipient', {
project: (anchor) => [managerOf(anchor.recipient)],
});
// docs:relation:end
// docs:webhook:start
hooks.http.webhook('directory-changed', {
body: DirectoryChange,
auth: {
type: 'hmac-sha256',
secret: () => requiredEnv('ACME_WEBHOOK_SECRET'),
signatureHeader: 'x-acme-signature',
timestampHeader: 'x-acme-timestamp',
maxClockSkew: '5 minutes',
},
map(change) {
const command =
change.type === 'deputies-changed'
? hooks.commands.assignees.sourceChanged({
providerId: PROVIDER_ID,
sourceKey: `deputies-of:${change.personId}`,
sourceVersion: change.version,
})
: hooks.commands.identity.invalidate({
providerId: PROVIDER_ID,
account: { origin: change.origin, subject: change.subject },
});
const envelope = { source: 'acme-directory', eventId: change.eventId, command };
return hooks.commands.envelope(envelope);
},
});
// docs:webhook:end
});
}
export default createDirectoryHooks(directoryFromEnvironment);
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}

The full registration surface, with every limit, is on Hooks API.