@pidgeon-ai/issuer 0.1.1 → 0.4.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/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { KeyObject } from 'node:crypto';
1
2
  import { Server } from 'node:http';
2
3
 
3
4
  /**
4
5
  * Who the agents are. The issuer holds no list of its own: the company's
5
- * identity provider is the record (Keycloak first; `keycloak.ts`), read
6
+ * identity provider is the record (`keycloak.ts`, `auth0.ts`, `okta.ts`), read
6
7
  * through this interface, and the issuer turns what it says into cards,
7
8
  * keys in the policy, and grants. Pidgeon is the format and the check,
8
9
  * never the identity provider.
@@ -42,10 +43,50 @@ declare function staticDirectory(agents: DirectoryAgent[]): Directory;
42
43
  */
43
44
  declare function cachedDirectory(inner: Directory, seconds?: number, now?: () => number): Directory;
44
45
 
46
+ /**
47
+ * A person signs in at the issuer: Pidgeon Cloud's person flow, with the
48
+ * company's own identity provider where Cloud has its consent page.
49
+ *
50
+ * The site speaks to the issuer exactly as it speaks to Cloud (the
51
+ * authorization code flow with PKCE and a nonce, `@pidgeon-ai/sign-in`'s
52
+ * `login` and `callback`). The issuer is a relying party of the company's
53
+ * OpenID Connect provider (Keycloak, Auth0, Okta, Entra): it sends the
54
+ * browser there with its own PKCE, state and nonce, checks the ID token
55
+ * that comes back, and signs the person in to the site as `kind: person`
56
+ * with the provider's `sub` and a verified address at the domain.
57
+ *
58
+ * **No database.** What a store would hold travels sealed (AES-256-GCM
59
+ * under `PIDGEON_ISSUER_SECRET`): the sign-in in progress in an HttpOnly
60
+ * cookie on the issuer's host, bound to the browser that started it; the
61
+ * code, sixty seconds, used once through the issuer's replay store.
62
+ * Rotating the secret ends the sign-ins in flight, and nothing else.
63
+ */
64
+ /** The company's identity provider, as its relying party. */
65
+ interface PersonOptions {
66
+ /** At least 32 characters. Seals the sign-in in progress and the code. */
67
+ secret: string;
68
+ oidc: {
69
+ /** The provider's issuer, exactly as its ID tokens say: `https://sso.acme.com/realms/acme`. */
70
+ issuer: string;
71
+ /**
72
+ * Where the issuer fetches the provider's configuration, when it reaches
73
+ * the provider by another name than browsers do. Default
74
+ * `<issuer>/.well-known/openid-configuration`.
75
+ */
76
+ discoveryUrl?: string;
77
+ clientId: string;
78
+ clientSecret: string;
79
+ /** How the issuer authenticates at the provider's token endpoint. Default `client_secret_basic`. */
80
+ tokenAuth?: 'client_secret_basic' | 'client_secret_post';
81
+ };
82
+ /** Domains a person's verified address may be at. Default the issuer's domain. */
83
+ emailDomains?: string[];
84
+ }
85
+
45
86
  /**
46
87
  * The issuer's signing key, behind one interface so custody never touches
47
- * the protocol: a file today; AWS KMS or an HSM over PKCS#11 implement the
48
- * same three members and nothing else changes.
88
+ * the protocol: a file, AWS KMS (`kms.ts`), or an HSM over PKCS#11
89
+ * implementing the same three members, and nothing else changes.
49
90
  *
50
91
  * Everything the issuer signs is a JWS signing input (a card, an ID token,
51
92
  * a grant), a few hundred bytes, so a KMS that signs pure Ed25519 only up to
@@ -88,7 +129,9 @@ declare function seedSigner(seed: string, options?: {
88
129
  * work against it unchanged: the policy and the card at `post.<domain>`
89
130
  * (the draft's §discovery and §card), the token exchange an agent signs
90
131
  * (RFC 8693, Web Bot Auth's profile) and the ID token a site checks
91
- * (`@pidgeon-ai/sign-in`), grants (§grants). The formats are the app's own
132
+ * (`@pidgeon-ai/sign-in`), grants (§grants), and a person's sign-in, the
133
+ * authorization code flow with PKCE, through the company's own identity
134
+ * provider (`person.ts`). The formats are the app's own
92
135
  * (`src/lib/post/card.ts`, `grant.ts`, `pidgeon-sign-in.ts`), written out
93
136
  * here because a package cannot import the app; `tests/unit/issuer.test.ts`
94
137
  * holds them equal by reading what this signs with the app's own readers.
@@ -97,7 +140,7 @@ declare function seedSigner(seed: string, options?: {
97
140
  * used once through `replay`, in memory unless a shared store is passed.
98
141
  * Run one replica, or pass one.
99
142
  *
100
- * Left out on purpose: the person's flow, userinfo, introspection and
143
+ * Left out on purpose: userinfo, introspection and
101
144
  * revocation (each needs a store; an ID token lives five minutes and
102
145
  * revoking an agent is disabling it in the identity provider), dynamic
103
146
  * client registration, and the card log.
@@ -110,6 +153,9 @@ declare const PATHS: {
110
153
  readonly card: "/.well-known/post/card";
111
154
  readonly directory: "/.well-known/http-message-signatures-directory";
112
155
  readonly token: "/api/post/oidc/token";
156
+ readonly authorize: "/api/post/oidc/authorize";
157
+ /** The issuer's own: where the company's identity provider sends a person back. Cloud has none. */
158
+ readonly callback: "/api/post/oidc/callback";
113
159
  readonly jwks: "/api/post/oidc/jwks";
