@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.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/index.cjs +2787 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +2773 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/types/index.d.ts +781 -0
- package/types/test-d.ts +320 -0
package/types/index.d.ts
ADDED
|
@@ -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
|
+
}
|