@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,320 @@
1
+ /**
2
+ * Compile-only exercise of the public declarations. Never executed — `tsc
3
+ * --noEmit` failing here means the .d.ts files drifted from the source.
4
+ *
5
+ * These declarations are hand-written and the SOURCE IMPORTS THEM, so most
6
+ * drift now fails in `src/` first. This file still earns its place: it is the
7
+ * only thing that checks the surface from OUTSIDE, the way a host consumes it —
8
+ * a type the source never happens to reference can still be wrong here.
9
+ *
10
+ * Hand-written types rot within a day. On featureboard this file immediately
11
+ * caught that `types/` was missing FOUR features added the same afternoon.
12
+ * Every exported symbol must appear below. See standards/traps.md #9.
13
+ */
14
+ import type {
15
+ ClaimsAdapter,
16
+ ClientBranding,
17
+ ClientIdMetadataConfig,
18
+ ClientSecretRecord,
19
+ ClientsApi,
20
+ ContextsApi,
21
+ CreateClientSpec,
22
+ CreatedClient,
23
+ CreateOAuthHostConfig,
24
+ GrantContext,
25
+ GrantContextAdapter,
26
+ GrantSummary,
27
+ GrantsApi,
28
+ LoadUser,
29
+ Logger,
30
+ ModelNames,
31
+ OAuthAuditDoc,
32
+ OAuthClientDoc,
33
+ OAuthCodeDoc,
34
+ OAuthError,
35
+ OAuthEvent,
36
+ OAuthGrantDoc,
37
+ OAuthHostInstance,
38
+ OAuthHostRouters,
39
+ OAuthKeyDoc,
40
+ OAuthModels,
41
+ OAuthRequestContext,
42
+ OAuthRequestDoc,
43
+ OAuthTokenDoc,
44
+ PackageUser,
45
+ ProtectOptions,
46
+ PublicClient,
47
+ RateLimitConfig,
48
+ RateLimitRule,
49
+ RateLimitStore,
50
+ ResolveUser,
51
+ ResourceSpec,
52
+ ScopeSpec,
53
+ SigningConfig,
54
+ SigningKeySpec,
55
+ TtlConfig,
56
+ UserAdapter,
57
+ UserId,
58
+ UsersApi,
59
+ } from './index.js';
60
+
61
+ declare const oauth: OAuthHostInstance;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // The degenerate case from plans/build-plan.md §0. If this ever stops
65
+ // compiling as written, the config layer grew a required key the paper test
66
+ // does not pay for.
67
+ // ---------------------------------------------------------------------------
68
+ const minimal: CreateOAuthHostConfig = {
69
+ issuer: 'https://api.example.com',
70
+ resources: [{ id: 'https://api.example.com/mcp', label: 'MCP server' }],
71
+ scopes: [
72
+ { id: 'openid', label: 'Sign you in', oidc: true },
73
+ { id: 'contacts.read', label: 'Read your contacts', description: 'Names and emails.' },
74
+ { id: 'contacts.write', label: 'Create and edit contacts', sensitive: true },
75
+ ],
76
+ consentUrl: '/settings/authorize',
77
+ };
78
+
79
+ // Strings are shorthand for `{ id, label: id }` — §0.3.
80
+ const shorthandScopes: CreateOAuthHostConfig = {
81
+ ...minimal,
82
+ scopes: ['openid', 'profile', 'email'],
83
+ };
84
+
85
+ // `defaultScopes` is optional and is plain scope ids — deliberately NOT a
86
+ // `ScopeSpec[]`, so it cannot be mistaken for a second catalog.
87
+ const withDefaultScopes: CreateOAuthHostConfig = {
88
+ ...minimal,
89
+ defaultScopes: ['openid', 'contacts.read'],
90
+ };
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Adapters, both forms and both directions.
94
+ // ---------------------------------------------------------------------------
95
+ const withFn: CreateOAuthHostConfig = {
96
+ ...minimal,
97
+ resolveUser: () => ({ id: 'abc', email: 'a@b.c', displayName: null }),
98
+ };
99
+ const withAdapter: CreateOAuthHostConfig = {
100
+ ...minimal,
101
+ userAdapter: { resolveUser: () => null } satisfies UserAdapter,
102
+ };
103
+ // The second inbound direction. `/userinfo` and the id_token are reached with
104
+ // no host session, so `resolveUser` alone cannot serve profile/email claims.
105
+ const withLoad: CreateOAuthHostConfig = {
106
+ ...minimal,
107
+ loadUser: async (id: UserId) => ({ id, email: 'a@b.c', displayName: 'A' }),
108
+ };
109
+ const bothOnAdapter: CreateOAuthHostConfig = {
110
+ ...minimal,
111
+ userAdapter: {
112
+ resolveUser: () => null,
113
+ loadUser: (id) => ({ id }),
114
+ } satisfies UserAdapter,
115
+ };
116
+ declare const load: LoadUser;
117
+ void load;
118
+ // Signed-out must be expressible — `/authorize` is a route a signed-out user
119
+ // lands on directly.
120
+ const anon: CreateOAuthHostConfig = { ...minimal, resolveUser: () => null };
121
+ // And async, for a host whose session lookup hits a store.
122
+ const asyncUser: CreateOAuthHostConfig = {
123
+ ...minimal,
124
+ resolveUser: async () => null,
125
+ };
126
+
127
+ const contextAdapter: GrantContextAdapter = {
128
+ list: (_user, { client, scopes }) => [
129
+ { id: 'org_1', label: client.name, description: scopes.join(' ') } satisfies GrantContext,
130
+ ],
131
+ verify: async (_user, contextId) => contextId.length > 0,
132
+ };
133
+
134
+ const claims: ClaimsAdapter = (user, { scopes, contextId, client }) => ({
135
+ plan: 'pro',
136
+ seen: [user.id, scopes.length, contextId, client.clientId],
137
+ });
138
+
139
+ const tenanted: CreateOAuthHostConfig = {
140
+ ...minimal,
141
+ grantContext: contextAdapter,
142
+ claims,
143
+ logger: {} satisfies Logger,
144
+ track: (event: OAuthEvent) => void event.type,
145
+ };
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Every remaining config key, so a rename in `src/` cannot pass silently.
149
+ // ---------------------------------------------------------------------------
150
+ const tuned: CreateOAuthHostConfig = {
151
+ ...minimal,
152
+ mountPath: '/oauth',
153
+ loginUrl: '/login',
154
+ returnParam: 'next',
155
+ ttl: {
156
+ code: 60,
157
+ accessToken: 3600,
158
+ refreshToken: 60 * 86_400,
159
+ refreshAbsolute: 180 * 86_400,
160
+ authorizationRequest: 600,
161
+ } satisfies TtlConfig,
162
+ subjectMode: 'pairwise',
163
+ pairwiseSalt: 'a-permanent-secret',
164
+ signing: {
165
+ autoGenerate: true,
166
+ keys: [{ kid: 'k1', privateKeyPem: '-----BEGIN PRIVATE KEY-----', alg: 'ES256' } satisfies SigningKeySpec],
167
+ } satisfies SigningConfig,
168
+ rateLimits: {
169
+ token: { max: 60, windowMs: 60_000 } satisfies RateLimitRule,
170
+ authorize: false,
171
+ consent: { max: 10, windowMs: 1_000 },
172
+ store: { hit: async () => ({ count: 1, resetAt: Date.now() }) } satisfies RateLimitStore,
173
+ } satisfies RateLimitConfig,
174
+ tokenCache: { ttlMs: 0 },
175
+ modelNames: { client: 'HostOAuthClient', grant: 'HostOAuthGrant' } satisfies ModelNames,
176
+ collectionPrefix: 'oauth_',
177
+ audit: { retentionDays: 400 },
178
+ cors: { tokenEndpoint: false, origins: [] },
179
+ clockSkewMs: 5_000,
180
+ clientIdMetadata: {
181
+ enabled: true,
182
+ allowedHosts: ['claude.ai', '.chatgpt.com'],
183
+ cacheTtlMs: 3_600_000,
184
+ fetchTimeoutMs: 5_000,
185
+ maxBytes: 65_536,
186
+ allowedScopes: ['contacts.read'],
187
+ } satisfies ClientIdMetadataConfig,
188
+ };
189
+
190
+ // `allowedHosts` is required whenever the key is present at all — the type is
191
+ // what stops "enabled, with no allowlist" from compiling in the first place.
192
+ const cimdMinimal: ClientIdMetadataConfig = { allowedHosts: ['claude.ai'] };
193
+
194
+ // A resource may narrow the catalog.
195
+ const narrowed: ResourceSpec = {
196
+ id: 'https://api.example.com/mcp',
197
+ label: 'MCP',
198
+ scopes: ['contacts.read'],
199
+ };
200
+ const scopeSpec: ScopeSpec = { id: 'a', label: 'A', description: 'd', sensitive: true, oidc: false };
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // The instance surface — §2's one screen, checked from outside.
204
+ // ---------------------------------------------------------------------------
205
+ const routers: OAuthHostRouters = oauth.routes;
206
+ void routers.discovery;
207
+ void routers.oauth;
208
+
209
+ // Both call shapes the docs show.
210
+ void oauth.protect('contacts.read');
211
+ void oauth.protect(['a', 'b'], { mode: 'any', resource: 'https://api.example.com/mcp' } satisfies ProtectOptions);
212
+ void oauth.protect();
213
+
214
+ declare const spec: CreateClientSpec;
215
+ async function adminSurface(): Promise<void> {
216
+ const clients: ClientsApi = oauth.clients;
217
+ const created: CreatedClient = await clients.create(spec);
218
+ // The secret is a plain string, returned once. If this ever becomes optional
219
+ // the provisioning script silently stops printing it.
220
+ const secret: string = created.clientSecret;
221
+ void secret;
222
+ await clients.rotateSecret(created.clientId, { retireAfter: 86_400_000, label: 'q3' });
223
+ await clients.update(created.clientId, { name: 'Claude', redirectUris: [] });
224
+ await clients.get(created.clientId);
225
+ await clients.list({ status: 'active', limit: 10, skip: 0 });
226
+ await clients.disable(created.clientId);
227
+
228
+ const grants: GrantsApi = oauth.grants;
229
+ const listed = await grants.list({ userId: 'u1', limit: 10 });
230
+ const summary: GrantSummary = listed.items[0]!;
231
+ void summary.client.branding;
232
+ await grants.revoke(summary.id, { by: 'user' });
233
+
234
+ const users: UsersApi = oauth.users;
235
+ await users.forget('u1');
236
+ await users.revokeAll('u1', { reason: 'password_change' });
237
+
238
+ const contexts: ContextsApi = oauth.contexts;
239
+ await contexts.revoked('u1', 'org_1');
240
+
241
+ await oauth.syncIndexes();
242
+ const models: OAuthModels = oauth.models;
243
+ void models.Client;
244
+ void models.Grant;
245
+ void models.Code;
246
+ void models.Token;
247
+ void models.Request;
248
+ void models.Key;
249
+ void models.Audit;
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Documents, as a host would type its own queries against them.
254
+ // ---------------------------------------------------------------------------
255
+ declare const client: OAuthClientDoc;
256
+ declare const grant: OAuthGrantDoc;
257
+ declare const code: OAuthCodeDoc;
258
+ declare const token: OAuthTokenDoc;
259
+ declare const request: OAuthRequestDoc;
260
+ declare const key: OAuthKeyDoc;
261
+ declare const audit: OAuthAuditDoc;
262
+ declare const publicClient: PublicClient;
263
+ declare const branding: ClientBranding;
264
+ declare const secretRecord: ClientSecretRecord;
265
+ declare const user: PackageUser;
266
+ declare const userId: UserId;
267
+ declare const resolve: ResolveUser;
268
+ declare const err: OAuthError;
269
+
270
+ void client.secrets;
271
+ void grant.contextId;
272
+ void code.codeChallengeMethod;
273
+ // The rotation chain has to be reachable from outside, or reuse detection is
274
+ // unverifiable by anyone auditing this package.
275
+ void token.familyId;
276
+ void token.parentId;
277
+ void request.requestId;
278
+ void key.publicJwk;
279
+ void audit.type;
280
+ void publicClient.clientId;
281
+ // A CIMD row must be distinguishable from a hand-registered one from outside —
282
+ // a "connected apps" screen showing both has to be able to say which is which.
283
+ void publicClient.registration;
284
+ void publicClient.metadataUrl;
285
+ void client.registration;
286
+ void client.metadataUrl;
287
+ void client.metadataFetchedAt;
288
+ void client.metadataEtag;
289
+ void branding.logoUrl;
290
+ void secretRecord.retiresAt;
291
+ void user.authTime;
292
+ void userId;
293
+ void resolve;
294
+ void err.code;
295
+
296
+ // The contract `mcp-server` consumes — plan §6 fixes it here.
297
+ declare const reqCtx: OAuthRequestContext;
298
+ void reqCtx.userId;
299
+ void reqCtx.clientId;
300
+ void reqCtx.contextId;
301
+ void reqCtx.scopes;
302
+ void reqCtx.grantId;
303
+ void reqCtx.tokenId;
304
+ void reqCtx.audience;
305
+
306
+ void minimal;
307
+ void shorthandScopes;
308
+ void withDefaultScopes;
309
+ void withFn;
310
+ void withAdapter;
311
+ void withLoad;
312
+ void bothOnAdapter;
313
+ void anon;
314
+ void asyncUser;
315
+ void tenanted;
316
+ void tuned;
317
+ void narrowed;
318
+ void scopeSpec;
319
+ void cimdMinimal;
320
+ void adminSurface;