114
160
  readonly grants: "/api/post/grants";
115
161
  };
@@ -133,10 +179,14 @@ interface GrantRule {
133
179
  /** How long each signed copy lasts. Default 30. */
134
180
  days?: number;
135
181
  }
136
- /** A site, registered by hand: its `client_id` and the host its grants must be for. */
182
+ /**
183
+ * A site, registered by hand: its `client_id`, the host its grants must be
184
+ * for, and where a person's sign-in may return to, each matched exactly.
185
+ */
137
186
  interface StaticClient {
138
187
  client_id: string;
139
188
  host: string;
189
+ redirect_uris?: string[];
140
190
  }
141
191
  /** Remembers a signature's `keyid nonce` until it expires; false when it was used. */
142
192
  interface ReplayStore {
@@ -162,6 +212,8 @@ interface IssuerOptions {
162
212
  * make it fetch an arbitrary URL.
163
213
  */
164
214
  clientMetadataHosts?: string[];
215
+ /** A person's sign-in, through the company's identity provider. Absent: agents only. */
216
+ person?: PersonOptions;
165
217
  replay?: ReplayStore;
166
218
  fetch?: typeof fetch;
167
219
  /** Milliseconds since the epoch, for tests. */
@@ -241,6 +293,135 @@ interface KeycloakOptions {
241
293
  }
242
294
  declare function keycloakDirectory(options: KeycloakOptions): Directory;
243
295
 
296
+ /**
297
+ * Auth0 as the directory: the issuer reads the tenant's Management API and
298
+ * never writes it.
299
+ *
300
+ * **An agent is a machine-to-machine application** (`app_type:
301
+ * non_interactive`) whose `client_metadata` says so. Auth0 metadata keys
302
+ * cannot hold a dot, so the names are Keycloak's with an underscore:
303
+ *
304
+ * | `client_metadata` | Becomes |
305
+ * | ------------------ | ------------------------------------------------------------- |
306
+ * | `pidgeon_key` | the agent's Ed25519 public key (`x`, base64url); required |
307
+ * | `pidgeon_address` | its address, at the issuer's domain; required |
308
+ * | `pidgeon_kind` | `agent` (default) or `service` |
309
+ * | `pidgeon_groups` | comma-separated, what the config's `grants` map |
310
+ * | `pidgeon_disabled` | `true`: not an agent, from its next sign-in |
311
+ *
312
+ * **Disabling.** An Auth0 application has no enabled switch, so an agent
313
+ * is disabled with `pidgeon_disabled` or by deleting the application.
314
+ * Whoever holds `update:clients` can set or clear it, so it is exactly as
315
+ * strong as that permission, the same people who could change its key.
316
+ *
317
+ * **One key, two uses is not possible here.** Auth0's Private Key JWT takes
318
+ * RS256, RS384 and PS256 with RSA keys only, not Ed25519, so the agent
319
+ * authenticates to Auth0 some other way and names its Pidgeon key in
320
+ * `pidgeon_key`.
321
+ *
322
+ * The card's `sub` is the `client_id`, which Auth0 generates and never lets
323
+ * anyone change; its documentation does not say whether a deleted
324
+ * application's id can come back. `created_at` is the application's.
325
+ *
326
+ * **The issuer's own credential** is a machine-to-machine application
327
+ * authorized for the Management API with `read:clients` only: never
328
+ * `read:client_keys` or `read:client_credentials`, which expose secrets.
329
+ * Its secret comes from the environment.
330
+ */
331
+ interface Auth0Options {
332
+ /** The tenant's domain: `acme.eu.auth0.com`, or its custom domain. */
333
+ tenant: string;
334
+ /** The issuer's own application. */
335
+ clientId: string;
336
+ clientSecret: string;
337
+ /** The domain agents' addresses are at. */
338
+ domain: string;
339
+ fetch?: typeof fetch;
340
+ /** Milliseconds since the epoch, for tests. */
341
+ now?: () => number;
342
+ }
343
+ declare function auth0Directory(options: Auth0Options): Directory;
344
+
345
+ /**
346
+ * Okta as the directory: the issuer reads the org's Apps API and never
347
+ * writes it.
348
+ *
349
+ * **An agent is an active OAuth 2.0 service app** (`application_type:
350
+ * service`, client credentials) whose `profile` names it. An Okta group
351
+ * holds people, never apps, so the agent's groups live in the profile too,
352
+ * as Okta's own `profile.groupallowlist` pattern does:
353
+ *
354
+ * ```json
355
+ * "profile": { "pidgeon": { "key": "<x>", "address": "support@acme.com", "kind": "agent", "groups": ["refunds"] } }
356
+ * ```
357
+ *
358
+ * A deactivated app (`status: INACTIVE`) or a deleted one is not an agent
359
+ * from its next sign-in. The card's `sub` is the app's id; `created_at` is
360
+ * the app's `created`.
361
+ *
362
+ * **One key, two uses is not possible here.** An Okta app's JWKS takes RSA
363
+ * and EC keys only, not Ed25519, so the agent authenticates to Okta with
364
+ * another key and names its Pidgeon key in the profile.
365
+ *
366
+ * **The issuer's own credential** is a service app of its own, granted the
367
+ * `okta.apps.read` scope and a read-only admin role (Okta requires both).
368
+ * Okta takes only Private Key JWT for its own scopes, so the issuer signs a
369
+ * client assertion with an RSA or P-256 key whose public half is in that
370
+ * app's JWKS; the private half comes from the environment.
371
+ */
372
+ interface OktaOptions {
373
+ /** The org: `https://acme.okta.com`. */
374
+ org: string;
375
+ /** The issuer's own service app. */
376
+ clientId: string;
377
+ /** Its RSA or P-256 private key, PEM, for the client assertion. */
378
+ privateKey: string | KeyObject;
379
+ /** That key's `kid` in the app's JWKS, when the app has more than one. */
380
+ keyId?: string;
381
+ /** The domain agents' addresses are at. */
382
+ domain: string;
383
+ fetch?: typeof fetch;
384
+ /** Milliseconds since the epoch, for tests. */
385
+ now?: () => number;
386
+ }
387
+ declare function oktaDirectory(options: OktaOptions): Directory;
388
+
389
+ /** The two KMS calls the issuer makes, as `@aws-sdk/client-kms` shapes them. */
390
+ interface KmsApi {
391
+ getPublicKey(input: {
392
+ KeyId: string;
393
+ }): Promise<{
394
+ KeyId?: string;
395
+ KeySpec?: string;
396
+ KeyUsage?: string;
397
+ SigningAlgorithms?: string[];
398
+ PublicKey?: Uint8Array;
399
+ }>;
400
+ sign(input: {
401
+ KeyId: string;
402
+ Message: Uint8Array;
403
+ MessageType: 'RAW';
404
+ SigningAlgorithm: string;
405
+ }): Promise<{
406
+ KeyId?: string;
407
+ Signature?: Uint8Array;
408
+ }>;
409
+ }
410
+ interface KmsSignerOptions {
411
+ /** The key's ARN, id, or an alias (`alias/pidgeon-issuer`), resolved once at start. */
412
+ keyId: string;
413
+ /** Default the SDK's: `AWS_REGION`, then the profile's. */
414
+ region?: string;
415
+ kms?: KmsApi;
416
+ }
417
+ declare function kmsSigner(options: KmsSignerOptions): Promise<Signer & {
418
+ keyArn: string;
419
+ }>;
420
+ /** A key in KMS the issuer no longer signs with, published until `notAfter`: its public half only. */
421
+ declare function kmsRetiredKey(options: KmsSignerOptions & {
422
+ notAfter: Date;
423
+ }): Promise<RetiredKey>;
424
+
244
425
  /**
245
426
  * `issuer.json`, the one file a company writes (`issuer.example.json`).
246
427
  * Secrets never go in it: the key is a file it names (mounted as a secret)
@@ -251,11 +432,15 @@ interface IssuerFile {
251
432
  domain: string;
252
433
  issuer?: string;
253
434
  organisation?: string;
254
- /** The key file, relative to this file. `PIDGEON_ISSUER_KEY` (the seed) wins when set. */
255
- key?: string;
435
+ /**
436
+ * The key file, relative to this file, or a key in AWS KMS (`{ "kms":
437
+ * "arn:aws:kms:…" }`, an `ECC_NIST_EDWARDS25519` key). `PIDGEON_ISSUER_KEY`
438
+ * (the seed) wins when set.
439
+ */
440
+ key?: KeyRef;
256
441
  /** Keys being withdrawn: published until `not_after`. */
257
442
  retired?: {
258
- key: string;
443
+ key: KeyRef;
259
444
  not_after: string;
260
445
  }[];
261
446
  directory: {
@@ -271,6 +456,44 @@ interface IssuerFile {
271
456
  /** The group whose members are agents. Default `agents`. */
272
457
  group?: string;
273
458
  };
459
+ } | {
460
+ auth0: {
461
+ /** The tenant's domain: `acme.eu.auth0.com`. */
462
+ tenant: string;
463
+ /** The issuer's own machine-to-machine application, with `read:clients` only. */
464
+ client_id: string;
465
+ /** The environment variable holding its secret. Default `AUTH0_CLIENT_SECRET`. */
466
+ client_secret_env?: string;
467
+ };
468
+ } | {
469
+ okta: {
470
+ /** The org: `https://acme.okta.com`. */
471
+ org: string;
472
+ /** The issuer's own service app, with `okta.apps.read` and a read-only admin role. */
473
+ client_id: string;
474
+ /** The environment variable holding its RSA or P-256 private key, PEM. Default `OKTA_PRIVATE_KEY`. */
475
+ private_key_env?: string;
476
+ /** That key's `kid` in the app's JWKS. */
477
+ key_id?: string;
478
+ };
479
+ };
480
+ /**
481
+ * A person's sign-in, through the company's OpenID Connect provider, with
482
+ * the issuer as one of its clients. Needs `PIDGEON_ISSUER_SECRET`.
483
+ */
484
+ person?: {
485
+ oidc: {
486
+ /** The provider's issuer, as its ID tokens say. */
487
+ issuer: string;
488
+ /** Where the issuer reaches the provider's configuration, when not at its issuer URL. */
489
+ discovery_url?: string;
490
+ client_id: string;
491
+ /** The environment variable holding its secret. Default `PIDGEON_PERSON_CLIENT_SECRET`. */
492
+ client_secret_env?: string;
493
+ token_auth?: 'client_secret_basic' | 'client_secret_post';
494
+ };
495
+ /** Default the domain. */
496
+ email_domains?: string[];
274
497
  };
275
498
  grants?: GrantRule[];
276
499
  clients?: StaticClient[];
@@ -278,6 +501,11 @@ interface IssuerFile {
278
501
  /** How long the list of agents is held for the public reads, in seconds. Default 60. */
279
502
  directory_cache_seconds?: number;
280
503
  }
504
+ /** A key file's path, or a KMS key: its ARN, id or alias, and its region when not the default. */
505
+ type KeyRef = string | {
506
+ kms: string;
507
+ region?: string;
508
+ };
281
509
  declare function loadConfig(path: string, env?: Record<string, string | undefined>): Promise<IssuerOptions>;
282
510
 
283
511
  /**
@@ -304,4 +532,4 @@ declare function runIssuer(args: string[], env?: Record<string, string | undefin
304
532
  log?: (line: string) => void;
305
533
  }): Promise<string>;
306
534
 
307
- export { type Directory, type DirectoryAgent, type GrantRule, type Issuer, type IssuerFile, type IssuerOptions, type KeycloakOptions, PATHS, type ReplayStore, type RetiredKey, type Signer, type StaticClient, TOKEN_EXCHANGE, UsageError, cachedDirectory, createIssuer, fileSigner, keycloakDirectory, loadConfig, memoryReplayStore, runIssuer, seedSigner, serve, staticDirectory };
535
+ export { type Auth0Options, type Directory, type DirectoryAgent, type GrantRule, type Issuer, type IssuerFile, type IssuerOptions, type KeyRef, type KeycloakOptions, type KmsApi, type KmsSignerOptions, type OktaOptions, PATHS, type PersonOptions, type ReplayStore, type RetiredKey, type Signer, type StaticClient, TOKEN_EXCHANGE, UsageError, auth0Directory, cachedDirectory, createIssuer, fileSigner, keycloakDirectory, kmsRetiredKey, kmsSigner, loadConfig, memoryReplayStore, oktaDirectory, runIssuer, seedSigner, serve, staticDirectory };
package/dist/index.js CHANGED
@@ -3,27 +3,35 @@ import {
3
3
  PATHS,
4
4
  TOKEN_EXCHANGE,
5
5
  UsageError,
6
+ auth0Directory,
6
7
  cachedDirectory,
7
8
  createIssuer,
8
9
  fileSigner,
9
10
  keycloakDirectory,
11
+ kmsRetiredKey,
12
+ kmsSigner,
10
13
  loadConfig,
11
14
  memoryReplayStore,
15
+ oktaDirectory,
12
16
  runIssuer,
13
17
  seedSigner,
14
18
  serve,
15
19
  staticDirectory
16
- } from "./chunk-CCOFMK5K.js";
20
+ } from "./chunk-5GZMAASA.js";
17
21
  export {
18
22
  PATHS,
19
23
  TOKEN_EXCHANGE,
20
24
  UsageError,
25
+ auth0Directory,
21
26
  cachedDirectory,
22
27
  createIssuer,
23
28
  fileSigner,
24
29
  keycloakDirectory,
30
+ kmsRetiredKey,
31
+ kmsSigner,
25
32
  loadConfig,
26
33
  memoryReplayStore,
34
+ oktaDirectory,
27
35
  runIssuer,
28
36
  seedSigner,
29
37
  serve,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pidgeon-ai/issuer",
3
- "version": "0.1.1",
4
- "description": "A Post issuer you run yourself, inside your own network: your domain signs your AI agents' cards and sign-ins with a key that never leaves it, for the agents your identity provider (Keycloak) says you have.",
3
+ "version": "0.4.0",
4
+ "description": "A Post issuer you run yourself, inside your own network: your domain signs your AI agents' cards and sign-ins with a key that never leaves it, for the agents your identity provider (Keycloak, Auth0, Okta) says you have.",
5
5
  "keywords": [
6
6
  "agent-identity",
7
7
  "ai-agents",
@@ -9,6 +9,8 @@
9
9
  "openid-connect",
10
10
  "token-exchange",
11
11
  "keycloak",
12
+ "auth0",
13
+ "okta",
12
14
  "web-bot-auth",
13
15
  "pidgeon",
14
16
  "post"
@@ -43,7 +45,16 @@
43
45
  "publishConfig": {
44
46
  "access": "public"
45
47
  },
48
+ "peerDependencies": {
49
+ "@aws-sdk/client-kms": "^3.1164.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@aws-sdk/client-kms": {
53
+ "optional": true
54
+ }
55
+ },
46
56
  "devDependencies": {
57
+ "@aws-sdk/client-kms": "^3.1164.0",
47
58
  "tsup": "^8.5.0",
48
59
  "typescript": "^5",
49
60
  "@pidgeon-ai/id": "0.2.0"