Map a login to principals
IdentityFlow knows who signed in. Your directory knows who that person is, which
approval teams they belong to and whom they manage. In this recipe you write a
hooks file that turns one login into those
principals, the
{ providerId, subject } pairs that dialogs and access rules name.
Save the two files as identity-hook.hooks.ts and identity-hook.hooks.test.ts.
The host discovers the hooks file by its .hooks.ts ending, as described in
Let the host find the 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';
/** Everything the directory knows about one login. The rest is derived here. */export interface DirectoryMembership { readonly personId: string; readonly approvalTeamIds: readonly string[]; readonly managesPersonIds: readonly string[];}
export interface Directory { findMembership(account: Account): Promise<DirectoryMembership | null>;}
// Three kinds of opaque, provider-scoped subject. IdentityFlow compares them and// never parses them, so the prefixes are the provider's own vocabulary.export const person = (personId: string): PrincipalRef => ({ providerId: PROVIDER_ID, subject: `person:${personId}`,});
export const approvalTeam = (teamId: string): PrincipalRef => ({ providerId: PROVIDER_ID, subject: `approval-team:${teamId}`,});
export const managerOf = (personId: string): PrincipalRef => ({ providerId: PROVIDER_ID, subject: `manager-of:${personId}`,});
/** * Maps one authenticated login to its complete current principal set: who the * person is, which approval teams they sit in, and whom they manage. IdentityFlow * calls this outside workflow replay, so it may read the directory. * * Return the complete set every time. It is a snapshot, not a patch. */export function createIdentityHooks(directory: Directory) { return defineHooks(PROVIDER_ID, (hooks) => { hooks.identity.provider(PROVIDER_ID, { async principals(account) { const membership = await directory.findMembership(account); // An unknown login is not an error. It holds no principal from this // provider, so nothing this directory grants reaches it. if (!membership) return [];
return [ person(membership.personId), ...membership.approvalTeamIds.map(approvalTeam), ...membership.managesPersonIds.map(managerOf), ]; }, }); });}
const Membership = v.object({ personId: v.string(), approvalTeamIds: v.array(v.string()), managesPersonIds: v.array(v.string()),});
/** * The acme directory's HTTP API. Replace this object with a client for your own * directory; the hooks above do not change. It reads its settings on every call, * so the file loads without them, and a lookup without them fails. */const acmeDirectory: Directory = { async findMembership(account) { // A base URL without a trailing slash would lose its last path segment. const baseUrl = requiredEnv('ACME_DIRECTORY_URL').replace(/\/?$/, '/'); const response = await fetch( new URL(`logins/${encodeURIComponent(account.subject)}`, baseUrl), { headers: { authorization: `Bearer ${requiredEnv('ACME_DIRECTORY_TOKEN')}` }, // The host stops waiting after 5 seconds; stop the request as well. signal: AbortSignal.timeout(5000), }, ); if (response.status === 404) return null; if (!response.ok) throw new Error(`The acme directory answered ${response.status}`); return v.parse(Membership, await response.json()); },};
// The host loads only the default export of a hooks file.export default createIdentityHooks(acmeDirectory);
function requiredEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); return value;}The host loads only the file’s default export. createIdentityHooks takes the
directory as an argument so the test can pass an in-memory one; the default
export passes acmeDirectory, a small HTTP client for an invented directory
API. Replace it with a client for your directory. It reads
ACME_DIRECTORY_URL and ACME_DIRECTORY_TOKEN on every lookup, so the host
loads the file without them, and every lookup fails until both are set. A 404
means an unknown login; any other error status fails the lookup. When the
lookup for an account that acts fails, or takes longer than 5 seconds,
IdentityFlow refuses the action, so the client stops its request after 5
seconds as well.
defineHooks(PROVIDER_ID, …) names the hooks file acme, and
hooks.identity.provider(PROVIDER_ID, …) registers the provider acme. The
host configuration decides which logins reach this provider; see
Route the login to your provider.
Three kinds of subject come out of one login: person:… is who they are,
approval-team:… is a team they share with others, and manager-of:… is a
relationship to someone else. IdentityFlow compares these strings and never
parses them, so the vocabulary is yours. This directory uses person IDs like
p-2041, not the user:e-2041 subjects of the workflow recipes.
Two rules are easy to get wrong. Return the complete current set every time: it is a snapshot, not a patch, and anything you leave out is authority the account no longer has. And an unknown login is not an error. It holds no principal from this provider, so nothing this directory grants reaches it.
Hooks run outside workflow replay, which is why this
one may call the directory directly. Inside a workflow the same call belongs in
a flow.do step, so its result is recorded once and not requested again on
replay. Call a GraphQL service through a binding
shows that.
import type { Account } from '@identity-flow/sdk';
import { createHookTestHost } from '@identity-flow/testing';import { afterEach, expect, test, vi } from 'vitest';
import hooks, { createIdentityHooks } from './identity-hook.hooks';
const clara: Account = { id: 'AZlKqAAAeACAAAAAAAAAAQ' as Account['id'], origin: 'acme-oidc', subject: 'login-clara',};
afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals();});
test('maps one login to person, team and manager principals', async () => { const host = createHookTestHost(); host.load( createIdentityHooks({ findMembership: () => Promise.resolve({ personId: 'p-2041', approvalTeamIds: ['vacation-eu'], managesPersonIds: ['p-2042', 'p-2043'], }), }), );
await expect(host.identity.resolve('acme', clara)).resolves.toEqual([ { providerId: 'acme', subject: 'person:p-2041' }, { providerId: 'acme', subject: 'approval-team:vacation-eu' }, { providerId: 'acme', subject: 'manager-of:p-2042' }, { providerId: 'acme', subject: 'manager-of:p-2043' }, ]); expect(host.identity.calls).toEqual([{ providerId: 'acme', account: clara }]);});
test('gives a login the directory does not know no principals at all', async () => { const host = createHookTestHost(); host.load(createIdentityHooks({ findMembership: () => Promise.resolve(null) }));
await expect(host.identity.resolve('acme', clara)).resolves.toEqual([]);});
test('asks the acme directory over HTTP in the file the host loads', async () => { vi.stubEnv('ACME_DIRECTORY_URL', 'https://directory.example.com/api'); vi.stubEnv('ACME_DIRECTORY_TOKEN', 'token'); const fetch = vi.fn(() => Promise.resolve( Response.json({ personId: 'p-2041', approvalTeamIds: [], managesPersonIds: [] }), ), ); vi.stubGlobal('fetch', fetch); const host = createHookTestHost(); host.load(hooks);
await expect(host.identity.resolve('acme', clara)).resolves.toEqual([ { providerId: 'acme', subject: 'person:p-2041' }, ]); expect(fetch).toHaveBeenCalledWith( new URL('https://directory.example.com/api/logins/login-clara'), expect.objectContaining({ headers: { authorization: 'Bearer token' } }), );});
test('treats a 404 as an unknown login and any other error as a failed lookup', async () => { vi.stubEnv('ACME_DIRECTORY_URL', 'https://directory.example.com/api/'); vi.stubEnv('ACME_DIRECTORY_TOKEN', 'token'); const host = createHookTestHost(); host.load(hooks);
vi.stubGlobal('fetch', () => Promise.resolve(new Response(null, { status: 404 }))); await expect(host.identity.resolve('acme', clara)).resolves.toEqual([]);
vi.stubGlobal('fetch', () => Promise.resolve(new Response(null, { status: 500 }))); await expect(host.identity.resolve('acme', clara)).rejects.toThrow( 'The acme directory answered 500', );});
test('fails the lookup while the directory is not configured', async () => { vi.stubEnv('ACME_DIRECTORY_URL', ''); const host = createHookTestHost(); host.load(hooks);
await expect(host.identity.resolve('acme', clara)).rejects.toThrow( 'ACME_DIRECTORY_URL is required', );});createHookTestHost() loads a hooks definition and calls it the way the host
would, with no engine and no database. It records every call, so the first test
can check that the provider was asked once, for exactly this account. The other
tests load the default export, the file as the host loads it: with a stubbed
fetch for a found login, a 404 and a 500, and without the directory URL.
This is a hook test, not a Workflow Test, so the file name ends in .test.ts.
With the configuration from Before you start, the
Workflow Test distribution runs it together with the Workflow Tests.
Test hooks explains how hook tests fit
into a project.
Next: Connect your identity provider for routing, change events and a test and a live directory, and Hooks API for every registration.