@entifix/zitadel 0.1.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/actions/action-webhook.d.ts +93 -0
- package/dist/actions/action-webhook.d.ts.map +1 -0
- package/dist/actions/action-webhook.js +126 -0
- package/dist/actions/action-webhook.js.map +1 -0
- package/dist/health/zitadel-health-probe.d.ts +19 -0
- package/dist/health/zitadel-health-probe.d.ts.map +1 -0
- package/dist/health/zitadel-health-probe.js +34 -0
- package/dist/health/zitadel-health-probe.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/management/management-client.d.ts +102 -0
- package/dist/management/management-client.d.ts.map +1 -0
- package/dist/management/management-client.js +152 -0
- package/dist/management/management-client.js.map +1 -0
- package/dist/oidc/discovery.d.ts +16 -0
- package/dist/oidc/discovery.d.ts.map +1 -0
- package/dist/oidc/discovery.js +60 -0
- package/dist/oidc/discovery.js.map +1 -0
- package/dist/oidc/oidc-client.d.ts +122 -0
- package/dist/oidc/oidc-client.d.ts.map +1 -0
- package/dist/oidc/oidc-client.js +198 -0
- package/dist/oidc/oidc-client.js.map +1 -0
- package/dist/oidc/pkce.d.ts +34 -0
- package/dist/oidc/pkce.d.ts.map +1 -0
- package/dist/oidc/pkce.js +31 -0
- package/dist/oidc/pkce.js.map +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/management/management-client.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport { Context, Effect, Layer } from 'effect';\n\n/** How the management client reaches an instance. */\nexport interface ZitadelManagementConfig {\n readonly issuer: string;\n /**\n * Personal access token of the seed machine user. Zitadel mints it at first\n * init and `infra/local/ensure.sh` extracts it, so it is per-instance and\n * disposable rather than a configured constant.\n */\n readonly personalAccessToken: string;\n}\n\n/** A human user as this client cares about it. */\nexport interface ZitadelUser {\n readonly userId: string;\n readonly username: string;\n readonly email?: string;\n readonly emailVerified: boolean;\n readonly displayName?: string;\n readonly active: boolean;\n}\n\n/** What it takes to create one. */\nexport interface ZitadelHumanInput {\n readonly username: string;\n readonly email: string;\n readonly emailVerified?: boolean;\n readonly givenName: string;\n readonly familyName: string;\n readonly displayName?: string;\n /** Optional: a user created without one signs in through a social provider. */\n readonly password?: string;\n readonly passwordChangeRequired?: boolean;\n}\n\n/** What to ask the event store for. */\nexport interface ZitadelEventQuery {\n /** Event types to match, e.g. `user.deactivated`. */\n readonly eventTypes: readonly string[];\n /** Oldest event to return, as an ISO timestamp. */\n readonly since: string;\n /** Page size. The caller sweeps on an interval, so this is a ceiling. */\n readonly limit: number;\n}\n\n/**\n * One event, reduced to what a reconciler needs.\n *\n * ⚠️ **`payload` is deliberately dropped.** A `user.human.added` event carries\n * the user's `encodedHash` — a bcrypt hash of their password — and this fleet\n * has no reason to hold one, log one, or move one across a process boundary.\n * What a reconciler acts on is *which user* and *when*, which is the whole of\n * this shape.\n */\nexport interface ZitadelEvent {\n /** The aggregate the event is about — for a user event, the user id. */\n readonly subject: string;\n /** The event's own type, e.g. `user.locked`. */\n readonly type: string;\n /** When the instance recorded it, as an ISO timestamp. */\n readonly createdAt: string;\n}\n\ninterface V1EventRow {\n readonly aggregate?: { readonly id?: string };\n readonly type?: { readonly type?: string };\n readonly creationDate?: string;\n}\n\nexport interface ZitadelManagement {\n findUserByEmail(\n email: string,\n ): Effect.Effect<ZitadelUser | null, EntifixConnError>;\n getUser(userId: string): Effect.Effect<ZitadelUser | null, EntifixConnError>;\n createHuman(\n input: ZitadelHumanInput,\n ): Effect.Effect<string, EntifixConnError>;\n updateProfile(\n userId: string,\n profile: {\n readonly givenName: string;\n readonly familyName: string;\n readonly displayName?: string;\n },\n ): Effect.Effect<void, EntifixConnError>;\n setActive(\n userId: string,\n active: boolean,\n ): Effect.Effect<void, EntifixConnError>;\n /** Compensating delete. Used when a provisioning half-write must be undone. */\n deleteUser(userId: string): Effect.Effect<void, EntifixConnError>;\n /**\n * Lifecycle events the instance recorded, oldest first, from a cursor.\n *\n * The one read here that is not about a single user: it answers \"what\n * happened while I was not listening\". Zitadel's Actions v2 target is\n * `restAsync` — it sends the call and ignores the response, and it does not\n * retry — so an event fired while auth-service is down is gone for good\n * ([ADR 0019](../../../../../../docs/adr/0019-provider-user-lifecycle-events-revoke-sessions.md)).\n */\n searchEvents(\n query: ZitadelEventQuery,\n ): Effect.Effect<readonly ZitadelEvent[], EntifixConnError>;\n}\n\nexport class ZitadelManagementTag extends Context.Tag('ZitadelManagementTag')<\n ZitadelManagementTag,\n ZitadelManagement\n>() {}\n\ninterface V1UserRow {\n readonly id?: string;\n readonly userName?: string;\n readonly state?: string;\n readonly human?: {\n readonly profile?: { readonly displayName?: string };\n readonly email?: {\n readonly email?: string;\n readonly isEmailVerified?: boolean;\n };\n };\n}\n\ninterface V2User {\n readonly userId?: string;\n readonly username?: string;\n readonly state?: string;\n readonly human?: {\n readonly profile?: { readonly displayName?: string };\n readonly email?: { readonly email?: string; readonly isVerified?: boolean };\n };\n}\n\nconst ACTIVE = 'USER_STATE_ACTIVE';\n\n/**\n * The slice of Zitadel's management API r10c drives.\n *\n * Only user lifecycle: everything about *credentials* — passwords, TOTP, linked\n * social accounts, recovery — is Zitadel's alone and has no method here on\n * purpose. r10c must not be able to read or set a credential, because the whole\n * point of the swap is that it no longer holds any.\n *\n * Endpoints are a deliberate mix of API versions, matching what Zitadel v4\n * actually offers: search is v1 (the v2 surface has no equivalent email query),\n * everything else is v2.\n */\nexport const makeZitadelManagement = (\n config: ZitadelManagementConfig,\n): ZitadelManagement => {\n const base = config.issuer.replace(/\\/$/, '');\n\n const call = <T>(\n method: string,\n path: string,\n body?: unknown,\n ): Effect.Effect<T, EntifixConnError> =>\n Effect.tryPromise({\n try: async () => {\n const response = await fetch(`${base}${path}`, {\n method,\n headers: {\n authorization: `Bearer ${config.personalAccessToken}`,\n 'content-type': 'application/json',\n },\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n const text = await response.text();\n const payload = text === '' ? {} : (JSON.parse(text) as unknown);\n if (!response.ok) {\n const message =\n (payload as { message?: string }).message ??\n `${method} ${path} answered ${String(response.status)}`;\n throw new Error(message);\n }\n return payload as T;\n },\n catch: error =>\n new EntifixConnError(\n `zitadel management call failed (${method} ${path}): ${String(error)}`,\n ),\n });\n\n const findUserByEmail = (email: string) =>\n call<{ result?: readonly V1UserRow[] }>(\n 'POST',\n '/management/v1/users/_search',\n {\n queries: [\n {\n emailQuery: {\n emailAddress: email,\n method: 'TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE',\n },\n },\n ],\n },\n ).pipe(\n Effect.map(payload => {\n const row = (payload.result ?? [])[0];\n if (row?.id === undefined) return null;\n return {\n userId: row.id,\n username: row.userName ?? '',\n email: row.human?.email?.email,\n emailVerified: row.human?.email?.isEmailVerified === true,\n displayName: row.human?.profile?.displayName,\n active: row.state === ACTIVE,\n } satisfies ZitadelUser;\n }),\n );\n\n const getUser = (userId: string) =>\n call<{ user?: V2User }>('GET', `/v2/users/${userId}`).pipe(\n Effect.map(payload => {\n const user = payload.user;\n if (user?.userId === undefined) return null;\n return {\n userId: user.userId,\n username: user.username ?? '',\n email: user.human?.email?.email,\n emailVerified: user.human?.email?.isVerified === true,\n displayName: user.human?.profile?.displayName,\n active: user.state === ACTIVE,\n } satisfies ZitadelUser;\n }),\n // A deleted or never-existing id is a `404`, which is an answer rather\n // than a failure: callers ask precisely because they do not know.\n Effect.catchAll(error =>\n /404|NotFound|doesn't exist/i.test(error.message)\n ? Effect.succeed(null)\n : Effect.fail(error),\n ),\n );\n\n const createHuman = (input: ZitadelHumanInput) =>\n call<{ userId?: string }>('POST', '/v2/users/human', {\n username: input.username,\n profile: {\n givenName: input.givenName,\n familyName: input.familyName,\n ...(input.displayName === undefined\n ? {}\n : { displayName: input.displayName }),\n },\n email: {\n email: input.email,\n isVerified: input.emailVerified === true,\n },\n ...(input.password === undefined\n ? {}\n : {\n password: {\n password: input.password,\n changeRequired: input.passwordChangeRequired === true,\n },\n }),\n }).pipe(\n Effect.flatMap(payload =>\n payload.userId === undefined\n ? Effect.fail(\n new EntifixConnError(\n 'zitadel accepted the user but returned no id',\n ),\n )\n : Effect.succeed(payload.userId),\n ),\n );\n\n const updateProfile = (\n userId: string,\n profile: {\n readonly givenName: string;\n readonly familyName: string;\n readonly displayName?: string;\n },\n ) =>\n call<unknown>('PUT', `/v2/users/human/${userId}`, { profile }).pipe(\n Effect.asVoid,\n );\n\n const setActive = (userId: string, active: boolean) =>\n call<unknown>(\n 'POST',\n `/v2/users/${userId}/${active ? 'reactivate' : 'deactivate'}`,\n {},\n ).pipe(Effect.asVoid);\n\n const deleteUser = (userId: string) =>\n call<unknown>('DELETE', `/v2/users/${userId}`).pipe(Effect.asVoid);\n\n /**\n * ⚠️ **Snake_case, and that is the API rather than a slip.** The admin event\n * search takes `event_types`, `creation_date` and `asc` while every other\n * call in this module takes camelCase — the v1 surface and the v2 surface do\n * not agree, and this is the one v1 endpoint with a body.\n *\n * `asc: true` because a reconciler advances a cursor: processing newest-first\n * and then storing the newest timestamp would skip everything behind a page\n * boundary.\n *\n * A row missing its aggregate id is dropped rather than failing the sweep.\n * There is nothing to act on, and one malformed row must not stop the ones\n * behind it from being reconciled.\n */\n const searchEvents = (query: ZitadelEventQuery) =>\n call<{ events?: readonly V1EventRow[] }>(\n 'POST',\n '/admin/v1/events/_search',\n {\n asc: true,\n limit: query.limit,\n creation_date: query.since,\n event_types: query.eventTypes,\n },\n ).pipe(\n Effect.map(payload =>\n (payload.events ?? []).flatMap(row => {\n const subject = row.aggregate?.id;\n const type = row.type?.type;\n if (\n typeof subject !== 'string' ||\n typeof type !== 'string' ||\n typeof row.creationDate !== 'string'\n ) {\n return [];\n }\n return [{ subject, type, createdAt: row.creationDate }];\n }),\n ),\n );\n\n return {\n findUserByEmail,\n getUser,\n createHuman,\n updateProfile,\n setActive,\n deleteUser,\n searchEvents,\n };\n};\n\n/** Binds {@link ZitadelManagementTag} from a resolved configuration. */\nexport const ZitadelManagementLayer = (\n config: ZitadelManagementConfig,\n): Layer.Layer<ZitadelManagementTag> =>\n Layer.succeed(ZitadelManagementTag, makeZitadelManagement(config));\n"],"names":["EntifixConnError","Context","Effect","Layer","ZitadelManagementTag","Tag","ACTIVE","makeZitadelManagement","config","base","issuer","replace","call","method","path","body","tryPromise","try","response","fetch","headers","authorization","personalAccessToken","undefined","JSON","stringify","text","payload","parse","ok","message","String","status","Error","catch","error","findUserByEmail","email","queries","emailQuery","emailAddress","pipe","map","row","result","id","userId","username","userName","human","emailVerified","isEmailVerified","displayName","profile","active","state","getUser","user","isVerified","catchAll","test","succeed","fail","createHuman","input","givenName","familyName","password","changeRequired","passwordChangeRequired","flatMap","updateProfile","asVoid","setActive","deleteUser","searchEvents","query","asc","limit","creation_date","since","event_types","eventTypes","events","subject","aggregate","type","creationDate","createdAt","ZitadelManagementLayer"],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { EntifixConnError } from '@entifix/core';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
/** The subset of the OIDC discovery document this client uses. */
|
|
4
|
+
export interface OidcDiscovery {
|
|
5
|
+
readonly issuer: string;
|
|
6
|
+
readonly authorization_endpoint: string;
|
|
7
|
+
readonly token_endpoint: string;
|
|
8
|
+
readonly userinfo_endpoint: string;
|
|
9
|
+
readonly end_session_endpoint: string;
|
|
10
|
+
readonly jwks_uri: string;
|
|
11
|
+
}
|
|
12
|
+
/** Drop the memoised documents. Exists for tests; nothing in production calls it. */
|
|
13
|
+
export declare const clearDiscoveryCache: () => void;
|
|
14
|
+
/** Fetch (or replay) the discovery document for an issuer. */
|
|
15
|
+
export declare const discover: (issuer: string) => Effect.Effect<OidcDiscovery, EntifixConnError>;
|
|
16
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../src/oidc/discovery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAuBD,qFAAqF;AACrF,eAAO,MAAM,mBAAmB,QAAO,IAEtC,CAAC;AAuBF,8DAA8D;AAC9D,eAAO,MAAM,QAAQ,GACnB,QAAQ,MAAM,KACb,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,gBAAgB,CAmB5C,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { EntifixConnError } from '@entifix/core';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
const REQUIRED_FIELDS = [
|
|
4
|
+
'issuer',
|
|
5
|
+
'authorization_endpoint',
|
|
6
|
+
'token_endpoint',
|
|
7
|
+
'userinfo_endpoint',
|
|
8
|
+
'end_session_endpoint',
|
|
9
|
+
'jwks_uri'
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Discovery documents are fetched once per issuer and kept for the process's
|
|
13
|
+
* life.
|
|
14
|
+
*
|
|
15
|
+
* They describe an instance's shape, not its state, so re-fetching per sign-in
|
|
16
|
+
* would put a network round trip on the login path to learn something that
|
|
17
|
+
* cannot have changed. A restart is the invalidation, which is the right
|
|
18
|
+
* granularity for a value that only moves when the identity provider is
|
|
19
|
+
* redeployed.
|
|
20
|
+
*/ const cache = new Map();
|
|
21
|
+
/** Drop the memoised documents. Exists for tests; nothing in production calls it. */ export const clearDiscoveryCache = ()=>{
|
|
22
|
+
cache.clear();
|
|
23
|
+
};
|
|
24
|
+
const load = async (issuer)=>{
|
|
25
|
+
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
26
|
+
const response = await fetch(url, {
|
|
27
|
+
headers: {
|
|
28
|
+
accept: 'application/json'
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
throw new Error(`${url} answered ${String(response.status)}`);
|
|
33
|
+
}
|
|
34
|
+
const document = await response.json();
|
|
35
|
+
// A document missing an endpoint would otherwise surface much later as
|
|
36
|
+
// `fetch(undefined)` in the middle of a sign-in, where the cause is invisible.
|
|
37
|
+
const missing = REQUIRED_FIELDS.filter((field)=>typeof document[field] !== 'string');
|
|
38
|
+
if (missing.length > 0) {
|
|
39
|
+
throw new Error(`${url} is missing: ${missing.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
return document;
|
|
42
|
+
};
|
|
43
|
+
/** Fetch (or replay) the discovery document for an issuer. */ export const discover = (issuer)=>Effect.tryPromise({
|
|
44
|
+
try: ()=>{
|
|
45
|
+
const cached = cache.get(issuer);
|
|
46
|
+
if (cached !== undefined) return cached;
|
|
47
|
+
const pending = load(issuer).catch((error)=>{
|
|
48
|
+
// A failed fetch must not be memoised: the identity provider is often
|
|
49
|
+
// simply still booting, and caching the rejection would make the first
|
|
50
|
+
// failed sign-in permanent until the service restarts.
|
|
51
|
+
cache.delete(issuer);
|
|
52
|
+
throw error;
|
|
53
|
+
});
|
|
54
|
+
cache.set(issuer, pending);
|
|
55
|
+
return pending;
|
|
56
|
+
},
|
|
57
|
+
catch: (error)=>new EntifixConnError(`cannot read the OIDC discovery document from ${issuer}: ${String(error)}`)
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/oidc/discovery.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport { Effect } from 'effect';\n\n/** The subset of the OIDC discovery document this client uses. */\nexport interface OidcDiscovery {\n readonly issuer: string;\n readonly authorization_endpoint: string;\n readonly token_endpoint: string;\n readonly userinfo_endpoint: string;\n readonly end_session_endpoint: string;\n readonly jwks_uri: string;\n}\n\nconst REQUIRED_FIELDS = [\n 'issuer',\n 'authorization_endpoint',\n 'token_endpoint',\n 'userinfo_endpoint',\n 'end_session_endpoint',\n 'jwks_uri',\n] as const;\n\n/**\n * Discovery documents are fetched once per issuer and kept for the process's\n * life.\n *\n * They describe an instance's shape, not its state, so re-fetching per sign-in\n * would put a network round trip on the login path to learn something that\n * cannot have changed. A restart is the invalidation, which is the right\n * granularity for a value that only moves when the identity provider is\n * redeployed.\n */\nconst cache = new Map<string, Promise<OidcDiscovery>>();\n\n/** Drop the memoised documents. Exists for tests; nothing in production calls it. */\nexport const clearDiscoveryCache = (): void => {\n cache.clear();\n};\n\nconst load = async (issuer: string): Promise<OidcDiscovery> => {\n const url = `${issuer.replace(/\\/$/, '')}/.well-known/openid-configuration`;\n const response = await fetch(url, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) {\n throw new Error(`${url} answered ${String(response.status)}`);\n }\n const document = (await response.json()) as Partial<OidcDiscovery>;\n\n // A document missing an endpoint would otherwise surface much later as\n // `fetch(undefined)` in the middle of a sign-in, where the cause is invisible.\n const missing = REQUIRED_FIELDS.filter(\n field => typeof document[field] !== 'string',\n );\n if (missing.length > 0) {\n throw new Error(`${url} is missing: ${missing.join(', ')}`);\n }\n return document as OidcDiscovery;\n};\n\n/** Fetch (or replay) the discovery document for an issuer. */\nexport const discover = (\n issuer: string,\n): Effect.Effect<OidcDiscovery, EntifixConnError> =>\n Effect.tryPromise({\n try: () => {\n const cached = cache.get(issuer);\n if (cached !== undefined) return cached;\n const pending = load(issuer).catch((error: unknown) => {\n // A failed fetch must not be memoised: the identity provider is often\n // simply still booting, and caching the rejection would make the first\n // failed sign-in permanent until the service restarts.\n cache.delete(issuer);\n throw error;\n });\n cache.set(issuer, pending);\n return pending;\n },\n catch: error =>\n new EntifixConnError(\n `cannot read the OIDC discovery document from ${issuer}: ${String(error)}`,\n ),\n });\n"],"names":["EntifixConnError","Effect","REQUIRED_FIELDS","cache","Map","clearDiscoveryCache","clear","load","issuer","url","replace","response","fetch","headers","accept","ok","Error","String","status","document","json","missing","filter","field","length","join","discover","tryPromise","try","cached","get","undefined","pending","catch","error","delete","set"],"mappings":"AAAA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,MAAM,QAAQ,SAAS;AAYhC,MAAMC,kBAAkB;IACtB;IACA;IACA;IACA;IACA;IACA;CACD;AAED;;;;;;;;;CASC,GACD,MAAMC,QAAQ,IAAIC;AAElB,mFAAmF,GACnF,OAAO,MAAMC,sBAAsB;IACjCF,MAAMG,KAAK;AACb,EAAE;AAEF,MAAMC,OAAO,OAAOC;IAClB,MAAMC,MAAM,GAAGD,OAAOE,OAAO,CAAC,OAAO,IAAI,iCAAiC,CAAC;IAC3E,MAAMC,WAAW,MAAMC,MAAMH,KAAK;QAChCI,SAAS;YAAEC,QAAQ;QAAmB;IACxC;IACA,IAAI,CAACH,SAASI,EAAE,EAAE;QAChB,MAAM,IAAIC,MAAM,GAAGP,IAAI,UAAU,EAAEQ,OAAON,SAASO,MAAM,GAAG;IAC9D;IACA,MAAMC,WAAY,MAAMR,SAASS,IAAI;IAErC,uEAAuE;IACvE,+EAA+E;IAC/E,MAAMC,UAAUnB,gBAAgBoB,MAAM,CACpCC,CAAAA,QAAS,OAAOJ,QAAQ,CAACI,MAAM,KAAK;IAEtC,IAAIF,QAAQG,MAAM,GAAG,GAAG;QACtB,MAAM,IAAIR,MAAM,GAAGP,IAAI,aAAa,EAAEY,QAAQI,IAAI,CAAC,OAAO;IAC5D;IACA,OAAON;AACT;AAEA,4DAA4D,GAC5D,OAAO,MAAMO,WAAW,CACtBlB,SAEAP,OAAO0B,UAAU,CAAC;QAChBC,KAAK;YACH,MAAMC,SAAS1B,MAAM2B,GAAG,CAACtB;YACzB,IAAIqB,WAAWE,WAAW,OAAOF;YACjC,MAAMG,UAAUzB,KAAKC,QAAQyB,KAAK,CAAC,CAACC;gBAClC,sEAAsE;gBACtE,uEAAuE;gBACvE,uDAAuD;gBACvD/B,MAAMgC,MAAM,CAAC3B;gBACb,MAAM0B;YACR;YACA/B,MAAMiC,GAAG,CAAC5B,QAAQwB;YAClB,OAAOA;QACT;QACAC,OAAOC,CAAAA,QACL,IAAIlC,iBACF,CAAC,6CAA6C,EAAEQ,OAAO,EAAE,EAAES,OAAOiB,QAAQ;IAEhF,GAAG"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { EntifixConnError, EntifixLogicError } from '@entifix/core';
|
|
2
|
+
import { Context, Effect, Layer } from 'effect';
|
|
3
|
+
/** How this client is pointed at an instance. Resolved from config-service at boot. */
|
|
4
|
+
export interface ZitadelOidcConfig {
|
|
5
|
+
/** e.g. `http://localhost:30080`. Must match the `iss` of every token issued. */
|
|
6
|
+
readonly issuer: string;
|
|
7
|
+
/** The public OIDC app's client id, created by `tools/zitadel-seed.mjs`. */
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
/** Where the hosted UI sends the browser back — an app origin, never a service. */
|
|
10
|
+
readonly redirectUri: string;
|
|
11
|
+
/** Where a completed sign-out lands. */
|
|
12
|
+
readonly postLogoutRedirectUri: string;
|
|
13
|
+
/**
|
|
14
|
+
* Requested scopes. `openid` alone would authenticate without telling us who,
|
|
15
|
+
* and the projection needs a verified email to key an account off.
|
|
16
|
+
*/
|
|
17
|
+
readonly scopes?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
/** The one algorithm an `id_token` may be signed with. See {@link makeZitadelOidc}. */
|
|
20
|
+
export declare const ID_TOKEN_ALGORITHM = "RS256";
|
|
21
|
+
/**
|
|
22
|
+
* The event a back-channel logout token must declare, from OpenID Connect
|
|
23
|
+
* Back-Channel Logout 1.0 §2.4. A JWT that verifies but does not carry it is
|
|
24
|
+
* some other token the provider issued, not a request to end a session.
|
|
25
|
+
*/
|
|
26
|
+
export declare const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
27
|
+
/**
|
|
28
|
+
* What the caller must have decided before a browser can be sent anywhere.
|
|
29
|
+
*
|
|
30
|
+
* The first three are minted by the caller rather than here, because all three
|
|
31
|
+
* have to be *stored* before the redirect and read back after it — and where
|
|
32
|
+
* they are stored (Redis, with a TTL and single-use semantics) is a composition
|
|
33
|
+
* concern this client has no business knowing about.
|
|
34
|
+
*/
|
|
35
|
+
export interface AuthorizationUrlInput {
|
|
36
|
+
/** CSRF handle; also the key the pending request is stored under. */
|
|
37
|
+
readonly state: string;
|
|
38
|
+
/** Binds the returned `id_token` to this request. */
|
|
39
|
+
readonly nonce: string;
|
|
40
|
+
/** The public half of the PKCE pair, from {@link createPkcePair}. */
|
|
41
|
+
readonly codeChallenge: string;
|
|
42
|
+
/**
|
|
43
|
+
* Which language the hosted login should render in — a space-separated list
|
|
44
|
+
* of BCP 47 tags, most preferred first, as `ui_locales` (OIDC Core §3.1.2.1).
|
|
45
|
+
*
|
|
46
|
+
* **Already validated by the caller.** This client has no opinion on which
|
|
47
|
+
* locales exist: the fleet's list lives in `@entifix/i18n`, and the
|
|
48
|
+
* value arrives here from a browser cookie, so whoever reads that cookie is
|
|
49
|
+
* the one that must check it against the list rather than echo it into a
|
|
50
|
+
* redirect URL.
|
|
51
|
+
*
|
|
52
|
+
* Optional, and omitted from the URL when absent rather than defaulted: a
|
|
53
|
+
* caller with no locale to state should leave the provider to negotiate from
|
|
54
|
+
* `Accept-Language`, which is what it does with no parameter at all.
|
|
55
|
+
*/
|
|
56
|
+
readonly uiLocales?: string;
|
|
57
|
+
}
|
|
58
|
+
/** Who Zitadel says signed in, reduced to what r10c projects onto its own record. */
|
|
59
|
+
export interface ZitadelIdentity {
|
|
60
|
+
/** The stable `sub`. Recorded as an `external-subject` identifier, never as a key. */
|
|
61
|
+
readonly subject: string;
|
|
62
|
+
readonly email?: string;
|
|
63
|
+
readonly emailVerified: boolean;
|
|
64
|
+
readonly preferredUsername?: string;
|
|
65
|
+
readonly displayName?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The provider's own session id (`sid`), when it issues one.
|
|
68
|
+
*
|
|
69
|
+
* It is what a back-channel logout token names, and our session id is our
|
|
70
|
+
* own — so unless this is recorded against the session it opens, a logout at
|
|
71
|
+
* the provider has nothing here to point at. Read from the **verified
|
|
72
|
+
* id_token** only; a userinfo response must never be able to supply it.
|
|
73
|
+
*/
|
|
74
|
+
readonly providerSessionId?: string;
|
|
75
|
+
/** Needed to end the session at the provider; opaque to us otherwise. */
|
|
76
|
+
readonly idToken: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* What a verified back-channel logout token asks for: end the session named by
|
|
80
|
+
* `sid`, or every session belonging to `sub`. The spec requires at least one,
|
|
81
|
+
* and providers differ on which they send.
|
|
82
|
+
*/
|
|
83
|
+
export interface LogoutEvent {
|
|
84
|
+
readonly providerSessionId?: string;
|
|
85
|
+
readonly subject?: string;
|
|
86
|
+
}
|
|
87
|
+
export interface ZitadelOidc {
|
|
88
|
+
/** Where to send the browser to begin a sign-in. */
|
|
89
|
+
authorizationUrl(input: AuthorizationUrlInput): Effect.Effect<string, EntifixConnError>;
|
|
90
|
+
/** Finish it: exchange the code, verify the `id_token`, and say who this is. */
|
|
91
|
+
exchangeCode(input: {
|
|
92
|
+
readonly code: string;
|
|
93
|
+
readonly codeVerifier: string;
|
|
94
|
+
readonly nonce: string;
|
|
95
|
+
}): Effect.Effect<ZitadelIdentity, EntifixConnError | EntifixLogicError>;
|
|
96
|
+
/** Where to send the browser so the provider's own session ends too. */
|
|
97
|
+
endSessionUrl(idToken?: string): Effect.Effect<string, EntifixConnError>;
|
|
98
|
+
/**
|
|
99
|
+
* The other direction: the provider telling us a session it owns has ended.
|
|
100
|
+
* Verifies the logout token and says which of our sessions it names.
|
|
101
|
+
*/
|
|
102
|
+
verifyLogoutToken(logoutToken: string): Effect.Effect<LogoutEvent, EntifixConnError | EntifixLogicError>;
|
|
103
|
+
}
|
|
104
|
+
declare const ZitadelOidcTag_base: Context.TagClass<ZitadelOidcTag, "ZitadelOidcTag", ZitadelOidc>;
|
|
105
|
+
export declare class ZitadelOidcTag extends ZitadelOidcTag_base {
|
|
106
|
+
}
|
|
107
|
+
/** Drop the memoised key sets. Exists for tests; nothing in production calls it. */
|
|
108
|
+
export declare const clearJwksCache: () => void;
|
|
109
|
+
/**
|
|
110
|
+
* An OIDC client for a Zitadel instance, authorization-code + PKCE.
|
|
111
|
+
*
|
|
112
|
+
* The client is registered **public** (no secret), so the code exchange is
|
|
113
|
+
* protected by PKCE alone. That is deliberate: a secret would be one more
|
|
114
|
+
* credential to distribute through config-service and one more to leak, and it
|
|
115
|
+
* would add nothing, because the exchange already proves possession of a
|
|
116
|
+
* verifier that never left the server.
|
|
117
|
+
*/
|
|
118
|
+
export declare const makeZitadelOidc: (config: ZitadelOidcConfig) => ZitadelOidc;
|
|
119
|
+
/** Binds {@link ZitadelOidcTag} from a resolved configuration. */
|
|
120
|
+
export declare const ZitadelOidcLayer: (config: ZitadelOidcConfig) => Layer.Layer<ZitadelOidcTag>;
|
|
121
|
+
export {};
|
|
122
|
+
//# sourceMappingURL=oidc-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oidc-client.d.ts","sourceRoot":"","sources":["../../src/oidc/oidc-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { EntifixConnError, EntifixLogicError } from '@entifix/core';
|
|
3
|
+
import { Context, Effect, Layer } from 'effect';
|
|
4
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
5
|
+
import { discover } from '../oidc/discovery.js';
|
|
6
|
+
import { CODE_CHALLENGE_METHOD } from '../oidc/pkce.js';
|
|
7
|
+
const DEFAULT_SCOPES = [
|
|
8
|
+
'openid',
|
|
9
|
+
'profile',
|
|
10
|
+
'email'
|
|
11
|
+
];
|
|
12
|
+
/** The one algorithm an `id_token` may be signed with. See {@link makeZitadelOidc}. */ export const ID_TOKEN_ALGORITHM = 'RS256';
|
|
13
|
+
/**
|
|
14
|
+
* The event a back-channel logout token must declare, from OpenID Connect
|
|
15
|
+
* Back-Channel Logout 1.0 §2.4. A JWT that verifies but does not carry it is
|
|
16
|
+
* some other token the provider issued, not a request to end a session.
|
|
17
|
+
*/ export const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
|
18
|
+
export class ZitadelOidcTag extends Context.Tag('ZitadelOidcTag')() {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* JWKS is fetched lazily and cached per issuer, with jose handling rotation:
|
|
22
|
+
* an unknown `kid` triggers one refetch rather than trusting the token.
|
|
23
|
+
*/ const jwksCache = new Map();
|
|
24
|
+
/** Drop the memoised key sets. Exists for tests; nothing in production calls it. */ export const clearJwksCache = ()=>{
|
|
25
|
+
jwksCache.clear();
|
|
26
|
+
};
|
|
27
|
+
const jwksFor = (discovery)=>{
|
|
28
|
+
const cached = jwksCache.get(discovery.jwks_uri);
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
30
|
+
const created = createRemoteJWKSet(new URL(discovery.jwks_uri));
|
|
31
|
+
jwksCache.set(discovery.jwks_uri, created);
|
|
32
|
+
return created;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* An OIDC client for a Zitadel instance, authorization-code + PKCE.
|
|
36
|
+
*
|
|
37
|
+
* The client is registered **public** (no secret), so the code exchange is
|
|
38
|
+
* protected by PKCE alone. That is deliberate: a secret would be one more
|
|
39
|
+
* credential to distribute through config-service and one more to leak, and it
|
|
40
|
+
* would add nothing, because the exchange already proves possession of a
|
|
41
|
+
* verifier that never left the server.
|
|
42
|
+
*/ export const makeZitadelOidc = (config)=>{
|
|
43
|
+
var _config_scopes;
|
|
44
|
+
const scopes = ((_config_scopes = config.scopes) != null ? _config_scopes : DEFAULT_SCOPES).join(' ');
|
|
45
|
+
const authorizationUrl = (input)=>discover(config.issuer).pipe(Effect.map((discovery)=>{
|
|
46
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
47
|
+
url.searchParams.set('client_id', config.clientId);
|
|
48
|
+
url.searchParams.set('redirect_uri', config.redirectUri);
|
|
49
|
+
url.searchParams.set('response_type', 'code');
|
|
50
|
+
url.searchParams.set('scope', scopes);
|
|
51
|
+
url.searchParams.set('code_challenge', input.codeChallenge);
|
|
52
|
+
url.searchParams.set('code_challenge_method', CODE_CHALLENGE_METHOD);
|
|
53
|
+
url.searchParams.set('state', input.state);
|
|
54
|
+
url.searchParams.set('nonce', input.nonce);
|
|
55
|
+
if (input.uiLocales !== undefined) {
|
|
56
|
+
url.searchParams.set('ui_locales', input.uiLocales);
|
|
57
|
+
}
|
|
58
|
+
return url.toString();
|
|
59
|
+
}));
|
|
60
|
+
const requestTokens = (discovery, code, codeVerifier)=>Effect.tryPromise({
|
|
61
|
+
try: async ()=>{
|
|
62
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
'content-type': 'application/x-www-form-urlencoded'
|
|
66
|
+
},
|
|
67
|
+
body: new URLSearchParams({
|
|
68
|
+
grant_type: 'authorization_code',
|
|
69
|
+
code,
|
|
70
|
+
redirect_uri: config.redirectUri,
|
|
71
|
+
client_id: config.clientId,
|
|
72
|
+
code_verifier: codeVerifier
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
const payload = await response.json();
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
var _ref, _payload_error_description;
|
|
78
|
+
throw new Error((_ref = (_payload_error_description = payload.error_description) != null ? _payload_error_description : payload.error) != null ? _ref : `token endpoint answered ${String(response.status)}`);
|
|
79
|
+
}
|
|
80
|
+
return payload;
|
|
81
|
+
},
|
|
82
|
+
catch: (error)=>new EntifixConnError(`the code exchange failed: ${String(error)}`)
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* The security boundary of this file, and the only place either token kind is
|
|
86
|
+
* verified. Without the pinned `algorithms` jose honours the token's own `alg`
|
|
87
|
+
* header, and a JWKS key served openly would be accepted as an HMAC secret —
|
|
88
|
+
* the same alg-confusion hole ADR 0015 closed on our own tokens. Written once
|
|
89
|
+
* so a second verifier cannot be added without it.
|
|
90
|
+
*/ const verifyProviderJwt = (discovery, token, what)=>Effect.tryPromise({
|
|
91
|
+
try: ()=>jwtVerify(token, jwksFor(discovery), {
|
|
92
|
+
algorithms: [
|
|
93
|
+
ID_TOKEN_ALGORITHM
|
|
94
|
+
],
|
|
95
|
+
issuer: discovery.issuer,
|
|
96
|
+
audience: config.clientId
|
|
97
|
+
}),
|
|
98
|
+
catch: (error)=>new EntifixLogicError(`the ${what} did not verify: ${String(error)}`)
|
|
99
|
+
});
|
|
100
|
+
const verifyIdToken = (discovery, idToken, nonce)=>verifyProviderJwt(discovery, idToken, 'id_token').pipe(Effect.flatMap(({ payload })=>// Checked here rather than left to jose: a replayed `id_token` from an
|
|
101
|
+
// older flow would otherwise verify perfectly.
|
|
102
|
+
payload['nonce'] === nonce ? Effect.succeed(payload) : Effect.fail(new EntifixLogicError('the id_token nonce does not match the authorization request'))));
|
|
103
|
+
/**
|
|
104
|
+
* The profile claims, from the userinfo endpoint.
|
|
105
|
+
*
|
|
106
|
+
* They are fetched rather than read off the `id_token` because that is where
|
|
107
|
+
* OIDC actually puts them: `email`, `email_verified` and `name` reach an
|
|
108
|
+
* id_token only if the provider is configured to inline userinfo, and Zitadel
|
|
109
|
+
* does not do that by default. Reading the id_token alone therefore produced a
|
|
110
|
+
* verified sign-in with no email and no display name at all — the projection
|
|
111
|
+
* had nothing to write, silently.
|
|
112
|
+
*
|
|
113
|
+
* Best-effort on purpose: the `id_token` is what proves *who* this is, and it
|
|
114
|
+
* has already been verified by the time this runs. A userinfo outage should
|
|
115
|
+
* cost a stale display name, never a sign-in.
|
|
116
|
+
*/ const fetchUserInfo = (discovery, accessToken)=>accessToken === undefined ? Effect.succeed({}) : Effect.tryPromise({
|
|
117
|
+
try: async ()=>{
|
|
118
|
+
const response = await fetch(discovery.userinfo_endpoint, {
|
|
119
|
+
headers: {
|
|
120
|
+
authorization: `Bearer ${accessToken}`
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new Error(`userinfo answered ${String(response.status)}`);
|
|
125
|
+
}
|
|
126
|
+
return await response.json();
|
|
127
|
+
},
|
|
128
|
+
catch: (error)=>new EntifixConnError(String(error))
|
|
129
|
+
}).pipe(Effect.catchAll(()=>Effect.succeed({})));
|
|
130
|
+
const exchangeCode = (input)=>discover(config.issuer).pipe(Effect.flatMap((discovery)=>requestTokens(discovery, input.code, input.codeVerifier).pipe(Effect.flatMap((tokens)=>tokens.id_token === undefined ? Effect.fail(new EntifixLogicError('the token response carried no id_token')) : verifyIdToken(discovery, tokens.id_token, input.nonce).pipe(Effect.flatMap((claims)=>fetchUserInfo(discovery, tokens.access_token).pipe(// The id_token wins on `sub`: it is the verified one, and
|
|
131
|
+
// a userinfo response naming a different subject must not
|
|
132
|
+
// be able to redirect the sign-in to another account. Same
|
|
133
|
+
// for `sid`, which decides whose sessions a later logout
|
|
134
|
+
// token revokes.
|
|
135
|
+
Effect.map((profile)=>toIdentity(_extends({}, profile, claims, {
|
|
136
|
+
sub: claims.sub,
|
|
137
|
+
sid: claims['sid']
|
|
138
|
+
}), tokens.id_token)))))))));
|
|
139
|
+
const endSessionUrl = (idToken)=>discover(config.issuer).pipe(Effect.map((discovery)=>{
|
|
140
|
+
const url = new URL(discovery.end_session_endpoint);
|
|
141
|
+
url.searchParams.set('post_logout_redirect_uri', config.postLogoutRedirectUri);
|
|
142
|
+
// Zitadel needs the hint to know *which* session to end; without it the
|
|
143
|
+
// browser is asked to confirm, which turns sign-out into a dead end.
|
|
144
|
+
if (idToken !== undefined) {
|
|
145
|
+
url.searchParams.set('id_token_hint', idToken);
|
|
146
|
+
}
|
|
147
|
+
url.searchParams.set('client_id', config.clientId);
|
|
148
|
+
return url.toString();
|
|
149
|
+
}));
|
|
150
|
+
/**
|
|
151
|
+
* A logout token verifies exactly like an `id_token` — same key set, same
|
|
152
|
+
* issuer, same audience — and then has to pass three checks that are the whole
|
|
153
|
+
* difference between the two (OIDC Back-Channel Logout 1.0 §2.6).
|
|
154
|
+
*
|
|
155
|
+
* The `nonce` check is the load-bearing one, and it is why this cannot simply
|
|
156
|
+
* reuse {@link verifyIdToken}: without it a stolen `id_token` POSTed to the
|
|
157
|
+
* back-channel endpoint would verify perfectly and sign its owner out — or,
|
|
158
|
+
* once this endpoint does anything less idempotent than a revoke, worse.
|
|
159
|
+
*/ const verifyLogoutToken = (logoutToken)=>discover(config.issuer).pipe(Effect.flatMap((discovery)=>verifyProviderJwt(discovery, logoutToken, 'logout_token')), Effect.flatMap(({ payload })=>{
|
|
160
|
+
const events = payload['events'];
|
|
161
|
+
if (typeof events !== 'object' || events === null || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
|
|
162
|
+
return Effect.fail(new EntifixLogicError('the logout_token does not declare a back-channel logout event'));
|
|
163
|
+
}
|
|
164
|
+
if (payload['nonce'] !== undefined) {
|
|
165
|
+
return Effect.fail(new EntifixLogicError('the logout_token carries a nonce, so it is an id_token replayed'));
|
|
166
|
+
}
|
|
167
|
+
const providerSessionId = asString(payload['sid']);
|
|
168
|
+
const subject = asString(payload.sub);
|
|
169
|
+
if (providerSessionId === undefined && subject === undefined) {
|
|
170
|
+
return Effect.fail(new EntifixLogicError('the logout_token names neither sid nor sub'));
|
|
171
|
+
}
|
|
172
|
+
return Effect.succeed({
|
|
173
|
+
providerSessionId,
|
|
174
|
+
subject
|
|
175
|
+
});
|
|
176
|
+
}));
|
|
177
|
+
return {
|
|
178
|
+
authorizationUrl,
|
|
179
|
+
exchangeCode,
|
|
180
|
+
endSessionUrl,
|
|
181
|
+
verifyLogoutToken
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
const asString = (value)=>typeof value === 'string' && value !== '' ? value : undefined;
|
|
185
|
+
const toIdentity = (claims, idToken)=>({
|
|
186
|
+
subject: String(claims.sub),
|
|
187
|
+
email: asString(claims['email']),
|
|
188
|
+
// Absent means unproven. An unverified address must never be treated as one,
|
|
189
|
+
// because linking on it is the classic account-takeover vector.
|
|
190
|
+
emailVerified: claims['email_verified'] === true,
|
|
191
|
+
preferredUsername: asString(claims['preferred_username']),
|
|
192
|
+
displayName: asString(claims['name']),
|
|
193
|
+
providerSessionId: asString(claims['sid']),
|
|
194
|
+
idToken
|
|
195
|
+
});
|
|
196
|
+
/** Binds {@link ZitadelOidcTag} from a resolved configuration. */ export const ZitadelOidcLayer = (config)=>Layer.succeed(ZitadelOidcTag, makeZitadelOidc(config));
|
|
197
|
+
|
|
198
|
+
//# sourceMappingURL=oidc-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/oidc/oidc-client.ts"],"sourcesContent":["import { EntifixConnError, EntifixLogicError } from '@entifix/core';\nimport { Context, Effect, Layer } from 'effect';\nimport { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose';\n\nimport { discover, type OidcDiscovery } from '../oidc/discovery.js';\nimport { CODE_CHALLENGE_METHOD } from '../oidc/pkce.js';\n\n/** How this client is pointed at an instance. Resolved from config-service at boot. */\nexport interface ZitadelOidcConfig {\n /** e.g. `http://localhost:30080`. Must match the `iss` of every token issued. */\n readonly issuer: string;\n /** The public OIDC app's client id, created by `tools/zitadel-seed.mjs`. */\n readonly clientId: string;\n /** Where the hosted UI sends the browser back — an app origin, never a service. */\n readonly redirectUri: string;\n /** Where a completed sign-out lands. */\n readonly postLogoutRedirectUri: string;\n /**\n * Requested scopes. `openid` alone would authenticate without telling us who,\n * and the projection needs a verified email to key an account off.\n */\n readonly scopes?: readonly string[];\n}\n\nconst DEFAULT_SCOPES = ['openid', 'profile', 'email'] as const;\n\n/** The one algorithm an `id_token` may be signed with. See {@link makeZitadelOidc}. */\nexport const ID_TOKEN_ALGORITHM = 'RS256';\n\n/**\n * The event a back-channel logout token must declare, from OpenID Connect\n * Back-Channel Logout 1.0 §2.4. A JWT that verifies but does not carry it is\n * some other token the provider issued, not a request to end a session.\n */\nexport const BACKCHANNEL_LOGOUT_EVENT =\n 'http://schemas.openid.net/event/backchannel-logout';\n\n/**\n * What the caller must have decided before a browser can be sent anywhere.\n *\n * The first three are minted by the caller rather than here, because all three\n * have to be *stored* before the redirect and read back after it — and where\n * they are stored (Redis, with a TTL and single-use semantics) is a composition\n * concern this client has no business knowing about.\n */\nexport interface AuthorizationUrlInput {\n /** CSRF handle; also the key the pending request is stored under. */\n readonly state: string;\n /** Binds the returned `id_token` to this request. */\n readonly nonce: string;\n /** The public half of the PKCE pair, from {@link createPkcePair}. */\n readonly codeChallenge: string;\n /**\n * Which language the hosted login should render in — a space-separated list\n * of BCP 47 tags, most preferred first, as `ui_locales` (OIDC Core §3.1.2.1).\n *\n * **Already validated by the caller.** This client has no opinion on which\n * locales exist: the fleet's list lives in `@entifix/i18n`, and the\n * value arrives here from a browser cookie, so whoever reads that cookie is\n * the one that must check it against the list rather than echo it into a\n * redirect URL.\n *\n * Optional, and omitted from the URL when absent rather than defaulted: a\n * caller with no locale to state should leave the provider to negotiate from\n * `Accept-Language`, which is what it does with no parameter at all.\n */\n readonly uiLocales?: string;\n}\n\n/** Who Zitadel says signed in, reduced to what r10c projects onto its own record. */\nexport interface ZitadelIdentity {\n /** The stable `sub`. Recorded as an `external-subject` identifier, never as a key. */\n readonly subject: string;\n readonly email?: string;\n readonly emailVerified: boolean;\n readonly preferredUsername?: string;\n readonly displayName?: string;\n /**\n * The provider's own session id (`sid`), when it issues one.\n *\n * It is what a back-channel logout token names, and our session id is our\n * own — so unless this is recorded against the session it opens, a logout at\n * the provider has nothing here to point at. Read from the **verified\n * id_token** only; a userinfo response must never be able to supply it.\n */\n readonly providerSessionId?: string;\n /** Needed to end the session at the provider; opaque to us otherwise. */\n readonly idToken: string;\n}\n\n/**\n * What a verified back-channel logout token asks for: end the session named by\n * `sid`, or every session belonging to `sub`. The spec requires at least one,\n * and providers differ on which they send.\n */\nexport interface LogoutEvent {\n readonly providerSessionId?: string;\n readonly subject?: string;\n}\n\nexport interface ZitadelOidc {\n /** Where to send the browser to begin a sign-in. */\n authorizationUrl(\n input: AuthorizationUrlInput,\n ): Effect.Effect<string, EntifixConnError>;\n /** Finish it: exchange the code, verify the `id_token`, and say who this is. */\n exchangeCode(input: {\n readonly code: string;\n readonly codeVerifier: string;\n readonly nonce: string;\n }): Effect.Effect<ZitadelIdentity, EntifixConnError | EntifixLogicError>;\n /** Where to send the browser so the provider's own session ends too. */\n endSessionUrl(idToken?: string): Effect.Effect<string, EntifixConnError>;\n /**\n * The other direction: the provider telling us a session it owns has ended.\n * Verifies the logout token and says which of our sessions it names.\n */\n verifyLogoutToken(\n logoutToken: string,\n ): Effect.Effect<LogoutEvent, EntifixConnError | EntifixLogicError>;\n}\n\nexport class ZitadelOidcTag extends Context.Tag('ZitadelOidcTag')<\n ZitadelOidcTag,\n ZitadelOidc\n>() {}\n\n/**\n * JWKS is fetched lazily and cached per issuer, with jose handling rotation:\n * an unknown `kid` triggers one refetch rather than trusting the token.\n */\nconst jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\n/** Drop the memoised key sets. Exists for tests; nothing in production calls it. */\nexport const clearJwksCache = (): void => {\n jwksCache.clear();\n};\n\nconst jwksFor = (discovery: OidcDiscovery) => {\n const cached = jwksCache.get(discovery.jwks_uri);\n if (cached !== undefined) return cached;\n const created = createRemoteJWKSet(new URL(discovery.jwks_uri));\n jwksCache.set(discovery.jwks_uri, created);\n return created;\n};\n\ninterface TokenResponse {\n readonly id_token?: string;\n readonly access_token?: string;\n}\n\n/**\n * An OIDC client for a Zitadel instance, authorization-code + PKCE.\n *\n * The client is registered **public** (no secret), so the code exchange is\n * protected by PKCE alone. That is deliberate: a secret would be one more\n * credential to distribute through config-service and one more to leak, and it\n * would add nothing, because the exchange already proves possession of a\n * verifier that never left the server.\n */\nexport const makeZitadelOidc = (config: ZitadelOidcConfig): ZitadelOidc => {\n const scopes = (config.scopes ?? DEFAULT_SCOPES).join(' ');\n\n const authorizationUrl = (input: AuthorizationUrlInput) =>\n discover(config.issuer).pipe(\n Effect.map(discovery => {\n const url = new URL(discovery.authorization_endpoint);\n url.searchParams.set('client_id', config.clientId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', scopes);\n url.searchParams.set('code_challenge', input.codeChallenge);\n url.searchParams.set('code_challenge_method', CODE_CHALLENGE_METHOD);\n url.searchParams.set('state', input.state);\n url.searchParams.set('nonce', input.nonce);\n if (input.uiLocales !== undefined) {\n url.searchParams.set('ui_locales', input.uiLocales);\n }\n return url.toString();\n }),\n );\n\n const requestTokens = (\n discovery: OidcDiscovery,\n code: string,\n codeVerifier: string,\n ) =>\n Effect.tryPromise({\n try: async () => {\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code,\n redirect_uri: config.redirectUri,\n client_id: config.clientId,\n code_verifier: codeVerifier,\n }),\n });\n const payload = (await response.json()) as TokenResponse & {\n error?: string;\n error_description?: string;\n };\n if (!response.ok) {\n throw new Error(\n payload.error_description ??\n payload.error ??\n `token endpoint answered ${String(response.status)}`,\n );\n }\n return payload;\n },\n catch: error =>\n new EntifixConnError(`the code exchange failed: ${String(error)}`),\n });\n\n /**\n * The security boundary of this file, and the only place either token kind is\n * verified. Without the pinned `algorithms` jose honours the token's own `alg`\n * header, and a JWKS key served openly would be accepted as an HMAC secret —\n * the same alg-confusion hole ADR 0015 closed on our own tokens. Written once\n * so a second verifier cannot be added without it.\n */\n const verifyProviderJwt = (\n discovery: OidcDiscovery,\n token: string,\n what: string,\n ) =>\n Effect.tryPromise({\n try: () =>\n jwtVerify(token, jwksFor(discovery), {\n algorithms: [ID_TOKEN_ALGORITHM],\n issuer: discovery.issuer,\n audience: config.clientId,\n }),\n catch: error =>\n new EntifixLogicError(`the ${what} did not verify: ${String(error)}`),\n });\n\n const verifyIdToken = (\n discovery: OidcDiscovery,\n idToken: string,\n nonce: string,\n ) =>\n verifyProviderJwt(discovery, idToken, 'id_token').pipe(\n Effect.flatMap(({ payload }) =>\n // Checked here rather than left to jose: a replayed `id_token` from an\n // older flow would otherwise verify perfectly.\n payload['nonce'] === nonce\n ? Effect.succeed(payload)\n : Effect.fail(\n new EntifixLogicError(\n 'the id_token nonce does not match the authorization request',\n ),\n ),\n ),\n );\n\n /**\n * The profile claims, from the userinfo endpoint.\n *\n * They are fetched rather than read off the `id_token` because that is where\n * OIDC actually puts them: `email`, `email_verified` and `name` reach an\n * id_token only if the provider is configured to inline userinfo, and Zitadel\n * does not do that by default. Reading the id_token alone therefore produced a\n * verified sign-in with no email and no display name at all — the projection\n * had nothing to write, silently.\n *\n * Best-effort on purpose: the `id_token` is what proves *who* this is, and it\n * has already been verified by the time this runs. A userinfo outage should\n * cost a stale display name, never a sign-in.\n */\n const fetchUserInfo = (discovery: OidcDiscovery, accessToken?: string) =>\n accessToken === undefined\n ? Effect.succeed({} as JWTPayload)\n : Effect.tryPromise({\n try: async () => {\n const response = await fetch(discovery.userinfo_endpoint, {\n headers: { authorization: `Bearer ${accessToken}` },\n });\n if (!response.ok) {\n throw new Error(`userinfo answered ${String(response.status)}`);\n }\n return (await response.json()) as JWTPayload;\n },\n catch: error => new EntifixConnError(String(error)),\n }).pipe(Effect.catchAll(() => Effect.succeed({} as JWTPayload)));\n\n const exchangeCode = (input: {\n readonly code: string;\n readonly codeVerifier: string;\n readonly nonce: string;\n }) =>\n discover(config.issuer).pipe(\n Effect.flatMap(discovery =>\n requestTokens(discovery, input.code, input.codeVerifier).pipe(\n Effect.flatMap(tokens =>\n tokens.id_token === undefined\n ? Effect.fail(\n new EntifixLogicError(\n 'the token response carried no id_token',\n ),\n )\n : verifyIdToken(discovery, tokens.id_token, input.nonce).pipe(\n Effect.flatMap(claims =>\n fetchUserInfo(discovery, tokens.access_token).pipe(\n // The id_token wins on `sub`: it is the verified one, and\n // a userinfo response naming a different subject must not\n // be able to redirect the sign-in to another account. Same\n // for `sid`, which decides whose sessions a later logout\n // token revokes.\n Effect.map(profile =>\n toIdentity(\n {\n ...profile,\n ...claims,\n sub: claims.sub,\n sid: claims['sid'],\n },\n tokens.id_token as string,\n ),\n ),\n ),\n ),\n ),\n ),\n ),\n ),\n );\n\n const endSessionUrl = (idToken?: string) =>\n discover(config.issuer).pipe(\n Effect.map(discovery => {\n const url = new URL(discovery.end_session_endpoint);\n url.searchParams.set(\n 'post_logout_redirect_uri',\n config.postLogoutRedirectUri,\n );\n // Zitadel needs the hint to know *which* session to end; without it the\n // browser is asked to confirm, which turns sign-out into a dead end.\n if (idToken !== undefined) {\n url.searchParams.set('id_token_hint', idToken);\n }\n url.searchParams.set('client_id', config.clientId);\n return url.toString();\n }),\n );\n\n /**\n * A logout token verifies exactly like an `id_token` — same key set, same\n * issuer, same audience — and then has to pass three checks that are the whole\n * difference between the two (OIDC Back-Channel Logout 1.0 §2.6).\n *\n * The `nonce` check is the load-bearing one, and it is why this cannot simply\n * reuse {@link verifyIdToken}: without it a stolen `id_token` POSTed to the\n * back-channel endpoint would verify perfectly and sign its owner out — or,\n * once this endpoint does anything less idempotent than a revoke, worse.\n */\n const verifyLogoutToken = (logoutToken: string) =>\n discover(config.issuer).pipe(\n Effect.flatMap(discovery =>\n verifyProviderJwt(discovery, logoutToken, 'logout_token'),\n ),\n Effect.flatMap(({ payload }) => {\n const events = payload['events'];\n if (\n typeof events !== 'object' ||\n events === null ||\n !(BACKCHANNEL_LOGOUT_EVENT in events)\n ) {\n return Effect.fail(\n new EntifixLogicError(\n 'the logout_token does not declare a back-channel logout event',\n ),\n );\n }\n if (payload['nonce'] !== undefined) {\n return Effect.fail(\n new EntifixLogicError(\n 'the logout_token carries a nonce, so it is an id_token replayed',\n ),\n );\n }\n const providerSessionId = asString(payload['sid']);\n const subject = asString(payload.sub);\n if (providerSessionId === undefined && subject === undefined) {\n return Effect.fail(\n new EntifixLogicError('the logout_token names neither sid nor sub'),\n );\n }\n return Effect.succeed({\n providerSessionId,\n subject,\n } satisfies LogoutEvent);\n }),\n );\n\n return { authorizationUrl, exchangeCode, endSessionUrl, verifyLogoutToken };\n};\n\nconst asString = (value: unknown): string | undefined =>\n typeof value === 'string' && value !== '' ? value : undefined;\n\nconst toIdentity = (claims: JWTPayload, idToken: string): ZitadelIdentity => ({\n subject: String(claims.sub),\n email: asString(claims['email']),\n // Absent means unproven. An unverified address must never be treated as one,\n // because linking on it is the classic account-takeover vector.\n emailVerified: claims['email_verified'] === true,\n preferredUsername: asString(claims['preferred_username']),\n displayName: asString(claims['name']),\n providerSessionId: asString(claims['sid']),\n idToken,\n});\n\n/** Binds {@link ZitadelOidcTag} from a resolved configuration. */\nexport const ZitadelOidcLayer = (\n config: ZitadelOidcConfig,\n): Layer.Layer<ZitadelOidcTag> =>\n Layer.succeed(ZitadelOidcTag, makeZitadelOidc(config));\n"],"names":["EntifixConnError","EntifixLogicError","Context","Effect","Layer","createRemoteJWKSet","jwtVerify","discover","CODE_CHALLENGE_METHOD","DEFAULT_SCOPES","ID_TOKEN_ALGORITHM","BACKCHANNEL_LOGOUT_EVENT","ZitadelOidcTag","Tag","jwksCache","Map","clearJwksCache","clear","jwksFor","discovery","cached","get","jwks_uri","undefined","created","URL","set","makeZitadelOidc","config","scopes","join","authorizationUrl","input","issuer","pipe","map","url","authorization_endpoint","searchParams","clientId","redirectUri","codeChallenge","state","nonce","uiLocales","toString","requestTokens","code","codeVerifier","tryPromise","try","response","fetch","token_endpoint","method","headers","body","URLSearchParams","grant_type","redirect_uri","client_id","code_verifier","payload","json","ok","Error","error_description","error","String","status","catch","verifyProviderJwt","token","what","algorithms","audience","verifyIdToken","idToken","flatMap","succeed","fail","fetchUserInfo","accessToken","userinfo_endpoint","authorization","catchAll","exchangeCode","tokens","id_token","claims","access_token","profile","toIdentity","sub","sid","endSessionUrl","end_session_endpoint","postLogoutRedirectUri","verifyLogoutToken","logoutToken","events","providerSessionId","asString","subject","value","email","emailVerified","preferredUsername","displayName","ZitadelOidcLayer"],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** A PKCE verifier and the challenge derived from it. */
|
|
2
|
+
export interface PkcePair {
|
|
3
|
+
/** Held by the server between the two legs; never leaves it. */
|
|
4
|
+
readonly codeVerifier: string;
|
|
5
|
+
/** Sent to the authorization endpoint, where it is public by design. */
|
|
6
|
+
readonly codeChallenge: string;
|
|
7
|
+
}
|
|
8
|
+
/** The only challenge method we send. Plain is a downgrade, so it is not offered. */
|
|
9
|
+
export declare const CODE_CHALLENGE_METHOD = "S256";
|
|
10
|
+
/**
|
|
11
|
+
* A fresh PKCE pair.
|
|
12
|
+
*
|
|
13
|
+
* PKCE is what secures a **public** client: the OIDC app is registered without
|
|
14
|
+
* a secret, so possession of the authorization code alone must not be enough to
|
|
15
|
+
* obtain tokens. The verifier, kept server-side between the redirect out and the
|
|
16
|
+
* callback back, is what proves the exchange comes from whoever started the
|
|
17
|
+
* flow.
|
|
18
|
+
*
|
|
19
|
+
* 32 random bytes, base64url — comfortably inside RFC 7636's 43-128 character
|
|
20
|
+
* window, and `base64url` avoids the padding and `+/` characters that would
|
|
21
|
+
* otherwise have to survive a URL round trip.
|
|
22
|
+
*/
|
|
23
|
+
export declare const createPkcePair: () => PkcePair;
|
|
24
|
+
/** The S256 challenge for a verifier: `BASE64URL(SHA256(verifier))`. */
|
|
25
|
+
export declare const challengeFor: (codeVerifier: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* An opaque, unguessable value for `state` or `nonce`.
|
|
28
|
+
*
|
|
29
|
+
* They defend different things and both are required: `state` ties the callback
|
|
30
|
+
* to the browser that began the flow (CSRF), `nonce` ties the returned
|
|
31
|
+
* `id_token` to that same request (replay).
|
|
32
|
+
*/
|
|
33
|
+
export declare const createOpaqueValue: () => string;
|
|
34
|
+
//# sourceMappingURL=pkce.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pkce.d.ts","sourceRoot":"","sources":["../../src/oidc/pkce.ts"],"names":[],"mappings":"AAEA,yDAAyD;AACzD,MAAM,WAAW,QAAQ;IACvB,gEAAgE;IAChE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,wEAAwE;IACxE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC;AAED,qFAAqF;AACrF,eAAO,MAAM,qBAAqB,SAAS,CAAC;AAE5C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,QAAO,QAGjC,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,YAAY,GAAI,cAAc,MAAM,KAAG,MACW,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,QAAO,MACE,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
/** The only challenge method we send. Plain is a downgrade, so it is not offered. */ export const CODE_CHALLENGE_METHOD = 'S256';
|
|
3
|
+
/**
|
|
4
|
+
* A fresh PKCE pair.
|
|
5
|
+
*
|
|
6
|
+
* PKCE is what secures a **public** client: the OIDC app is registered without
|
|
7
|
+
* a secret, so possession of the authorization code alone must not be enough to
|
|
8
|
+
* obtain tokens. The verifier, kept server-side between the redirect out and the
|
|
9
|
+
* callback back, is what proves the exchange comes from whoever started the
|
|
10
|
+
* flow.
|
|
11
|
+
*
|
|
12
|
+
* 32 random bytes, base64url — comfortably inside RFC 7636's 43-128 character
|
|
13
|
+
* window, and `base64url` avoids the padding and `+/` characters that would
|
|
14
|
+
* otherwise have to survive a URL round trip.
|
|
15
|
+
*/ export const createPkcePair = ()=>{
|
|
16
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
17
|
+
return {
|
|
18
|
+
codeVerifier,
|
|
19
|
+
codeChallenge: challengeFor(codeVerifier)
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/** The S256 challenge for a verifier: `BASE64URL(SHA256(verifier))`. */ export const challengeFor = (codeVerifier)=>createHash('sha256').update(codeVerifier).digest('base64url');
|
|
23
|
+
/**
|
|
24
|
+
* An opaque, unguessable value for `state` or `nonce`.
|
|
25
|
+
*
|
|
26
|
+
* They defend different things and both are required: `state` ties the callback
|
|
27
|
+
* to the browser that began the flow (CSRF), `nonce` ties the returned
|
|
28
|
+
* `id_token` to that same request (replay).
|
|
29
|
+
*/ export const createOpaqueValue = ()=>randomBytes(32).toString('base64url');
|
|
30
|
+
|
|
31
|
+
//# sourceMappingURL=pkce.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/oidc/pkce.ts"],"sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\n\n/** A PKCE verifier and the challenge derived from it. */\nexport interface PkcePair {\n /** Held by the server between the two legs; never leaves it. */\n readonly codeVerifier: string;\n /** Sent to the authorization endpoint, where it is public by design. */\n readonly codeChallenge: string;\n}\n\n/** The only challenge method we send. Plain is a downgrade, so it is not offered. */\nexport const CODE_CHALLENGE_METHOD = 'S256';\n\n/**\n * A fresh PKCE pair.\n *\n * PKCE is what secures a **public** client: the OIDC app is registered without\n * a secret, so possession of the authorization code alone must not be enough to\n * obtain tokens. The verifier, kept server-side between the redirect out and the\n * callback back, is what proves the exchange comes from whoever started the\n * flow.\n *\n * 32 random bytes, base64url — comfortably inside RFC 7636's 43-128 character\n * window, and `base64url` avoids the padding and `+/` characters that would\n * otherwise have to survive a URL round trip.\n */\nexport const createPkcePair = (): PkcePair => {\n const codeVerifier = randomBytes(32).toString('base64url');\n return { codeVerifier, codeChallenge: challengeFor(codeVerifier) };\n};\n\n/** The S256 challenge for a verifier: `BASE64URL(SHA256(verifier))`. */\nexport const challengeFor = (codeVerifier: string): string =>\n createHash('sha256').update(codeVerifier).digest('base64url');\n\n/**\n * An opaque, unguessable value for `state` or `nonce`.\n *\n * They defend different things and both are required: `state` ties the callback\n * to the browser that began the flow (CSRF), `nonce` ties the returned\n * `id_token` to that same request (replay).\n */\nexport const createOpaqueValue = (): string =>\n randomBytes(32).toString('base64url');\n"],"names":["createHash","randomBytes","CODE_CHALLENGE_METHOD","createPkcePair","codeVerifier","toString","codeChallenge","challengeFor","update","digest","createOpaqueValue"],"mappings":"AAAA,SAASA,UAAU,EAAEC,WAAW,QAAQ,cAAc;AAUtD,mFAAmF,GACnF,OAAO,MAAMC,wBAAwB,OAAO;AAE5C;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,iBAAiB;IAC5B,MAAMC,eAAeH,YAAY,IAAII,QAAQ,CAAC;IAC9C,OAAO;QAAED;QAAcE,eAAeC,aAAaH;IAAc;AACnE,EAAE;AAEF,sEAAsE,GACtE,OAAO,MAAMG,eAAe,CAACH,eAC3BJ,WAAW,UAAUQ,MAAM,CAACJ,cAAcK,MAAM,CAAC,aAAa;AAEhE;;;;;;CAMC,GACD,OAAO,MAAMC,oBAAoB,IAC/BT,YAAY,IAAII,QAAQ,CAAC,aAAa"}
|