@jeffjassky/oauth-host 0.1.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.
@@ -0,0 +1,781 @@
1
+ import type { Request, RequestHandler, Router } from 'express';
2
+ import type { Connection, Model, Mongoose, Types } from 'mongoose';
3
+
4
+ /**
5
+ * Published declarations for `@jeffjassky/oauth-host`.
6
+ *
7
+ * Hand-written, not generated — `tsup` runs with `dts: false` and `tsc
8
+ * --noEmit` is what keeps these honest against `src/`. The source imports its
9
+ * public shapes FROM this file, so a field added to a schema or a method added
10
+ * to the instance without a matching declaration here is a compile error rather
11
+ * than a silent divergence. See standards/traps.md #9.
12
+ *
13
+ * Terminology follows the specs, not internal habits: `client` is the
14
+ * third-party app (Claude, ChatGPT), `user` is the resource owner, `host` is
15
+ * the Express app embedding this package, `resource` is an API a token can be
16
+ * audience-bound to.
17
+ */
18
+
19
+ /** A host user id. The package stores it; it never interprets it. */
20
+ export type UserId = Types.ObjectId | string;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Configuration
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * One entry in the host's scope catalog.
28
+ *
29
+ * The consent endpoint's entire job is serving `label`/`description`/`sensitive`
30
+ * to the host's UI, which is why scopes are objects rather than strings. A bare
31
+ * string is accepted as shorthand and inflated to `{ id, label: id }`.
32
+ */
33
+ export interface ScopeSpec {
34
+ id: string;
35
+ /** Short imperative phrase shown in the consent list. Defaults to `id`. */
36
+ label?: string;
37
+ /** Optional second line. */
38
+ description?: string;
39
+ /** Render with emphasis — write access, destructive access, billing. */
40
+ sensitive?: boolean;
41
+ /** An OIDC scope (`openid`, `profile`, `email`). Drives claim mapping. */
42
+ oidc?: boolean;
43
+ }
44
+
45
+ /** An API that tokens can be audience-bound to (RFC 8707). */
46
+ export interface ResourceSpec {
47
+ /** Absolute URI. MUST match what the client sends as `resource`. */
48
+ id: string;
49
+ label?: string;
50
+ /** Scopes valid at this resource. Omitted means the whole catalog. */
51
+ scopes?: string[];
52
+ }
53
+
54
+ export interface TtlConfig {
55
+ /** Authorization code lifetime. Default 60s; RFC 6749 §4.1.2 caps at 10min. */
56
+ code?: number;
57
+ /** Access token lifetime. Default 3600s. */
58
+ accessToken?: number;
59
+ /** Refresh token sliding lifetime. Default 5_184_000s (60d). */
60
+ refreshToken?: number;
61
+ /** Refresh token absolute ceiling, from first issuance. Default 180d. */
62
+ refreshAbsolute?: number;
63
+ /** How long a pending consent handle lives. Default 600s (10min). */
64
+ authorizationRequest?: number;
65
+ }
66
+
67
+ export interface SigningKeySpec {
68
+ kid: string;
69
+ /** PKCS#8 PEM. ES256 (P-256) only in v1. */
70
+ privateKeyPem: string;
71
+ /** SPKI PEM. Derived from the private key when omitted. */
72
+ publicKeyPem?: string;
73
+ alg?: 'ES256';
74
+ status?: 'active' | 'retiring';
75
+ }
76
+
77
+ export interface SigningConfig {
78
+ keys?: SigningKeySpec[];
79
+ /**
80
+ * Generate a keypair and persist it in `oauth_keys` when none exists.
81
+ * A development convenience: it means the signing key lives in the same
82
+ * database as the tokens it signs. Default false.
83
+ */
84
+ autoGenerate?: boolean;
85
+ }
86
+
87
+ export interface RateLimitRule {
88
+ /** Requests allowed per window. */
89
+ max: number;
90
+ /** Window length in ms. */
91
+ windowMs: number;
92
+ }
93
+
94
+ /**
95
+ * Swap the in-memory counter for something shared.
96
+ *
97
+ * The default is per-process, which on more than one instance means the
98
+ * effective limit is `max × instances`. Documented rather than hidden.
99
+ */
100
+ export interface RateLimitStore {
101
+ hit(key: string, windowMs: number): Promise<{ count: number; resetAt: number }>;
102
+ }
103
+
104
+ export interface RateLimitConfig {
105
+ token?: RateLimitRule | false;
106
+ authorize?: RateLimitRule | false;
107
+ consent?: RateLimitRule | false;
108
+ store?: RateLimitStore;
109
+ }
110
+
111
+ /**
112
+ * Client ID Metadata Documents — a client whose `client_id` IS an `https://`
113
+ * URL serving a JSON document that describes it (`client_name`,
114
+ * `redirect_uris`, branding). The server fetches that document and treats it as
115
+ * the registration. No registration endpoint, no registration access tokens, no
116
+ * per-install provisioning; both Claude and ChatGPT prefer it over RFC 7591 DCR.
117
+ *
118
+ * **This makes the authorization server issue an outbound HTTP request driven
119
+ * by an unauthenticated request parameter — server-side request forgery by
120
+ * construction.** `allowedHosts` is the control that contains it, which is why
121
+ * it is required rather than defaulted: an empty or missing list is a boot
122
+ * error, never an implicit "any host". See docs/guide/cimd.md.
123
+ */
124
+ export interface ClientIdMetadataConfig {
125
+ /** Default false. Off is the safe default — the SSRF surface does not exist until you turn it on. */
126
+ enabled?: boolean;
127
+ /**
128
+ * Hosts whose metadata documents may be fetched. **Required when `enabled`.**
129
+ *
130
+ * An entry matches the hostname exactly and case-insensitively
131
+ * (`claude.ai` does NOT match `evilclaude.ai`). Write it with a leading dot
132
+ * (`.claude.ai`) to also admit subdomains. A port is matched only when the
133
+ * entry names one (`localhost:8080`); otherwise the URL must use the default.
134
+ */
135
+ allowedHosts: string[];
136
+ /** How long a fetched document is trusted before a re-fetch. Default 3_600_000 (1h). */
137
+ cacheTtlMs?: number;
138
+ /** Hard ceiling on the outbound request. Default 5_000. */
139
+ fetchTimeoutMs?: number;
140
+ /** Response body cap, enforced while reading — `Content-Length` is not trusted. Default 65_536. */
141
+ maxBytes?: number;
142
+ /**
143
+ * Scopes a CIMD client may ever request, whatever its document claims.
144
+ * Defaults to the full catalog. The document's own `scope` narrows further.
145
+ */
146
+ allowedScopes?: string[];
147
+ }
148
+
149
+ export interface ModelNames {
150
+ client?: string;
151
+ grant?: string;
152
+ code?: string;
153
+ token?: string;
154
+ request?: string;
155
+ key?: string;
156
+ audit?: string;
157
+ }
158
+
159
+ export interface Logger {
160
+ debug?(...args: unknown[]): void;
161
+ info?(...args: unknown[]): void;
162
+ warn?(...args: unknown[]): void;
163
+ error?(...args: unknown[]): void;
164
+ }
165
+
166
+ /** The identity the package needs. Never an auth result — the host authed already. */
167
+ export interface PackageUser {
168
+ id: UserId;
169
+ email?: string;
170
+ displayName?: string | null;
171
+ avatarUrl?: string | null;
172
+ /**
173
+ * When the host's session was established. Drives `auth_time`, `max_age`
174
+ * and `prompt=login`. Omitted means those are unsupported for this host.
175
+ */
176
+ authTime?: Date;
177
+ /** Drives badges only. Gates nothing — see standards/adapters.md. */
178
+ isAdmin?: boolean;
179
+ }
180
+
181
+ /** Inbound direction of the user adapter. Returns null for a signed-out caller. */
182
+ export type ResolveUser = (req: Request) => PackageUser | null | Promise<PackageUser | null>;
183
+
184
+ /**
185
+ * Second inbound direction: load a user the package only has an id for.
186
+ *
187
+ * `resolveUser` cannot serve `/userinfo` or the `id_token` — both are reached
188
+ * on the client-authenticated and bearer bands, where there is no host session
189
+ * to read and the only identity available is the `userId` on the token. Without
190
+ * this, `profile`/`email` claims come back empty no matter what the host
191
+ * configured.
192
+ *
193
+ * The alternative — snapshotting the profile at consent and carrying it on the
194
+ * grant — was rejected: it makes `/userinfo` serve a name the user changed
195
+ * eight months ago, and this package's stated contract is that tokens carry no
196
+ * claims and `/userinfo` reads live.
197
+ */
198
+ export type LoadUser = (userId: UserId) => PackageUser | null | Promise<PackageUser | null>;
199
+
200
+ export interface UserAdapter {
201
+ resolveUser: ResolveUser;
202
+ loadUser?: LoadUser;
203
+ }
204
+
205
+ /**
206
+ * What a user can grant access *on behalf of* — an org, a workspace, a team.
207
+ *
208
+ * Omit the adapter entirely and the package runs in single-subject mode: no
209
+ * picker in the consent payload, no `contextId` on the grant, no claim in the
210
+ * token, no membership re-check on refresh.
211
+ */
212
+ export interface GrantContext {
213
+ id: string;
214
+ label: string;
215
+ description?: string;
216
+ }
217
+
218
+ export interface GrantContextAdapter {
219
+ /** What this user may grant, for this client and scope set. */
220
+ list(
221
+ user: PackageUser,
222
+ ctx: { client: PublicClient; scopes: string[] },
223
+ ): GrantContext[] | Promise<GrantContext[]>;
224
+ /**
225
+ * Still a member? Re-checked on **every refresh**, not only at consent —
226
+ * a grant made as an employee must die when the employment does.
227
+ */
228
+ verify(user: PackageUser, contextId: string): boolean | Promise<boolean>;
229
+ }
230
+
231
+ /**
232
+ * Extra claims for `id_token` and `/userinfo`.
233
+ *
234
+ * Inbound only, deliberately: claims are a read-only projection of host data,
235
+ * and access tokens carry none — `/userinfo` reads live on every call. There is
236
+ * no "a claim changed" event because nothing is cached to invalidate.
237
+ */
238
+ export type ClaimsAdapter = (
239
+ user: PackageUser,
240
+ ctx: { scopes: string[]; contextId?: string; client: PublicClient },
241
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
242
+
243
+ export interface CreateOAuthHostConfig {
244
+ /** Mongoose connection. Defaults to the global mongoose instance. */
245
+ connection?: Mongoose | Connection;
246
+
247
+ /**
248
+ * The public origin this server is reached at, no trailing slash. Appears in
249
+ * discovery metadata, in `iss` on every authorization response (RFC 9207),
250
+ * and in `id_token.iss`. A mismatch with the URL the client actually used is
251
+ * a spec violation clients do check.
252
+ */
253
+ issuer: string;
254
+
255
+ /**
256
+ * Where the host mounts `routes.oauth`, relative to `issuer`. Defaults to
257
+ * `/oauth`.
258
+ *
259
+ * The package has to be told: discovery metadata publishes absolute
260
+ * `authorization_endpoint` / `token_endpoint` URLs, and a router cannot see
261
+ * the path it was mounted at until a request arrives — by which time the
262
+ * metadata document has already been built. A mismatch here is the failure
263
+ * where discovery looks fine and every client 404s on `/token`.
264
+ */
265
+ mountPath?: string;
266
+
267
+ /** APIs tokens can be bound to (RFC 8707). At least one. */
268
+ resources: ResourceSpec[];
269
+
270
+ /** The scope catalog. Strings are shorthand for `{ id, label: id }`. */
271
+ scopes: (ScopeSpec | string)[];
272
+
273
+ /**
274
+ * What `/authorize` grants when the request omits `scope`. RFC 6749 §3.3
275
+ * makes the parameter optional and leaves the fallback to the server.
276
+ *
277
+ * Deliberately NOT the client's `allowedScopes`. That list is a registration
278
+ * ceiling — everything the client may ever ask for — and making it double as
279
+ * the default hands a broadly registered client the whole catalog the moment
280
+ * it drops one parameter. The two lists answer different questions.
281
+ *
282
+ * Always intersected with the client's `allowedScopes`, so a default can
283
+ * never exceed a registration. Every entry must be in the catalog, and an
284
+ * empty array is a boot error: omit the key instead. Omitted, a `scope`-less
285
+ * `/authorize` is an `invalid_scope` error naming both ways to fix it —
286
+ * an empty grant would be a token that can do nothing.
287
+ */
288
+ defaultScopes?: string[];
289
+
290
+ /**
291
+ * Where `/authorize` sends a signed-in user to approve. The package appends
292
+ * `?request_id=…`; the host's page fetches the consent JSON with it.
293
+ */
294
+ consentUrl: string;
295
+
296
+ /** Where to send a signed-out user. See standards/adapters.md §Sign-in redirects. */
297
+ loginUrl?: string;
298
+ /** Query param carrying the return URL. Defaults to `next`. */
299
+ returnParam?: string;
300
+
301
+ userAdapter?: UserAdapter;
302
+ /** Shorthand for a one-method `userAdapter`. Mutually exclusive with it. */
303
+ resolveUser?: ResolveUser;
304
+ /** Shorthand for `userAdapter.loadUser`. Mutually exclusive with `userAdapter`. */
305
+ loadUser?: LoadUser;
306
+ grantContext?: GrantContextAdapter;
307
+ claims?: ClaimsAdapter;
308
+
309
+ ttl?: TtlConfig;
310
+
311
+ /**
312
+ * `public` (default) — `sub` is the host's user id.
313
+ * `pairwise` — `sub` is HMAC(userId, clientId, salt), stable per client.
314
+ *
315
+ * Permanent per client once issued: partners store the value. Choose before
316
+ * first issuance. OIDC Core §8.
317
+ */
318
+ subjectMode?: 'public' | 'pairwise';
319
+ /** Required when `subjectMode` is `pairwise`. */
320
+ pairwiseSalt?: string;
321
+
322
+ signing?: SigningConfig;
323
+ rateLimits?: RateLimitConfig;
324
+
325
+ /**
326
+ * Cache introspected access tokens for this many ms. **Default 0 — off.**
327
+ *
328
+ * Any non-zero value is the window in which a revoked token still works.
329
+ * That is a real tradeoff, not a free performance knob, which is why it is
330
+ * opt-in and named for the thing it costs.
331
+ */
332
+ tokenCache?: { ttlMs?: number };
333
+
334
+ /** Override model names when a collision with a host model is plausible — traps #2. */
335
+ modelNames?: ModelNames;
336
+ /** Collection name prefix. Defaults to `oauth_`. */
337
+ collectionPrefix?: string;
338
+
339
+ audit?: { retentionDays?: number };
340
+
341
+ /**
342
+ * Send CORS headers on `/token` and `/revoke`. **Default off** — v1 has no
343
+ * public clients, so a browser has no business calling either.
344
+ */
345
+ cors?: { tokenEndpoint?: boolean; origins?: string[] };
346
+
347
+ /** Tolerance for a client's wrong clock on `id_token` `iat`/`nbf`. Default 0. */
348
+ clockSkewMs?: number;
349
+
350
+ /** Client ID Metadata Documents. Off unless `enabled` — see the interface. */
351
+ clientIdMetadata?: ClientIdMetadataConfig;
352
+
353
+ logger?: Logger;
354
+ /** Optional peer seam for analytics. No-op by default; never a hard dep. */
355
+ track?: (event: OAuthEvent) => void;
356
+ }
357
+
358
+ export interface OAuthEvent {
359
+ type:
360
+ | 'oauth.authorization_requested'
361
+ | 'oauth.consent_granted'
362
+ | 'oauth.consent_denied'
363
+ | 'oauth.token_issued'
364
+ | 'oauth.token_refreshed'
365
+ | 'oauth.refresh_reuse_detected'
366
+ | 'oauth.client_secret_rotated'
367
+ | 'oauth.grant_revoked';
368
+ userId?: UserId;
369
+ clientId?: string;
370
+ grantId?: string;
371
+ contextId?: string;
372
+ scopes?: string[];
373
+ meta?: Record<string, unknown>;
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Documents
378
+ // ---------------------------------------------------------------------------
379
+
380
+ export interface ClientBranding {
381
+ logoUrl?: string;
382
+ publisher?: string;
383
+ homepageUrl?: string;
384
+ tosUrl?: string;
385
+ privacyUrl?: string;
386
+ }
387
+
388
+ export interface ClientSecretRecord {
389
+ hash: string;
390
+ label?: string;
391
+ createdAt: Date;
392
+ lastUsedAt?: Date;
393
+ /** Set by `rotateSecret`. The secret stops verifying after this instant. */
394
+ retiresAt?: Date;
395
+ }
396
+
397
+ export interface OAuthClientDoc {
398
+ _id: Types.ObjectId;
399
+ clientId: string;
400
+ name: string;
401
+ /**
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.
406
+ */
407
+ type: 'confidential' | 'public';
408
+ /**
409
+ * How this registration came to exist. `cimd` rows are re-derived from the
410
+ * client's metadata document on a cache miss, so anything an operator edits
411
+ * on one is overwritten on the next fetch — with the deliberate exception of
412
+ * `status`, which a re-fetch never resurrects.
413
+ */
414
+ registration: 'manual' | 'cimd';
415
+ /** The document URL a `cimd` registration was fetched from. */
416
+ metadataUrl?: string;
417
+ /** When that document was last fetched. Compared against `clientIdMetadata.cacheTtlMs`. */
418
+ metadataFetchedAt?: Date;
419
+ /** Last `ETag`, replayed as `If-None-Match` so an unchanged document is a 304. */
420
+ metadataEtag?: string;
421
+ /** First-party marker. Reserved — v1 never skips consent. */
422
+ trusted: boolean;
423
+ /** Empty for a public client. There is no secret to store. */
424
+ secrets: ClientSecretRecord[];
425
+ redirectUris: string[];
426
+ allowedScopes: string[];
427
+ allowedResources: string[];
428
+ branding: ClientBranding;
429
+ status: 'active' | 'disabled';
430
+ /** Pairwise `sub` values already handed to this client, by user id. */
431
+ pairwiseSubjects?: Map<string, string>;
432
+ createdAt: Date;
433
+ updatedAt: Date;
434
+ }
435
+
436
+ export interface OAuthGrantDoc {
437
+ _id: Types.ObjectId;
438
+ userId: UserId;
439
+ clientId: string;
440
+ /** Single-subject mode stores `null`, which the unique index treats as a value. */
441
+ contextId: string | null;
442
+ scopes: string[];
443
+ resources: string[];
444
+ /** Bumped when the user approves a superset. Drives `isNew` on re-consent. */
445
+ version: number;
446
+ lastUsedAt?: Date;
447
+ revokedAt?: Date | null;
448
+ revokedBy?: 'user' | 'admin' | 'system' | 'client';
449
+ createdAt: Date;
450
+ updatedAt: Date;
451
+ }
452
+
453
+ export interface OAuthCodeDoc {
454
+ _id: Types.ObjectId;
455
+ codeHash: string;
456
+ clientId: string;
457
+ userId: UserId;
458
+ grantId: Types.ObjectId;
459
+ contextId: string | null;
460
+ scopes: string[];
461
+ resources: string[];
462
+ redirectUri: string;
463
+ codeChallenge: string;
464
+ codeChallengeMethod: 'S256';
465
+ nonce?: string;
466
+ authTime?: Date;
467
+ consumedAt?: Date | null;
468
+ expiresAt: Date;
469
+ createdAt: Date;
470
+ }
471
+
472
+ export interface OAuthTokenDoc {
473
+ _id: Types.ObjectId;
474
+ kind: 'access' | 'refresh';
475
+ tokenHash: string;
476
+ clientId: string;
477
+ userId: UserId;
478
+ grantId: Types.ObjectId;
479
+ contextId: string | null;
480
+ scopes: string[];
481
+ audience: string[];
482
+ /** Shared by every token descended from one authorization code. */
483
+ familyId: string;
484
+ /** The refresh token this one replaced. Null for the first in a family. */
485
+ parentId?: Types.ObjectId | null;
486
+ consumedAt?: Date | null;
487
+ revokedAt?: Date | null;
488
+ /** Absolute ceiling for the family, copied onto each rotation. */
489
+ familyExpiresAt?: Date;
490
+ expiresAt: Date;
491
+ createdAt: Date;
492
+ }
493
+
494
+ export interface OAuthRequestDoc {
495
+ _id: Types.ObjectId;
496
+ requestId: string;
497
+ clientId: string;
498
+ userId: UserId;
499
+ redirectUri: string;
500
+ scopes: string[];
501
+ resources: string[];
502
+ state?: string;
503
+ nonce?: string;
504
+ codeChallenge: string;
505
+ codeChallengeMethod: 'S256';
506
+ prompt?: string;
507
+ maxAge?: number;
508
+ decision?: 'approved' | 'denied';
509
+ decidedAt?: Date;
510
+ expiresAt: Date;
511
+ createdAt: Date;
512
+ }
513
+
514
+ export interface OAuthKeyDoc {
515
+ _id: Types.ObjectId;
516
+ kid: string;
517
+ alg: 'ES256';
518
+ publicJwk: Record<string, unknown>;
519
+ privateJwk: Record<string, unknown>;
520
+ status: 'active' | 'retiring';
521
+ createdAt: Date;
522
+ }
523
+
524
+ export interface OAuthAuditDoc {
525
+ _id: Types.ObjectId;
526
+ type: string;
527
+ actor?: 'user' | 'client' | 'admin' | 'system';
528
+ clientId?: string;
529
+ userId?: UserId;
530
+ grantId?: Types.ObjectId;
531
+ ip?: string;
532
+ meta?: Record<string, unknown>;
533
+ createdAt: Date;
534
+ }
535
+
536
+ export interface OAuthModels {
537
+ Client: Model<OAuthClientDoc>;
538
+ Grant: Model<OAuthGrantDoc>;
539
+ Code: Model<OAuthCodeDoc>;
540
+ Token: Model<OAuthTokenDoc>;
541
+ Request: Model<OAuthRequestDoc>;
542
+ Key: Model<OAuthKeyDoc>;
543
+ Audit: Model<OAuthAuditDoc>;
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // Programmatic admin API
548
+ // ---------------------------------------------------------------------------
549
+
550
+ /** A client as anything outside the package may see it. No secrets, ever. */
551
+ export interface PublicClient {
552
+ clientId: string;
553
+ name: string;
554
+ type: 'confidential' | 'public';
555
+ /** `cimd` rows appear in `clients.list()` and are revocable like any other. */
556
+ registration: 'manual' | 'cimd';
557
+ metadataUrl?: string;
558
+ trusted: boolean;
559
+ redirectUris: string[];
560
+ allowedScopes: string[];
561
+ allowedResources: string[];
562
+ branding: ClientBranding;
563
+ status: 'active' | 'disabled';
564
+ createdAt: Date;
565
+ }
566
+
567
+ export interface CreateClientSpec {
568
+ name: string;
569
+ redirectUris: string[];
570
+ allowedScopes: string[];
571
+ /** Defaults to every configured resource. */
572
+ allowedResources?: string[];
573
+ branding?: ClientBranding;
574
+ trusted?: boolean;
575
+ /** Supply a fixed id for a re-provisioned client. Generated when omitted. */
576
+ clientId?: string;
577
+ }
578
+
579
+ export interface CreatedClient {
580
+ client: PublicClient;
581
+ clientId: string;
582
+ /** Returned once. Only its SHA-256 is stored; there is no way to read it back. */
583
+ clientSecret: string;
584
+ }
585
+
586
+ export interface ClientsApi {
587
+ create(spec: CreateClientSpec): Promise<CreatedClient>;
588
+ /**
589
+ * Issue a second valid secret and retire the current one after `retireAfter`
590
+ * ms (default 0 — immediate). Two live secrets is what makes a rotation
591
+ * deployable without downtime.
592
+ *
593
+ * 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.
595
+ */
596
+ rotateSecret(clientId: string, opts?: { retireAfter?: number; label?: string }): Promise<CreatedClient>;
597
+ update(clientId: string, patch: Partial<Omit<CreateClientSpec, 'clientId'>>): Promise<PublicClient>;
598
+ list(query?: { status?: 'active' | 'disabled'; limit?: number; skip?: number }): Promise<{ items: PublicClient[]; limit: number }>;
599
+ get(clientId: string): Promise<PublicClient | null>;
600
+ /** Disables the client and revokes every grant and token it holds. */
601
+ disable(clientId: string): Promise<{ grantsRevoked: number; tokensRevoked: number }>;
602
+ }
603
+
604
+ export interface GrantSummary {
605
+ id: string;
606
+ client: Pick<PublicClient, 'clientId' | 'name' | 'branding'>;
607
+ scopes: ScopeSpec[];
608
+ context?: GrantContext;
609
+ createdAt: Date;
610
+ lastUsedAt?: Date;
611
+ }
612
+
613
+ export interface GrantsApi {
614
+ list(query: { userId?: UserId; clientId?: string; limit?: number; skip?: number }): Promise<{ items: GrantSummary[]; limit: number }>;
615
+ revoke(grantId: string, opts?: { by?: 'user' | 'admin' | 'system' | 'client' }): Promise<{ tokensRevoked: number }>;
616
+ }
617
+
618
+ export interface UsersApi {
619
+ /** Erasure. Deletes grants, tokens, codes, requests and pairwise subjects. */
620
+ forget(userId: UserId): Promise<{ grants: number; tokens: number }>;
621
+ /** Password change, deactivation — kill live access, keep the audit trail. */
622
+ revokeAll(userId: UserId, opts?: { reason?: string }): Promise<{ grantsRevoked: number; tokensRevoked: number }>;
623
+ }
624
+
625
+ export interface ContextsApi {
626
+ /** Membership ended — revoke every grant this user made for this context. */
627
+ revoked(userId: UserId, contextId: string): Promise<{ grantsRevoked: number; tokensRevoked: number }>;
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Resource server
632
+ // ---------------------------------------------------------------------------
633
+
634
+ /** What `protect()` leaves on the request. The contract `mcp-server` consumes. */
635
+ export interface OAuthRequestContext {
636
+ userId: UserId;
637
+ clientId: string;
638
+ contextId: string | null;
639
+ scopes: string[];
640
+ grantId: string;
641
+ tokenId: string;
642
+ audience: string[];
643
+ }
644
+
645
+ export interface ProtectOptions {
646
+ /** Which declared resource this middleware guards. Defaults to the first. */
647
+ resource?: string;
648
+ /** Require every listed scope (the default) or any one of them. */
649
+ mode?: 'all' | 'any';
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Instance
654
+ // ---------------------------------------------------------------------------
655
+
656
+ export interface OAuthHostRouters {
657
+ /** Mounted at the origin root — `/.well-known/*` is not relocatable. */
658
+ discovery: Router;
659
+ /** Mounted wherever `issuer`-relative endpoints live, e.g. `/oauth`. */
660
+ oauth: Router;
661
+ }
662
+
663
+ export interface OAuthHostInstance {
664
+ routes: OAuthHostRouters;
665
+ /**
666
+ * Resource-server middleware.
667
+ *
668
+ * app.use('/mcp', oauth.protect('contacts.read'), mcpRouter)
669
+ * app.use('/mcp', oauth.protect(['a', 'b'], { mode: 'any' }), mcpRouter)
670
+ */
671
+ protect(scopes?: string | string[], opts?: ProtectOptions): RequestHandler;
672
+
673
+ clients: ClientsApi;
674
+ grants: GrantsApi;
675
+ users: UsersApi;
676
+ contexts: ContextsApi;
677
+
678
+ /** Build every index. Await at boot, before the first write — traps #3. */
679
+ syncIndexes(): Promise<void>;
680
+ /**
681
+ * True once `syncIndexes()` has resolved.
682
+ *
683
+ * A boolean rather than a promise on purpose: a promise would have to exist
684
+ * from construction, so a host that never calls `syncIndexes()` would await it
685
+ * forever and the mistake would present as a hang with no message. Until this
686
+ * is true, `/authorize`, `POST /consent/:requestId` and `POST /token` answer
687
+ * `server_error` naming `syncIndexes()`; discovery, `/jwks`, `/userinfo` and
688
+ * `protect()` keep serving, because taking a read-only API down would turn a
689
+ * boot-order mistake into an outage.
690
+ */
691
+ readonly ready: boolean;
692
+ /** Escape hatch. Prefer the APIs above; these have no invariants attached. */
693
+ models: OAuthModels;
694
+ }
695
+
696
+ export declare function createOAuthHost(config: CreateOAuthHostConfig): OAuthHostInstance;
697
+
698
+ export declare function createModels(opts: {
699
+ connection?: Mongoose | Connection;
700
+ modelNames?: ModelNames;
701
+ collectionPrefix?: string;
702
+ auditRetentionDays?: number;
703
+ }): OAuthModels;
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // Vendor callback URLs
707
+ // ---------------------------------------------------------------------------
708
+
709
+ /**
710
+ * Callback URLs for the connectors people actually register, shipped as
711
+ * constants because `clients.create()` compares them byte-for-byte and
712
+ * retyping one out of a vendor's documentation is the most typo-prone step in
713
+ * the whole setup.
714
+ *
715
+ * **All of these are vendor product details, not standards.** They can change
716
+ * without notice and without a release of this package; each is stamped in the
717
+ * source with the date it was verified. See docs/guide/mcp.md.
718
+ */
719
+
720
+ /** claude.ai's hosted connector callback. Verified 2026-08-12. */
721
+ export declare const CLAUDE_CONNECTOR_REDIRECT_URI: string;
722
+
723
+ /**
724
+ * Claude Code's local callbacks. Register both; the PORT varies per session and
725
+ * is allowed to (RFC 8252 §7.3), so register them WITHOUT one.
726
+ * Verified 2026-08-12.
727
+ */
728
+ export declare const CLAUDE_CODE_REDIRECT_URIS: readonly string[];
729
+
730
+ /** ChatGPT's legacy single connector callback. Verified 2026-08-12. */
731
+ export declare const CHATGPT_LEGACY_REDIRECT_URI: string;
732
+
733
+ /**
734
+ * The SHAPE of ChatGPT's current per-connector callback, not a value to
735
+ * register — `{callback_id}` is assigned when the connector is created and the
736
+ * only correct source for the full URL is the connector's own setup screen.
737
+ * Verified 2026-08-12.
738
+ */
739
+ export declare const CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN: string;
740
+
741
+ /**
742
+ * A suggested `clientIdMetadata.allowedHosts` for both vendors. A suggestion,
743
+ * never a default — that key is the control that contains CIMD's SSRF surface.
744
+ * Verified 2026-08-12.
745
+ */
746
+ export declare const CIMD_ALLOWED_HOSTS: readonly string[];
747
+
748
+ export declare function defaultResolveUser(req: Request): PackageUser | null;
749
+ export declare function createUserAdapter(opts?: {
750
+ resolveUser?: ResolveUser;
751
+ loadUser?: LoadUser;
752
+ }): UserAdapter;
753
+
754
+ /**
755
+ * A spec-shaped failure.
756
+ *
757
+ * `code` is the RFC token (`invalid_grant`, `invalid_client`, …) and is what
758
+ * reaches the wire as `error`. Never invent one: clients switch on these.
759
+ */
760
+ export declare class OAuthError extends Error {
761
+ constructor(
762
+ status: number,
763
+ code: string,
764
+ description?: string,
765
+ opts?: { headers?: Record<string, string> },
766
+ );
767
+ status: number;
768
+ code: string;
769
+ description?: string;
770
+ headers?: Record<string, string>;
771
+ }
772
+
773
+ declare global {
774
+ // eslint-disable-next-line @typescript-eslint/no-namespace
775
+ namespace Express {
776
+ interface Request {
777
+ /** Set by `oauth.protect()`. Absent on unprotected routes. */
778
+ oauth?: OAuthRequestContext;
779
+ }
780
+ }
781
+ }