@entifix/zitadel 0.1.3 → 0.2.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/dist/actions/action-webhook.d.ts +1 -1
- package/dist/actions/action-webhook.d.ts.map +1 -1
- package/dist/actions/action-webhook.js +1 -1
- package/dist/actions/action-webhook.js.map +1 -1
- package/dist/health/zitadel-health-probe.js +1 -1
- package/dist/health/zitadel-health-probe.js.map +1 -1
- package/dist/management/management-client.d.ts +1 -1
- package/dist/management/management-client.d.ts.map +1 -1
- package/dist/management/management-client.js +2 -2
- package/dist/management/management-client.js.map +1 -1
- package/dist/oidc/oidc-client.d.ts +1 -1
- package/dist/oidc/oidc-client.d.ts.map +1 -1
- package/dist/oidc/oidc-client.js +2 -2
- package/dist/oidc/oidc-client.js.map +1 -1
- package/package.json +7 -7
|
@@ -58,7 +58,7 @@ export interface ZitadelActions {
|
|
|
58
58
|
*/
|
|
59
59
|
verifyEvent(rawBody: string, signatureHeader: string | undefined): Effect.Effect<ProviderUserEvent, EntifixLogicError>;
|
|
60
60
|
}
|
|
61
|
-
declare const ZitadelActionsTag_base: Context.
|
|
61
|
+
declare const ZitadelActionsTag_base: Context.ServiceClass<ZitadelActionsTag, "ZitadelActionsTag", ZitadelActions>;
|
|
62
62
|
export declare class ZitadelActionsTag extends ZitadelActionsTag_base {
|
|
63
63
|
}
|
|
64
64
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action-webhook.d.ts","sourceRoot":"","sources":["../../src/actions/action-webhook.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEhD;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,sBAAsB,CAAC;AAE3D;;;;GAIG;AACH,eAAO,MAAM,kCAAkC,MAAM,CAAC;AAEtD;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,8DAIjC,CAAC;AAEX,MAAM,MAAM,0BAA0B,GACpC,CAAC,OAAO,8BAA8B,CAAC,CAAC,MAAM,CAAC,CAAC;AAElD,6EAA6E;AAC7E,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CACnC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,WAAW,CACT,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,MAAM,GAAG,SAAS,GAClC,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,CAAC;CACxD;;AAED,qBAAa,iBAAkB,SAAQ,
|
|
1
|
+
{"version":3,"file":"action-webhook.d.ts","sourceRoot":"","sources":["../../src/actions/action-webhook.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEhD;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,sBAAsB,CAAC;AAE3D;;;;GAIG;AACH,eAAO,MAAM,kCAAkC,MAAM,CAAC;AAEtD;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,8DAIjC,CAAC;AAEX,MAAM,MAAM,0BAA0B,GACpC,CAAC,OAAO,8BAA8B,CAAC,CAAC,MAAM,CAAC,CAAC;AAElD,6EAA6E;AAC7E,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CACnC;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,WAAW,CACT,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,MAAM,GAAG,SAAS,GAClC,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,CAAC;CACxD;;AAED,qBAAa,iBAAkB,SAAQ,sBAGf;CAAG;AAkC3B;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,GAChC,SAAS,MAAM,EACf,iBAAiB,MAAM,GAAG,SAAS,EACnC,YAAY,MAAM,EAClB,aAAY,MAAsC,KACjD,OAeF,CAAC;AA8BF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC7B,QAAQ,oBAAoB,KAC3B,cAiBD,CAAC;AAEH,qEAAqE;AACrE,eAAO,MAAM,mBAAmB,GAC9B,QAAQ,oBAAoB,KAC3B,KAAK,CAAC,KAAK,CAAC,iBAAiB,CAC8B,CAAC"}
|
|
@@ -27,7 +27,7 @@ import { Context, Effect, Layer } from 'effect';
|
|
|
27
27
|
'user.locked',
|
|
28
28
|
'user.removed'
|
|
29
29
|
];
|
|
30
|
-
export class ZitadelActionsTag extends Context.
|
|
30
|
+
export class ZitadelActionsTag extends Context.Service()('ZitadelActionsTag') {
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* `t=<unix>,v1=<hex>` — and there may be more than one `v1=`, because Zitadel's
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/actions/action-webhook.ts"],"sourcesContent":["import { createHmac, timingSafeEqual } from 'node:crypto';\n\nimport { EntifixLogicError } from '@entifix/core';\nimport { Context, Effect, Layer } from 'effect';\n\n/**\n * The header Zitadel signs an Actions v2 payload with.\n *\n * Read from the sender, not from the docs: `internal/execution/execution.go`\n * sets `actions.SigningHeader`, which is this exact spelling. The `CreateTarget`\n * proto comment claims `X-ZITADEL-Signature` and is wrong for v4.16.2 — we pin\n * the one the running instance actually sends rather than accepting both, since\n * a second accepted spelling is a second thing to get wrong later.\n */\nexport const ACTION_SIGNATURE_HEADER = 'zitadel-signature';\n\n/**\n * How far the signature's own timestamp may be from now, matching Zitadel's\n * `DefaultTolerance`. It is what bounds replay: the payload is signed over the\n * timestamp, so an old capture cannot be re-dated without breaking the MAC.\n */\nexport const ACTION_SIGNATURE_TOLERANCE_SECONDS = 300;\n\n/**\n * The user-lifecycle events that must end an r10c session.\n *\n * All three are `user` aggregate events, so `aggregateID` is the Zitadel user id\n * in every case — the same value that arrives as `sub` in an `id_token` and is\n * stored as the account's `external-subject` identifier. That is what lets one\n * handler serve all three without knowing anything about them individually.\n */\nexport const PROVIDER_USER_LIFECYCLE_EVENTS = [\n 'user.deactivated',\n 'user.locked',\n 'user.removed',\n] as const;\n\nexport type ProviderUserLifecycleEvent =\n (typeof PROVIDER_USER_LIFECYCLE_EVENTS)[number];\n\n/** A verified Actions v2 event, reduced to what session revocation needs. */\nexport interface ProviderUserEvent {\n /** e.g. `user.deactivated`. Kept for logging and for the no-op branch. */\n readonly eventType: string;\n /** The `aggregateID`: the provider's user id, i.e. the OIDC `sub`. */\n readonly subject: string;\n /**\n * True when {@link eventType} is one this platform acts on. An execution can be\n * added at the provider without a deploy here, so an unrecognised type must be\n * a no-op rather than an error.\n */\n readonly revokesSessions: boolean;\n}\n\n/** How the verifier is pointed at a target. Resolved from config-service at boot. */\nexport interface ZitadelActionsConfig {\n /**\n * The key Zitadel minted when the target was created. It is returned **only**\n * from `CreateTarget` and can never be read back, which is why\n * `tools/zitadel-seed.mjs` stamps it into `.generated.env` and carries it\n * forward across re-seeds.\n */\n readonly signingKey: string;\n}\n\nexport interface ZitadelActions {\n /**\n * Verify a webhook call and say what it asks for.\n *\n * Takes the **raw** body, not a parsed object: the MAC covers the bytes\n * Zitadel sent, and a re-serialised object is a different sequence of bytes.\n */\n verifyEvent(\n rawBody: string,\n signatureHeader: string | undefined,\n ): Effect.Effect<ProviderUserEvent, EntifixLogicError>;\n}\n\nexport class ZitadelActionsTag extends Context.Tag('ZitadelActionsTag')<\n ZitadelActionsTag,\n ZitadelActions\n>() {}\n\ninterface ParsedSignature {\n readonly timestamp: number;\n readonly signatures: readonly string[];\n}\n\n/**\n * `t=<unix>,v1=<hex>` — and there may be more than one `v1=`, because Zitadel's\n * `ComputeSignatureHeader` takes a list of keys and appends one part per key.\n * Accepting only the first would break the moment a key is rotated with an\n * overlap window.\n */\nconst parseSignatureHeader = (header: string): ParsedSignature | undefined => {\n let timestamp: number | undefined;\n const signatures: string[] = [];\n for (const part of header.split(',')) {\n const [name, value] = part.trim().split('=', 2);\n if (value === undefined || value === '') continue;\n if (name === 't') timestamp = Number(value);\n if (name === 'v1') signatures.push(value);\n }\n if (timestamp === undefined || !Number.isFinite(timestamp)) return undefined;\n if (signatures.length === 0) return undefined;\n return { timestamp, signatures };\n};\n\nconst signaturesMatch = (expected: Buffer, candidate: string): boolean => {\n const actual = Buffer.from(candidate, 'hex');\n // `timingSafeEqual` throws on a length mismatch, and a wrong-length signature\n // is a mismatch we can answer without looking at the bytes anyway.\n return actual.length === expected.length && timingSafeEqual(actual, expected);\n};\n\n/**\n * The security boundary of this file: whether a request that named a user\n * deactivation really came from the identity provider.\n *\n * The endpoint it guards is unauthenticated by necessity — the caller is a\n * server holding no cookie — exactly as the back-channel logout route is\n * (ADR 0017). There the token's own signature is the authentication; here it is\n * this HMAC, computed the way Zitadel's `pkg/actions/signing.go` computes it:\n * `HMAC-SHA256(signingKey, \"<unix timestamp>.<raw body>\")`, hex.\n *\n * An empty key **fails closed**. A fleet whose signing key never arrived (the\n * seed did not run, the config row is blank) must reject every call rather than\n * accept anonymous requests to revoke anyone's sessions — the one failure mode\n * that would be strictly worse than the bug this closes.\n */\nexport const verifyActionSignature = (\n rawBody: string,\n signatureHeader: string | undefined,\n signingKey: string,\n nowSeconds: number = Math.floor(Date.now() / 1000),\n): boolean => {\n if (signingKey === '' || signatureHeader === undefined) return false;\n const parsed = parseSignatureHeader(signatureHeader);\n if (parsed === undefined) return false;\n if (\n Math.abs(nowSeconds - parsed.timestamp) > ACTION_SIGNATURE_TOLERANCE_SECONDS\n ) {\n return false;\n }\n const expected = createHmac('sha256', signingKey)\n .update(`${String(parsed.timestamp)}.${rawBody}`)\n .digest();\n return parsed.signatures.some(candidate =>\n signaturesMatch(expected, candidate),\n );\n};\n\nconst isLifecycleEvent = (eventType: string): boolean =>\n (PROVIDER_USER_LIFECYCLE_EVENTS as readonly string[]).includes(eventType);\n\n/**\n * Zitadel's event payload, of which only two fields matter here. `event_type` is\n * snake_case in the JSON while `aggregateID` is not — that asymmetry is the\n * provider's, not a typo.\n */\nconst readEvent = (rawBody: string): ProviderUserEvent | undefined => {\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawBody);\n } catch {\n return undefined;\n }\n if (typeof parsed !== 'object' || parsed === null) return undefined;\n const body = parsed as Record<string, unknown>;\n const eventType = body['event_type'];\n const subject = body['aggregateID'];\n if (typeof eventType !== 'string' || eventType === '') return undefined;\n if (typeof subject !== 'string' || subject === '') return undefined;\n return {\n eventType,\n subject,\n revokesSessions: isLifecycleEvent(eventType),\n };\n};\n\n/**\n * A verifier for the Actions v2 webhook a Zitadel event execution calls.\n *\n * Deliberately separate from {@link ZitadelOidc}: that one speaks OIDC and\n * verifies asymmetric provider JWTs against a published key set, this one\n * verifies a shared-secret MAC over an arbitrary JSON body. Folding them\n * together would put a symmetric key next to the pinned-`algorithms` verifier\n * whose whole purpose is that no symmetric key is ever accepted there.\n */\nexport const makeZitadelActions = (\n config: ZitadelActionsConfig,\n): ZitadelActions => ({\n verifyEvent: (rawBody, signatureHeader) =>\n Effect.suspend(() => {\n if (!verifyActionSignature(rawBody, signatureHeader, config.signingKey)) {\n return Effect.fail(\n new EntifixLogicError('the action payload signature did not verify'),\n );\n }\n const event = readEvent(rawBody);\n return event === undefined\n ? Effect.fail(\n new EntifixLogicError(\n 'the action payload is not a user event: no event_type or aggregateID',\n ),\n )\n : Effect.succeed(event);\n }),\n});\n\n/** Binds {@link ZitadelActionsTag} from a resolved configuration. */\nexport const ZitadelActionsLayer = (\n config: ZitadelActionsConfig,\n): Layer.Layer<ZitadelActionsTag> =>\n Layer.succeed(ZitadelActionsTag, makeZitadelActions(config));\n"],"names":["createHmac","timingSafeEqual","EntifixLogicError","Context","Effect","Layer","ACTION_SIGNATURE_HEADER","ACTION_SIGNATURE_TOLERANCE_SECONDS","PROVIDER_USER_LIFECYCLE_EVENTS","ZitadelActionsTag","Tag","parseSignatureHeader","header","timestamp","signatures","part","split","name","value","trim","undefined","Number","push","isFinite","length","signaturesMatch","expected","candidate","actual","Buffer","from","verifyActionSignature","rawBody","signatureHeader","signingKey","nowSeconds","Math","floor","Date","now","parsed","abs","update","String","digest","some","isLifecycleEvent","eventType","includes","readEvent","JSON","parse","body","subject","revokesSessions","makeZitadelActions","config","verifyEvent","suspend","fail","event","succeed","ZitadelActionsLayer"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/actions/action-webhook.ts"],"sourcesContent":["import { createHmac, timingSafeEqual } from 'node:crypto';\n\nimport { EntifixLogicError } from '@entifix/core';\nimport { Context, Effect, Layer } from 'effect';\n\n/**\n * The header Zitadel signs an Actions v2 payload with.\n *\n * Read from the sender, not from the docs: `internal/execution/execution.go`\n * sets `actions.SigningHeader`, which is this exact spelling. The `CreateTarget`\n * proto comment claims `X-ZITADEL-Signature` and is wrong for v4.16.2 — we pin\n * the one the running instance actually sends rather than accepting both, since\n * a second accepted spelling is a second thing to get wrong later.\n */\nexport const ACTION_SIGNATURE_HEADER = 'zitadel-signature';\n\n/**\n * How far the signature's own timestamp may be from now, matching Zitadel's\n * `DefaultTolerance`. It is what bounds replay: the payload is signed over the\n * timestamp, so an old capture cannot be re-dated without breaking the MAC.\n */\nexport const ACTION_SIGNATURE_TOLERANCE_SECONDS = 300;\n\n/**\n * The user-lifecycle events that must end an r10c session.\n *\n * All three are `user` aggregate events, so `aggregateID` is the Zitadel user id\n * in every case — the same value that arrives as `sub` in an `id_token` and is\n * stored as the account's `external-subject` identifier. That is what lets one\n * handler serve all three without knowing anything about them individually.\n */\nexport const PROVIDER_USER_LIFECYCLE_EVENTS = [\n 'user.deactivated',\n 'user.locked',\n 'user.removed',\n] as const;\n\nexport type ProviderUserLifecycleEvent =\n (typeof PROVIDER_USER_LIFECYCLE_EVENTS)[number];\n\n/** A verified Actions v2 event, reduced to what session revocation needs. */\nexport interface ProviderUserEvent {\n /** e.g. `user.deactivated`. Kept for logging and for the no-op branch. */\n readonly eventType: string;\n /** The `aggregateID`: the provider's user id, i.e. the OIDC `sub`. */\n readonly subject: string;\n /**\n * True when {@link eventType} is one this platform acts on. An execution can be\n * added at the provider without a deploy here, so an unrecognised type must be\n * a no-op rather than an error.\n */\n readonly revokesSessions: boolean;\n}\n\n/** How the verifier is pointed at a target. Resolved from config-service at boot. */\nexport interface ZitadelActionsConfig {\n /**\n * The key Zitadel minted when the target was created. It is returned **only**\n * from `CreateTarget` and can never be read back, which is why\n * `tools/zitadel-seed.mjs` stamps it into `.generated.env` and carries it\n * forward across re-seeds.\n */\n readonly signingKey: string;\n}\n\nexport interface ZitadelActions {\n /**\n * Verify a webhook call and say what it asks for.\n *\n * Takes the **raw** body, not a parsed object: the MAC covers the bytes\n * Zitadel sent, and a re-serialised object is a different sequence of bytes.\n */\n verifyEvent(\n rawBody: string,\n signatureHeader: string | undefined,\n ): Effect.Effect<ProviderUserEvent, EntifixLogicError>;\n}\n\nexport class ZitadelActionsTag extends Context.Service<\n ZitadelActionsTag,\n ZitadelActions\n>()('ZitadelActionsTag') {}\n\ninterface ParsedSignature {\n readonly timestamp: number;\n readonly signatures: readonly string[];\n}\n\n/**\n * `t=<unix>,v1=<hex>` — and there may be more than one `v1=`, because Zitadel's\n * `ComputeSignatureHeader` takes a list of keys and appends one part per key.\n * Accepting only the first would break the moment a key is rotated with an\n * overlap window.\n */\nconst parseSignatureHeader = (header: string): ParsedSignature | undefined => {\n let timestamp: number | undefined;\n const signatures: string[] = [];\n for (const part of header.split(',')) {\n const [name, value] = part.trim().split('=', 2);\n if (value === undefined || value === '') continue;\n if (name === 't') timestamp = Number(value);\n if (name === 'v1') signatures.push(value);\n }\n if (timestamp === undefined || !Number.isFinite(timestamp)) return undefined;\n if (signatures.length === 0) return undefined;\n return { timestamp, signatures };\n};\n\nconst signaturesMatch = (expected: Buffer, candidate: string): boolean => {\n const actual = Buffer.from(candidate, 'hex');\n // `timingSafeEqual` throws on a length mismatch, and a wrong-length signature\n // is a mismatch we can answer without looking at the bytes anyway.\n return actual.length === expected.length && timingSafeEqual(actual, expected);\n};\n\n/**\n * The security boundary of this file: whether a request that named a user\n * deactivation really came from the identity provider.\n *\n * The endpoint it guards is unauthenticated by necessity — the caller is a\n * server holding no cookie — exactly as the back-channel logout route is\n * (ADR 0017). There the token's own signature is the authentication; here it is\n * this HMAC, computed the way Zitadel's `pkg/actions/signing.go` computes it:\n * `HMAC-SHA256(signingKey, \"<unix timestamp>.<raw body>\")`, hex.\n *\n * An empty key **fails closed**. A fleet whose signing key never arrived (the\n * seed did not run, the config row is blank) must reject every call rather than\n * accept anonymous requests to revoke anyone's sessions — the one failure mode\n * that would be strictly worse than the bug this closes.\n */\nexport const verifyActionSignature = (\n rawBody: string,\n signatureHeader: string | undefined,\n signingKey: string,\n nowSeconds: number = Math.floor(Date.now() / 1000),\n): boolean => {\n if (signingKey === '' || signatureHeader === undefined) return false;\n const parsed = parseSignatureHeader(signatureHeader);\n if (parsed === undefined) return false;\n if (\n Math.abs(nowSeconds - parsed.timestamp) > ACTION_SIGNATURE_TOLERANCE_SECONDS\n ) {\n return false;\n }\n const expected = createHmac('sha256', signingKey)\n .update(`${String(parsed.timestamp)}.${rawBody}`)\n .digest();\n return parsed.signatures.some(candidate =>\n signaturesMatch(expected, candidate),\n );\n};\n\nconst isLifecycleEvent = (eventType: string): boolean =>\n (PROVIDER_USER_LIFECYCLE_EVENTS as readonly string[]).includes(eventType);\n\n/**\n * Zitadel's event payload, of which only two fields matter here. `event_type` is\n * snake_case in the JSON while `aggregateID` is not — that asymmetry is the\n * provider's, not a typo.\n */\nconst readEvent = (rawBody: string): ProviderUserEvent | undefined => {\n let parsed: unknown;\n try {\n parsed = JSON.parse(rawBody);\n } catch {\n return undefined;\n }\n if (typeof parsed !== 'object' || parsed === null) return undefined;\n const body = parsed as Record<string, unknown>;\n const eventType = body['event_type'];\n const subject = body['aggregateID'];\n if (typeof eventType !== 'string' || eventType === '') return undefined;\n if (typeof subject !== 'string' || subject === '') return undefined;\n return {\n eventType,\n subject,\n revokesSessions: isLifecycleEvent(eventType),\n };\n};\n\n/**\n * A verifier for the Actions v2 webhook a Zitadel event execution calls.\n *\n * Deliberately separate from {@link ZitadelOidc}: that one speaks OIDC and\n * verifies asymmetric provider JWTs against a published key set, this one\n * verifies a shared-secret MAC over an arbitrary JSON body. Folding them\n * together would put a symmetric key next to the pinned-`algorithms` verifier\n * whose whole purpose is that no symmetric key is ever accepted there.\n */\nexport const makeZitadelActions = (\n config: ZitadelActionsConfig,\n): ZitadelActions => ({\n verifyEvent: (rawBody, signatureHeader) =>\n Effect.suspend(() => {\n if (!verifyActionSignature(rawBody, signatureHeader, config.signingKey)) {\n return Effect.fail(\n new EntifixLogicError('the action payload signature did not verify'),\n );\n }\n const event = readEvent(rawBody);\n return event === undefined\n ? Effect.fail(\n new EntifixLogicError(\n 'the action payload is not a user event: no event_type or aggregateID',\n ),\n )\n : Effect.succeed(event);\n }),\n});\n\n/** Binds {@link ZitadelActionsTag} from a resolved configuration. */\nexport const ZitadelActionsLayer = (\n config: ZitadelActionsConfig,\n): Layer.Layer<ZitadelActionsTag> =>\n Layer.succeed(ZitadelActionsTag, makeZitadelActions(config));\n"],"names":["createHmac","timingSafeEqual","EntifixLogicError","Context","Effect","Layer","ACTION_SIGNATURE_HEADER","ACTION_SIGNATURE_TOLERANCE_SECONDS","PROVIDER_USER_LIFECYCLE_EVENTS","ZitadelActionsTag","Service","parseSignatureHeader","header","timestamp","signatures","part","split","name","value","trim","undefined","Number","push","isFinite","length","signaturesMatch","expected","candidate","actual","Buffer","from","verifyActionSignature","rawBody","signatureHeader","signingKey","nowSeconds","Math","floor","Date","now","parsed","abs","update","String","digest","some","isLifecycleEvent","eventType","includes","readEvent","JSON","parse","body","subject","revokesSessions","makeZitadelActions","config","verifyEvent","suspend","fail","event","succeed","ZitadelActionsLayer"],"mappings":"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"}
|
|
@@ -27,7 +27,7 @@ import { Effect, Layer } from 'effect';
|
|
|
27
27
|
],
|
|
28
28
|
check: Effect.tryPromise(()=>fetch(url, {
|
|
29
29
|
signal: AbortSignal.timeout(PROBE_TIMEOUT_MILLIS)
|
|
30
|
-
})).pipe(Effect.map((response)=>response.ok), Effect.
|
|
30
|
+
})).pipe(Effect.map((response)=>response.ok), Effect.catch(()=>Effect.succeed(false)))
|
|
31
31
|
});
|
|
32
32
|
}));
|
|
33
33
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/health/zitadel-health-probe.ts"],"sourcesContent":["import { HealthRegistryTag } from '@entifix/business';\nimport { Effect, Layer } from 'effect';\n\n/** Probe name reported by `/api/health/ready` when Zitadel is unreachable. */\nexport const ZITADEL_PROBE_NAME = 'zitadel';\n\n/** How long the probe waits before calling the instance unreachable. */\nconst PROBE_TIMEOUT_MILLIS = 2_000;\n\n/**\n * Registers a readiness probe against the instance's `/debug/ready`.\n *\n * A service that cannot reach Zitadel can still answer liveness — its process\n * is fine — while readiness reports the truth, which is the split Kubernetes\n * acts on: drain traffic, do not restart.\n *\n * The endpoint is unauthenticated on purpose: a readiness check must not spend\n * a credential, and `/debug/ready` reveals nothing beyond \"this instance is\n * serving\". The deadline matters as much as the check, because an instance that\n * is mid-init accepts the connection and then simply does not answer — which\n * would leave readiness hanging at the one moment it must reply.\n */\nexport const ZitadelHealthProbeLayer = (\n issuer: string,\n): Layer.Layer<never, never, HealthRegistryTag> =>\n Layer.effectDiscard(\n Effect.gen(function* () {\n const registry = yield* HealthRegistryTag;\n const url = `${issuer.replace(/\\/$/, '')}/debug/ready`;\n\n yield* registry.register({\n name: ZITADEL_PROBE_NAME,\n kind: 'upstream',\n // A foreign system, deliberately not a Store (`docs/_shared/planes.md`):\n // it has an owner, and the owner is not us.\n targets: [ZITADEL_PROBE_NAME],\n check: Effect.tryPromise(() =>\n fetch(url, {\n signal: AbortSignal.timeout(PROBE_TIMEOUT_MILLIS),\n }),\n ).pipe(\n Effect.map(response => response.ok),\n Effect.
|
|
1
|
+
{"version":3,"sources":["../../src/health/zitadel-health-probe.ts"],"sourcesContent":["import { HealthRegistryTag } from '@entifix/business';\nimport { Effect, Layer } from 'effect';\n\n/** Probe name reported by `/api/health/ready` when Zitadel is unreachable. */\nexport const ZITADEL_PROBE_NAME = 'zitadel';\n\n/** How long the probe waits before calling the instance unreachable. */\nconst PROBE_TIMEOUT_MILLIS = 2_000;\n\n/**\n * Registers a readiness probe against the instance's `/debug/ready`.\n *\n * A service that cannot reach Zitadel can still answer liveness — its process\n * is fine — while readiness reports the truth, which is the split Kubernetes\n * acts on: drain traffic, do not restart.\n *\n * The endpoint is unauthenticated on purpose: a readiness check must not spend\n * a credential, and `/debug/ready` reveals nothing beyond \"this instance is\n * serving\". The deadline matters as much as the check, because an instance that\n * is mid-init accepts the connection and then simply does not answer — which\n * would leave readiness hanging at the one moment it must reply.\n */\nexport const ZitadelHealthProbeLayer = (\n issuer: string,\n): Layer.Layer<never, never, HealthRegistryTag> =>\n Layer.effectDiscard(\n Effect.gen(function* () {\n const registry = yield* HealthRegistryTag;\n const url = `${issuer.replace(/\\/$/, '')}/debug/ready`;\n\n yield* registry.register({\n name: ZITADEL_PROBE_NAME,\n kind: 'upstream',\n // A foreign system, deliberately not a Store (`docs/_shared/planes.md`):\n // it has an owner, and the owner is not us.\n targets: [ZITADEL_PROBE_NAME],\n check: Effect.tryPromise(() =>\n fetch(url, {\n signal: AbortSignal.timeout(PROBE_TIMEOUT_MILLIS),\n }),\n ).pipe(\n Effect.map(response => response.ok),\n Effect.catch(() => Effect.succeed(false)),\n ),\n });\n }),\n );\n"],"names":["HealthRegistryTag","Effect","Layer","ZITADEL_PROBE_NAME","PROBE_TIMEOUT_MILLIS","ZitadelHealthProbeLayer","issuer","effectDiscard","gen","registry","url","replace","register","name","kind","targets","check","tryPromise","fetch","signal","AbortSignal","timeout","pipe","map","response","ok","catch","succeed"],"mappings":"AAAA,SAASA,iBAAiB,QAAQ,oBAAoB;AACtD,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAEvC,4EAA4E,GAC5E,OAAO,MAAMC,qBAAqB,UAAU;AAE5C,sEAAsE,GACtE,MAAMC,uBAAuB;AAE7B;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,SAEAJ,MAAMK,aAAa,CACjBN,OAAOO,GAAG,CAAC;QACT,MAAMC,WAAW,OAAOT;QACxB,MAAMU,MAAM,GAAGJ,OAAOK,OAAO,CAAC,OAAO,IAAI,YAAY,CAAC;QAEtD,OAAOF,SAASG,QAAQ,CAAC;YACvBC,MAAMV;YACNW,MAAM;YACN,yEAAyE;YACzE,4CAA4C;YAC5CC,SAAS;gBAACZ;aAAmB;YAC7Ba,OAAOf,OAAOgB,UAAU,CAAC,IACvBC,MAAMR,KAAK;oBACTS,QAAQC,YAAYC,OAAO,CAACjB;gBAC9B,IACAkB,IAAI,CACJrB,OAAOsB,GAAG,CAACC,CAAAA,WAAYA,SAASC,EAAE,GAClCxB,OAAOyB,KAAK,CAAC,IAAMzB,OAAO0B,OAAO,CAAC;QAEtC;IACF,IACA"}
|
|
@@ -80,7 +80,7 @@ export interface ZitadelManagement {
|
|
|
80
80
|
*/
|
|
81
81
|
searchEvents(query: ZitadelEventQuery): Effect.Effect<readonly ZitadelEvent[], EntifixConnError>;
|
|
82
82
|
}
|
|
83
|
-
declare const ZitadelManagementTag_base: Context.
|
|
83
|
+
declare const ZitadelManagementTag_base: Context.ServiceClass<ZitadelManagementTag, "ZitadelManagementTag", ZitadelManagement>;
|
|
84
84
|
export declare class ZitadelManagementTag extends ZitadelManagementTag_base {
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"management-client.d.ts","sourceRoot":"","sources":["../../src/management/management-client.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"management-client.d.ts","sourceRoot":"","sources":["../../src/management/management-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
2
|
import { EntifixConnError } from '@entifix/core';
|
|
3
3
|
import { Context, Effect, Layer } from 'effect';
|
|
4
|
-
export class ZitadelManagementTag extends Context.
|
|
4
|
+
export class ZitadelManagementTag extends Context.Service()('ZitadelManagementTag') {
|
|
5
5
|
}
|
|
6
6
|
const ACTIVE = 'USER_STATE_ACTIVE';
|
|
7
7
|
/**
|
|
@@ -77,7 +77,7 @@ const ACTIVE = 'USER_STATE_ACTIVE';
|
|
|
77
77
|
};
|
|
78
78
|
}), // A deleted or never-existing id is a `404`, which is an answer rather
|
|
79
79
|
// than a failure: callers ask precisely because they do not know.
|
|
80
|
-
Effect.
|
|
80
|
+
Effect.catch((error)=>/404|NotFound|doesn't exist/i.test(error.message) ? Effect.succeed(null) : Effect.fail(error)));
|
|
81
81
|
const createHuman = (input)=>call('POST', '/v2/users/human', _extends({
|
|
82
82
|
username: input.username,
|
|
83
83
|
profile: _extends({
|
|
@@ -1 +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":";AAAA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AA0GhD,OAAO,MAAMC,6BAA6BH,QAAQI,GAAG,CAAC;AAGjD;AAyBL,MAAMC,SAAS;AAEf;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,wBAAwB,CACnCC;IAEA,MAAMC,OAAOD,OAAOE,MAAM,CAACC,OAAO,CAAC,OAAO;IAE1C,MAAMC,OAAO,CACXC,QACAC,MACAC,OAEAb,OAAOc,UAAU,CAAC;YAChBC,KAAK;gBACH,MAAMC,WAAW,MAAMC,MAAM,GAAGV,OAAOK,MAAM,EAAE;oBAC7CD;oBACAO,SAAS;wBACPC,eAAe,CAAC,OAAO,EAAEb,OAAOc,mBAAmB,EAAE;wBACrD,gBAAgB;oBAClB;mBACIP,SAASQ,YAAY,CAAC,IAAI;oBAAER,MAAMS,KAAKC,SAAS,CAACV;gBAAM;gBAE7D,MAAMW,OAAO,MAAMR,SAASQ,IAAI;gBAChC,MAAMC,UAAUD,SAAS,KAAK,CAAC,IAAKF,KAAKI,KAAK,CAACF;gBAC/C,IAAI,CAACR,SAASW,EAAE,EAAE;wBAEd;oBADF,MAAMC,WACJ,mBAAA,AAACH,QAAiCG,OAAO,YAAzC,mBACA,GAAGjB,OAAO,CAAC,EAAEC,KAAK,UAAU,EAAEiB,OAAOb,SAASc,MAAM,GAAG;oBACzD,MAAM,IAAIC,MAAMH;gBAClB;gBACA,OAAOH;YACT;YACAO,OAAOC,CAAAA,QACL,IAAInC,iBACF,CAAC,gCAAgC,EAAEa,OAAO,CAAC,EAAEC,KAAK,GAAG,EAAEiB,OAAOI,QAAQ;QAE5E;IAEF,MAAMC,kBAAkB,CAACC,QACvBzB,KACE,QACA,gCACA;YACE0B,SAAS;gBACP;oBACEC,YAAY;wBACVC,cAAcH;wBACdxB,QAAQ;oBACV;gBACF;aACD;QACH,GACA4B,IAAI,CACJvC,OAAOwC,GAAG,CAACf,CAAAA;gBACIA,iBAIDgB;gBACHA,kBAAAA,YACQA,mBAAAA,aACFA,oBAAAA;YAPf,MAAMA,MAAM,EAAChB,kBAAAA,QAAQiB,MAAM,YAAdjB,kBAAkB,EAAE,CAAC,CAAC,EAAE;YACrC,IAAIgB,CAAAA,uBAAAA,IAAKE,EAAE,MAAKtB,WAAW,OAAO;YAClC,OAAO;gBACLuB,QAAQH,IAAIE,EAAE;gBACdE,QAAQ,GAAEJ,gBAAAA,IAAIK,QAAQ,YAAZL,gBAAgB;gBAC1BN,KAAK,GAAEM,aAAAA,IAAIM,KAAK,sBAATN,mBAAAA,WAAWN,KAAK,qBAAhBM,iBAAkBN,KAAK;gBAC9Ba,eAAeP,EAAAA,cAAAA,IAAIM,KAAK,sBAATN,oBAAAA,YAAWN,KAAK,qBAAhBM,kBAAkBQ,eAAe,MAAK;gBACrDC,WAAW,GAAET,cAAAA,IAAIM,KAAK,sBAATN,qBAAAA,YAAWU,OAAO,qBAAlBV,mBAAoBS,WAAW;gBAC5CE,QAAQX,IAAIY,KAAK,KAAKjD;YACxB;QACF;IAGJ,MAAMkD,UAAU,CAACV,SACflC,KAAwB,OAAO,CAAC,UAAU,EAAEkC,QAAQ,EAAEL,IAAI,CACxDvC,OAAOwC,GAAG,CAACf,CAAAA;gBAKG8B;gBACHA,mBAAAA,aACQA,oBAAAA,cACFA,qBAAAA;YAPf,MAAMA,OAAO9B,QAAQ8B,IAAI;YACzB,IAAIA,CAAAA,wBAAAA,KAAMX,MAAM,MAAKvB,WAAW,OAAO;YACvC,OAAO;gBACLuB,QAAQW,KAAKX,MAAM;gBACnBC,QAAQ,GAAEU,iBAAAA,KAAKV,QAAQ,YAAbU,iBAAiB;gBAC3BpB,KAAK,GAAEoB,cAAAA,KAAKR,KAAK,sBAAVQ,oBAAAA,YAAYpB,KAAK,qBAAjBoB,kBAAmBpB,KAAK;gBAC/Ba,eAAeO,EAAAA,eAAAA,KAAKR,KAAK,sBAAVQ,qBAAAA,aAAYpB,KAAK,qBAAjBoB,mBAAmBC,UAAU,MAAK;gBACjDN,WAAW,GAAEK,eAAAA,KAAKR,KAAK,sBAAVQ,sBAAAA,aAAYJ,OAAO,qBAAnBI,oBAAqBL,WAAW;gBAC7CE,QAAQG,KAAKF,KAAK,KAAKjD;YACzB;QACF,IACA,uEAAuE;QACvE,kEAAkE;QAClEJ,OAAOyD,QAAQ,CAACxB,CAAAA,QACd,8BAA8ByB,IAAI,CAACzB,MAAML,OAAO,IAC5C5B,OAAO2D,OAAO,CAAC,QACf3D,OAAO4D,IAAI,CAAC3B;IAItB,MAAM4B,cAAc,CAACC,QACnBpD,KAA0B,QAAQ,mBAAmB;YACnDmC,UAAUiB,MAAMjB,QAAQ;YACxBM,SAAS;gBACPY,WAAWD,MAAMC,SAAS;gBAC1BC,YAAYF,MAAME,UAAU;eACxBF,MAAMZ,WAAW,KAAK7B,YACtB,CAAC,IACD;gBAAE6B,aAAaY,MAAMZ,WAAW;YAAC;YAEvCf,OAAO;gBACLA,OAAO2B,MAAM3B,KAAK;gBAClBqB,YAAYM,MAAMd,aAAa,KAAK;YACtC;WACIc,MAAMG,QAAQ,KAAK5C,YACnB,CAAC,IACD;YACE4C,UAAU;gBACRA,UAAUH,MAAMG,QAAQ;gBACxBC,gBAAgBJ,MAAMK,sBAAsB,KAAK;YACnD;QACF,IACH5B,IAAI,CACLvC,OAAOoE,OAAO,CAAC3C,CAAAA,UACbA,QAAQmB,MAAM,KAAKvB,YACfrB,OAAO4D,IAAI,CACT,IAAI9D,iBACF,mDAGJE,OAAO2D,OAAO,CAAClC,QAAQmB,MAAM;IAIvC,MAAMyB,gBAAgB,CACpBzB,QACAO,UAMAzC,KAAc,OAAO,CAAC,gBAAgB,EAAEkC,QAAQ,EAAE;YAAEO;QAAQ,GAAGZ,IAAI,CACjEvC,OAAOsE,MAAM;IAGjB,MAAMC,YAAY,CAAC3B,QAAgBQ,SACjC1C,KACE,QACA,CAAC,UAAU,EAAEkC,OAAO,CAAC,EAAEQ,SAAS,eAAe,cAAc,EAC7D,CAAC,GACDb,IAAI,CAACvC,OAAOsE,MAAM;IAEtB,MAAME,aAAa,CAAC5B,SAClBlC,KAAc,UAAU,CAAC,UAAU,EAAEkC,QAAQ,EAAEL,IAAI,CAACvC,OAAOsE,MAAM;IAEnE;;;;;;;;;;;;;GAaC,GACD,MAAMG,eAAe,CAACC,QACpBhE,KACE,QACA,4BACA;YACEiE,KAAK;YACLC,OAAOF,MAAME,KAAK;YAClBC,eAAeH,MAAMI,KAAK;YAC1BC,aAAaL,MAAMM,UAAU;QAC/B,GACAzC,IAAI,CACJvC,OAAOwC,GAAG,CAACf,CAAAA;gBACRA;mBAAD,EAACA,kBAAAA,QAAQwD,MAAM,YAAdxD,kBAAkB,EAAE,EAAE2C,OAAO,CAAC3B,CAAAA;oBACbA,gBACHA;gBADb,MAAMyC,WAAUzC,iBAAAA,IAAI0C,SAAS,qBAAb1C,eAAeE,EAAE;gBACjC,MAAMyC,QAAO3C,YAAAA,IAAI2C,IAAI,qBAAR3C,UAAU2C,IAAI;gBAC3B,IACE,OAAOF,YAAY,YACnB,OAAOE,SAAS,YAChB,OAAO3C,IAAI4C,YAAY,KAAK,UAC5B;oBACA,OAAO,EAAE;gBACX;gBACA,OAAO;oBAAC;wBAAEH;wBAASE;wBAAME,WAAW7C,IAAI4C,YAAY;oBAAC;iBAAE;YACzD;;IAIN,OAAO;QACLnD;QACAoB;QACAO;QACAQ;QACAE;QACAC;QACAC;IACF;AACF,EAAE;AAEF,sEAAsE,GACtE,OAAO,MAAMc,yBAAyB,CACpCjF,SAEAL,MAAM0D,OAAO,CAACzD,sBAAsBG,sBAAsBC,SAAS"}
|
|
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.Service<\n ZitadelManagementTag,\n ZitadelManagement\n>()('ZitadelManagementTag') {}\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.catch(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","Service","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","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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"}
|
|
@@ -101,7 +101,7 @@ export interface ZitadelOidc {
|
|
|
101
101
|
*/
|
|
102
102
|
verifyLogoutToken(logoutToken: string): Effect.Effect<LogoutEvent, EntifixConnError | EntifixLogicError>;
|
|
103
103
|
}
|
|
104
|
-
declare const ZitadelOidcTag_base: Context.
|
|
104
|
+
declare const ZitadelOidcTag_base: Context.ServiceClass<ZitadelOidcTag, "ZitadelOidcTag", ZitadelOidc>;
|
|
105
105
|
export declare class ZitadelOidcTag extends ZitadelOidcTag_base {
|
|
106
106
|
}
|
|
107
107
|
/** Drop the memoised key sets. Exists for tests; nothing in production calls it. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oidc-client.d.ts","sourceRoot":"","sources":["../../src/oidc/oidc-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAMhD,uFAAuF;AACvF,MAAM,WAAW,iBAAiB;IAChC,iFAAiF;IACjF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wCAAwC;IACxC,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC;IACvC;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAID,uFAAuF;AACvF,eAAO,MAAM,kBAAkB,UAAU,CAAC;AAE1C;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,uDACiB,CAAC;AAEvD;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAqB;IACpC,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,qEAAqE;IACrE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,qFAAqF;AACrF,MAAM,WAAW,eAAe;IAC9B,sFAAsF;IACtF,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW;IAC1B,oDAAoD;IACpD,gBAAgB,CACd,KAAK,EAAE,qBAAqB,GAC3B,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC3C,gFAAgF;IAChF,YAAY,CAAC,KAAK,EAAE;QAClB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;QAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;KACxB,GAAG,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,gBAAgB,GAAG,iBAAiB,CAAC,CAAC;IACzE,wEAAwE;IACxE,aAAa,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IACzE;;;OAGG;IACH,iBAAiB,CACf,WAAW,EAAE,MAAM,GAClB,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,gBAAgB,GAAG,iBAAiB,CAAC,CAAC;CACrE;;AAED,qBAAa,cAAe,SAAQ,
|
|
1
|
+
{"version":3,"file":"oidc-client.d.ts","sourceRoot":"","sources":["../../src/oidc/oidc-client.ts"],"names":[],"mappings":"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"}
|
package/dist/oidc/oidc-client.js
CHANGED
|
@@ -15,7 +15,7 @@ const DEFAULT_SCOPES = [
|
|
|
15
15
|
* Back-Channel Logout 1.0 §2.4. A JWT that verifies but does not carry it is
|
|
16
16
|
* some other token the provider issued, not a request to end a session.
|
|
17
17
|
*/ export const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
|
18
|
-
export class ZitadelOidcTag extends Context.
|
|
18
|
+
export class ZitadelOidcTag extends Context.Service()('ZitadelOidcTag') {
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* JWKS is fetched lazily and cached per issuer, with jose handling rotation:
|
|
@@ -126,7 +126,7 @@ const jwksFor = (discovery)=>{
|
|
|
126
126
|
return await response.json();
|
|
127
127
|
},
|
|
128
128
|
catch: (error)=>new EntifixConnError(String(error))
|
|
129
|
-
}).pipe(Effect.
|
|
129
|
+
}).pipe(Effect.catch(()=>Effect.succeed({})));
|
|
130
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
131
|
// a userinfo response naming a different subject must not
|
|
132
132
|
// be able to redirect the sign-in to another account. Same
|
|
@@ -1 +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":";AAAA,SAASA,gBAAgB,EAAEC,iBAAiB,QAAQ,gBAAgB;AACpE,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAChD,SAASC,kBAAkB,EAAmBC,SAAS,QAAQ,OAAO;AAEtE,SAASC,QAAQ,QAA4B,uBAAuB;AACpE,SAASC,qBAAqB,QAAQ,kBAAkB;AAmBxD,MAAMC,iBAAiB;IAAC;IAAU;IAAW;CAAQ;AAErD,qFAAqF,GACrF,OAAO,MAAMC,qBAAqB,QAAQ;AAE1C;;;;CAIC,GACD,OAAO,MAAMC,2BACX,qDAAqD;AAuFvD,OAAO,MAAMC,uBAAuBV,QAAQW,GAAG,CAAC;AAG3C;AAEL;;;CAGC,GACD,MAAMC,YAAY,IAAIC;AAEtB,kFAAkF,GAClF,OAAO,MAAMC,iBAAiB;IAC5BF,UAAUG,KAAK;AACjB,EAAE;AAEF,MAAMC,UAAU,CAACC;IACf,MAAMC,SAASN,UAAUO,GAAG,CAACF,UAAUG,QAAQ;IAC/C,IAAIF,WAAWG,WAAW,OAAOH;IACjC,MAAMI,UAAUnB,mBAAmB,IAAIoB,IAAIN,UAAUG,QAAQ;IAC7DR,UAAUY,GAAG,CAACP,UAAUG,QAAQ,EAAEE;IAClC,OAAOA;AACT;AAOA;;;;;;;;CAQC,GACD,OAAO,MAAMG,kBAAkB,CAACC;QACdA;IAAhB,MAAMC,SAAS,EAACD,iBAAAA,OAAOC,MAAM,YAAbD,iBAAiBnB,gBAAgBqB,IAAI,CAAC;IAEtD,MAAMC,mBAAmB,CAACC,QACxBzB,SAASqB,OAAOK,MAAM,EAAEC,IAAI,CAC1B/B,OAAOgC,GAAG,CAAChB,CAAAA;YACT,MAAMiB,MAAM,IAAIX,IAAIN,UAAUkB,sBAAsB;YACpDD,IAAIE,YAAY,CAACZ,GAAG,CAAC,aAAaE,OAAOW,QAAQ;YACjDH,IAAIE,YAAY,CAACZ,GAAG,CAAC,gBAAgBE,OAAOY,WAAW;YACvDJ,IAAIE,YAAY,CAACZ,GAAG,CAAC,iBAAiB;YACtCU,IAAIE,YAAY,CAACZ,GAAG,CAAC,SAASG;YAC9BO,IAAIE,YAAY,CAACZ,GAAG,CAAC,kBAAkBM,MAAMS,aAAa;YAC1DL,IAAIE,YAAY,CAACZ,GAAG,CAAC,yBAAyBlB;YAC9C4B,IAAIE,YAAY,CAACZ,GAAG,CAAC,SAASM,MAAMU,KAAK;YACzCN,IAAIE,YAAY,CAACZ,GAAG,CAAC,SAASM,MAAMW,KAAK;YACzC,IAAIX,MAAMY,SAAS,KAAKrB,WAAW;gBACjCa,IAAIE,YAAY,CAACZ,GAAG,CAAC,cAAcM,MAAMY,SAAS;YACpD;YACA,OAAOR,IAAIS,QAAQ;QACrB;IAGJ,MAAMC,gBAAgB,CACpB3B,WACA4B,MACAC,eAEA7C,OAAO8C,UAAU,CAAC;YAChBC,KAAK;gBACH,MAAMC,WAAW,MAAMC,MAAMjC,UAAUkC,cAAc,EAAE;oBACrDC,QAAQ;oBACRC,SAAS;wBAAE,gBAAgB;oBAAoC;oBAC/DC,MAAM,IAAIC,gBAAgB;wBACxBC,YAAY;wBACZX;wBACAY,cAAc/B,OAAOY,WAAW;wBAChCoB,WAAWhC,OAAOW,QAAQ;wBAC1BsB,eAAeb;oBACjB;gBACF;gBACA,MAAMc,UAAW,MAAMX,SAASY,IAAI;gBAIpC,IAAI,CAACZ,SAASa,EAAE,EAAE;wBAEdF,MAAAA;oBADF,MAAM,IAAIG,OACRH,QAAAA,6BAAAA,QAAQI,iBAAiB,YAAzBJ,6BACEA,QAAQK,KAAK,YADfL,OAEE,CAAC,wBAAwB,EAAEM,OAAOjB,SAASkB,MAAM,GAAG;gBAE1D;gBACA,OAAOP;YACT;YACAQ,OAAOH,CAAAA,QACL,IAAInE,iBAAiB,CAAC,0BAA0B,EAAEoE,OAAOD,QAAQ;QACrE;IAEF;;;;;;GAMC,GACD,MAAMI,oBAAoB,CACxBpD,WACAqD,OACAC,OAEAtE,OAAO8C,UAAU,CAAC;YAChBC,KAAK,IACH5C,UAAUkE,OAAOtD,QAAQC,YAAY;oBACnCuD,YAAY;wBAAChE;qBAAmB;oBAChCuB,QAAQd,UAAUc,MAAM;oBACxB0C,UAAU/C,OAAOW,QAAQ;gBAC3B;YACF+B,OAAOH,CAAAA,QACL,IAAIlE,kBAAkB,CAAC,IAAI,EAAEwE,KAAK,iBAAiB,EAAEL,OAAOD,QAAQ;QACxE;IAEF,MAAMS,gBAAgB,CACpBzD,WACA0D,SACAlC,QAEA4B,kBAAkBpD,WAAW0D,SAAS,YAAY3C,IAAI,CACpD/B,OAAO2E,OAAO,CAAC,CAAC,EAAEhB,OAAO,EAAE,GACzB,uEAAuE;YACvE,+CAA+C;YAC/CA,OAAO,CAAC,QAAQ,KAAKnB,QACjBxC,OAAO4E,OAAO,CAACjB,WACf3D,OAAO6E,IAAI,CACT,IAAI/E,kBACF;IAMd;;;;;;;;;;;;;GAaC,GACD,MAAMgF,gBAAgB,CAAC9D,WAA0B+D,cAC/CA,gBAAgB3D,YACZpB,OAAO4E,OAAO,CAAC,CAAC,KAChB5E,OAAO8C,UAAU,CAAC;YAChBC,KAAK;gBACH,MAAMC,WAAW,MAAMC,MAAMjC,UAAUgE,iBAAiB,EAAE;oBACxD5B,SAAS;wBAAE6B,eAAe,CAAC,OAAO,EAAEF,aAAa;oBAAC;gBACpD;gBACA,IAAI,CAAC/B,SAASa,EAAE,EAAE;oBAChB,MAAM,IAAIC,MAAM,CAAC,kBAAkB,EAAEG,OAAOjB,SAASkB,MAAM,GAAG;gBAChE;gBACA,OAAQ,MAAMlB,SAASY,IAAI;YAC7B;YACAO,OAAOH,CAAAA,QAAS,IAAInE,iBAAiBoE,OAAOD;QAC9C,GAAGjC,IAAI,CAAC/B,OAAOkF,QAAQ,CAAC,IAAMlF,OAAO4E,OAAO,CAAC,CAAC;IAEpD,MAAMO,eAAe,CAACtD,QAKpBzB,SAASqB,OAAOK,MAAM,EAAEC,IAAI,CAC1B/B,OAAO2E,OAAO,CAAC3D,CAAAA,YACb2B,cAAc3B,WAAWa,MAAMe,IAAI,EAAEf,MAAMgB,YAAY,EAAEd,IAAI,CAC3D/B,OAAO2E,OAAO,CAACS,CAAAA,SACbA,OAAOC,QAAQ,KAAKjE,YAChBpB,OAAO6E,IAAI,CACT,IAAI/E,kBACF,6CAGJ2E,cAAczD,WAAWoE,OAAOC,QAAQ,EAAExD,MAAMW,KAAK,EAAET,IAAI,CACzD/B,OAAO2E,OAAO,CAACW,CAAAA,SACbR,cAAc9D,WAAWoE,OAAOG,YAAY,EAAExD,IAAI,CAChD,0DAA0D;oBAC1D,0DAA0D;oBAC1D,2DAA2D;oBAC3D,yDAAyD;oBACzD,iBAAiB;oBACjB/B,OAAOgC,GAAG,CAACwD,CAAAA,UACTC,WACE,aACKD,SACAF;4BACHI,KAAKJ,OAAOI,GAAG;4BACfC,KAAKL,MAAM,CAAC,MAAM;4BAEpBF,OAAOC,QAAQ;IAWvC,MAAMO,gBAAgB,CAAClB,UACrBtE,SAASqB,OAAOK,MAAM,EAAEC,IAAI,CAC1B/B,OAAOgC,GAAG,CAAChB,CAAAA;YACT,MAAMiB,MAAM,IAAIX,IAAIN,UAAU6E,oBAAoB;YAClD5D,IAAIE,YAAY,CAACZ,GAAG,CAClB,4BACAE,OAAOqE,qBAAqB;YAE9B,wEAAwE;YACxE,qEAAqE;YACrE,IAAIpB,YAAYtD,WAAW;gBACzBa,IAAIE,YAAY,CAACZ,GAAG,CAAC,iBAAiBmD;YACxC;YACAzC,IAAIE,YAAY,CAACZ,GAAG,CAAC,aAAaE,OAAOW,QAAQ;YACjD,OAAOH,IAAIS,QAAQ;QACrB;IAGJ;;;;;;;;;GASC,GACD,MAAMqD,oBAAoB,CAACC,cACzB5F,SAASqB,OAAOK,MAAM,EAAEC,IAAI,CAC1B/B,OAAO2E,OAAO,CAAC3D,CAAAA,YACboD,kBAAkBpD,WAAWgF,aAAa,kBAE5ChG,OAAO2E,OAAO,CAAC,CAAC,EAAEhB,OAAO,EAAE;YACzB,MAAMsC,SAAStC,OAAO,CAAC,SAAS;YAChC,IACE,OAAOsC,WAAW,YAClBA,WAAW,QACX,CAAEzF,CAAAA,4BAA4ByF,MAAK,GACnC;gBACA,OAAOjG,OAAO6E,IAAI,CAChB,IAAI/E,kBACF;YAGN;YACA,IAAI6D,OAAO,CAAC,QAAQ,KAAKvC,WAAW;gBAClC,OAAOpB,OAAO6E,IAAI,CAChB,IAAI/E,kBACF;YAGN;YACA,MAAMoG,oBAAoBC,SAASxC,OAAO,CAAC,MAAM;YACjD,MAAMyC,UAAUD,SAASxC,QAAQ+B,GAAG;YACpC,IAAIQ,sBAAsB9E,aAAagF,YAAYhF,WAAW;gBAC5D,OAAOpB,OAAO6E,IAAI,CAChB,IAAI/E,kBAAkB;YAE1B;YACA,OAAOE,OAAO4E,OAAO,CAAC;gBACpBsB;gBACAE;YACF;QACF;IAGJ,OAAO;QAAExE;QAAkBuD;QAAcS;QAAeG;IAAkB;AAC5E,EAAE;AAEF,MAAMI,WAAW,CAACE,QAChB,OAAOA,UAAU,YAAYA,UAAU,KAAKA,QAAQjF;AAEtD,MAAMqE,aAAa,CAACH,QAAoBZ,UAAsC,CAAA;QAC5E0B,SAASnC,OAAOqB,OAAOI,GAAG;QAC1BY,OAAOH,SAASb,MAAM,CAAC,QAAQ;QAC/B,6EAA6E;QAC7E,gEAAgE;QAChEiB,eAAejB,MAAM,CAAC,iBAAiB,KAAK;QAC5CkB,mBAAmBL,SAASb,MAAM,CAAC,qBAAqB;QACxDmB,aAAaN,SAASb,MAAM,CAAC,OAAO;QACpCY,mBAAmBC,SAASb,MAAM,CAAC,MAAM;QACzCZ;IACF,CAAA;AAEA,gEAAgE,GAChE,OAAO,MAAMgC,mBAAmB,CAC9BjF,SAEAxB,MAAM2E,OAAO,CAACnE,gBAAgBe,gBAAgBC,SAAS"}
|
|
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.Service<\n ZitadelOidcTag,\n ZitadelOidc\n>()('ZitadelOidcTag') {}\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.catch(() => 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","Service","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","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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/zitadel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Zitadel client: OIDC sign-in with PKCE, back-channel logout, the management API, and signed Actions webhooks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -52,15 +52,15 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@swc/helpers": "0.5.23",
|
|
55
|
-
"jose": "6.2.
|
|
56
|
-
"@entifix/business": "0.
|
|
57
|
-
"@entifix/core": "0.
|
|
55
|
+
"jose": "6.2.12",
|
|
56
|
+
"@entifix/business": "0.2.0",
|
|
57
|
+
"@entifix/core": "0.2.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"effect": "
|
|
61
|
-
"@entifix/testing-unit": "0.
|
|
60
|
+
"effect": "4.0.5",
|
|
61
|
+
"@entifix/testing-unit": "0.2.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
-
"effect": "
|
|
64
|
+
"effect": "^4.0.5"
|
|
65
65
|
}
|
|
66
66
|
}
|