@jeffjassky/oauth-host 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffjassky/oauth-host",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "OAuth 2.1 + OIDC authorization server for Express/Mongoose apps.",
5
5
  "license": "MIT",
6
6
  "author": "Jeff Jassky <jeff@jeffjassky.com>",
@@ -43,6 +43,9 @@
43
43
  "engines": {
44
44
  "node": ">=20"
45
45
  },
46
+ "dependencies": {
47
+ "jose": "^6.2.10"
48
+ },
46
49
  "scripts": {
47
50
  "build": "tsup",
48
51
  "dev": "tsup --watch",
package/types/index.d.ts CHANGED
@@ -400,9 +400,13 @@ export interface OAuthClientDoc {
400
400
  name: string;
401
401
  /**
402
402
  * `public` clients hold no secret and authenticate with `client_id` alone —
403
- * PKCE is the binding that stands in for it. Only CIMD registrations are
404
- * public today; a `confidential` client can never downgrade itself by
405
- * omitting its secret.
403
+ * PKCE is the binding that stands in for it. A `confidential` client can
404
+ * never downgrade itself by omitting its secret.
405
+ *
406
+ * Orthogonal to `registration`: a CIMD row is always public, but a public row
407
+ * is not always CIMD. `clients.create({ type: 'public' })` registers one by
408
+ * hand, which is the only path open to a client that wants PKCE-only and
409
+ * publishes no metadata document (Codex CLI).
406
410
  */
407
411
  type: 'confidential' | 'public';
408
412
  /**
@@ -418,6 +422,19 @@ export interface OAuthClientDoc {
418
422
  metadataFetchedAt?: Date;
419
423
  /** Last `ETag`, replayed as `If-None-Match` so an unchanged document is a 304. */
420
424
  metadataEtag?: string;
425
+ /**
426
+ * The usable (intersected) token endpoint auth method set for a CIMD
427
+ * client — what `validateMetadataDocument` computed from the document's
428
+ * offered methods ∩ what this server supports. Absent on a manual
429
+ * registration; its behavior derives from `type` exactly as before. A row
430
+ * with no value here supports `private_key_jwt` never — see
431
+ * `authenticateClient`.
432
+ */
433
+ tokenEndpointAuthMethods?: string[];
434
+ /** `private_key_jwt` key source: a JWKS URL. Mutually exclusive with `jwks`. */
435
+ jwksUri?: string;
436
+ /** `private_key_jwt` key source: an inline JWK Set. Mutually exclusive with `jwksUri`. */
437
+ jwks?: unknown;
421
438
  /** First-party marker. Reserved — v1 never skips consent. */
422
439
  trusted: boolean;
423
440
  /** Empty for a public client. There is no secret to store. */
@@ -574,16 +591,65 @@ export interface CreateClientSpec {
574
591
  trusted?: boolean;
575
592
  /** Supply a fixed id for a re-provisioned client. Generated when omitted. */
576
593
  clientId?: string;
594
+ /**
595
+ * How this client authenticates at `/token`. **Defaults to `confidential`**,
596
+ * so an existing caller is unaffected.
597
+ *
598
+ * `public` generates no secret at all: the registration is `client_id` plus
599
+ * PKCE, `secrets` is empty, and `rotateSecret()` on it throws. Register one
600
+ * for a client that takes a `client_id` and nothing else — Codex CLI's MCP
601
+ * login has `oauth_client_id` and no `oauth_client_secret` field — and cannot
602
+ * use CIMD because it publishes no metadata document.
603
+ */
604
+ type?: 'confidential' | 'public';
577
605
  }
578
606
 
579
- export interface CreatedClient {
607
+ /** What `clients.create({ type: 'confidential' })` and `rotateSecret()` return. */
608
+ export interface CreatedConfidentialClient {
580
609
  client: PublicClient;
581
610
  clientId: string;
611
+ type: 'confidential';
582
612
  /** Returned once. Only its SHA-256 is stored; there is no way to read it back. */
583
613
  clientSecret: string;
584
614
  }
585
615
 
616
+ /**
617
+ * What `clients.create({ type: 'public' })` returns.
618
+ *
619
+ * `clientSecret` is declared as `?: undefined` rather than omitted so that
620
+ * `created.clientSecret` still type-checks against the union below — and lands
621
+ * as `string | undefined`, which is what stops a provisioning script printing
622
+ * the word `undefined` into somebody's connector setup screen.
623
+ */
624
+ export interface CreatedPublicClient {
625
+ client: PublicClient;
626
+ clientId: string;
627
+ type: 'public';
628
+ clientSecret?: undefined;
629
+ }
630
+
631
+ /**
632
+ * Discriminated on `type`, not a single interface with an optional secret.
633
+ *
634
+ * The alternative — widening `clientSecret` to `string | undefined` on one
635
+ * shape — reads as source-compatible and is not: `const s: string =
636
+ * created.clientSecret` stops compiling either way. The difference is what
637
+ * happens to the code that does not annotate. With an optional field a
638
+ * provisioning script keeps compiling and prints `undefined`; with a union the
639
+ * caller has to say which kind of registration it asked for before it can reach
640
+ * the secret at all.
641
+ */
642
+ export type CreatedClient = CreatedConfidentialClient | CreatedPublicClient;
643
+
586
644
  export interface ClientsApi {
645
+ /**
646
+ * The overloads exist so the default path keeps its precise type. A spec with
647
+ * no `type` (or `type: 'confidential'`) returns a `clientSecret: string`
648
+ * exactly as before; only a caller that asked for `public`, or that passes a
649
+ * spec whose `type` is not known statically, has to narrow.
650
+ */
651
+ create(spec: CreateClientSpec & { type: 'public' }): Promise<CreatedPublicClient>;
652
+ create(spec: CreateClientSpec & { type?: 'confidential' }): Promise<CreatedConfidentialClient>;
587
653
  create(spec: CreateClientSpec): Promise<CreatedClient>;
588
654
  /**
589
655
  * Issue a second valid secret and retire the current one after `retireAfter`
@@ -591,9 +657,10 @@ export interface ClientsApi {
591
657
  * deployable without downtime.
592
658
  *
593
659
  * Throws on a **public** client. There is no secret to rotate, and returning
594
- * one would hand the caller a credential the token endpoint refuses.
660
+ * one would hand the caller a credential the token endpoint refuses — which
661
+ * is also why the return type is the confidential member alone.
595
662
  */
596
- rotateSecret(clientId: string, opts?: { retireAfter?: number; label?: string }): Promise<CreatedClient>;
663
+ rotateSecret(clientId: string, opts?: { retireAfter?: number; label?: string }): Promise<CreatedConfidentialClient>;
597
664
  update(clientId: string, patch: Partial<Omit<CreateClientSpec, 'clientId'>>): Promise<PublicClient>;
598
665
  list(query?: { status?: 'active' | 'disabled'; limit?: number; skip?: number }): Promise<{ items: PublicClient[]; limit: number }>;
599
666
  get(clientId: string): Promise<PublicClient | null>;