@grest-ts/oauth 0.0.0-stage → 0.0.90

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -2
  3. package/dist/src/_dedupCheck.d.ts +2 -0
  4. package/dist/src/_dedupCheck.d.ts.map +1 -0
  5. package/dist/src/_dedupCheck.js +28 -0
  6. package/dist/src/_dedupCheck.js.map +1 -0
  7. package/dist/src/browser/GGOAuthClient.d.ts +50 -0
  8. package/dist/src/browser/GGOAuthClient.d.ts.map +1 -0
  9. package/dist/src/browser/GGOAuthClient.js +152 -0
  10. package/dist/src/browser/GGOAuthClient.js.map +1 -0
  11. package/dist/src/index-browser.d.ts +6 -0
  12. package/dist/src/index-browser.d.ts.map +1 -0
  13. package/dist/src/index-browser.js +4 -0
  14. package/dist/src/index-browser.js.map +1 -0
  15. package/dist/src/index-node.d.ts +8 -0
  16. package/dist/src/index-node.d.ts.map +1 -0
  17. package/dist/src/index-node.js +5 -0
  18. package/dist/src/index-node.js.map +1 -0
  19. package/dist/src/node/GGOAuthServer.d.ts +72 -0
  20. package/dist/src/node/GGOAuthServer.d.ts.map +1 -0
  21. package/dist/src/node/GGOAuthServer.js +430 -0
  22. package/dist/src/node/GGOAuthServer.js.map +1 -0
  23. package/dist/src/node/store/DynamoOAuthStore.d.ts +28 -0
  24. package/dist/src/node/store/DynamoOAuthStore.d.ts.map +1 -0
  25. package/dist/src/node/store/DynamoOAuthStore.js +78 -0
  26. package/dist/src/node/store/DynamoOAuthStore.js.map +1 -0
  27. package/dist/src/node/store/GGOAuthStore.d.ts +18 -0
  28. package/dist/src/node/store/GGOAuthStore.d.ts.map +1 -0
  29. package/dist/src/node/store/GGOAuthStore.js +16 -0
  30. package/dist/src/node/store/GGOAuthStore.js.map +1 -0
  31. package/dist/src/node/store/InMemoryOAuthStore.d.ts +16 -0
  32. package/dist/src/node/store/InMemoryOAuthStore.d.ts.map +1 -0
  33. package/dist/src/node/store/InMemoryOAuthStore.js +44 -0
  34. package/dist/src/node/store/InMemoryOAuthStore.js.map +1 -0
  35. package/dist/src/shared/interactionContract.d.ts +77 -0
  36. package/dist/src/shared/interactionContract.d.ts.map +1 -0
  37. package/dist/src/shared/interactionContract.js +34 -0
  38. package/dist/src/shared/interactionContract.js.map +1 -0
  39. package/dist/src/tsconfig.json +18 -0
  40. package/dist/tsconfig.publish.tsbuildinfo +1 -0
  41. package/package.json +71 -4
  42. package/src/_dedupCheck.ts +29 -0
  43. package/src/browser/GGOAuthClient.test.ts +76 -0
  44. package/src/browser/GGOAuthClient.ts +187 -0
  45. package/src/index-browser.ts +5 -0
  46. package/src/index-node.ts +7 -0
  47. package/src/node/GGOAuthServer.test.ts +416 -0
  48. package/src/node/GGOAuthServer.ts +500 -0
  49. package/src/node/store/DynamoOAuthStore.ts +93 -0
  50. package/src/node/store/GGOAuthStore.ts +41 -0
  51. package/src/node/store/InMemoryOAuthStore.ts +54 -0
  52. package/src/shared/interactionContract.ts +39 -0
  53. package/src/tsconfig.json +18 -0
@@ -0,0 +1,500 @@
1
+ import {createECDH, createHash, hkdfSync, randomBytes} from "node:crypto"
2
+ import type {IncomingMessage, ServerResponse} from "node:http"
3
+ import Provider, {errors as oidcErrors, type Configuration, type InteractionResults, type KoaContextWithOIDC} from "oidc-provider"
4
+ import {NOT_FOUND} from "@grest-ts/schema"
5
+ import type {GGHttpServer} from "@grest-ts/http"
6
+ import type {GGAccessPayload, GGAuthAccessToken} from "@grest-ts/auth"
7
+ import {GGOAuthInteractionContract, type GGOAuthInteraction} from "../shared/interactionContract"
8
+ import {toOidcAdapter, type GGOAuthStore} from "./store/GGOAuthStore"
9
+
10
+ export interface GGOAuthFirstPartyClient {
11
+ clientId: string
12
+ name: string
13
+ redirectUris: string[]
14
+ postLogoutRedirectUris?: string[]
15
+ }
16
+
17
+ // One API the server issues tokens for (RFC 8707 resource indicator → its own audience and claims).
18
+ export interface GGOAuthResource<C extends object> {
19
+ // Absolute URL identifying the API; for an MCP server, its URL exactly as users enter it.
20
+ resource: () => string
21
+ // Tokens for this resource ARE this token kind (signer, audience — defaults to the resource —,
22
+ // TTL, claim schema), so the wire already verifying it accepts them. Signer must expose jwsKey().
23
+ accessToken: GGAuthAccessToken<C>
24
+ // At least one: a request granting no scope is refused. What they permit is up to the app.
25
+ scopes: [string, ...string[]]
26
+ // Re-run at every issuance (code exchange and each refresh), like GGAuthRefreshToken's resolve:
27
+ // permission changes apply on refresh. undefined = subject gone → invalid_grant.
28
+ claims: (subject: string, client: {clientId: string, name: string}) => Promise<C | undefined>
29
+ // Only pre-registered first-party clients may get tokens for it.
30
+ firstPartyOnly?: boolean
31
+ }
32
+
33
+ export interface GGOAuthServerOptions {
34
+ // Public origin of the authorization server, no path (e.g. https://auth.example.com).
35
+ issuer: () => string
36
+ // Other public origins it is also reached on. A sign-in that started on one of these finishes
37
+ // there (its cookie lives there); any other request origin is ignored in favour of the issuer.
38
+ origins?: () => string[]
39
+ store: GGOAuthStore
40
+ // Derives the cookie-signing key and the ES256 key of the published JWKS (id tokens). Rotating it
41
+ // signs everyone out of the authorization server; issued access/refresh tokens are unaffected.
42
+ secret: () => string
43
+ // The first is the default for first-party clients; others default to the first one they may use.
44
+ resources: [GGOAuthResource<any>, ...GGOAuthResource<any>[]]
45
+ // The app's login/consent page; it receives the interaction uid and uses GGOAuthInteractionContract.
46
+ // `request` lets a first-party client's sign-in stay on the origin it was opened from.
47
+ interactionUrl: (uid: string, request: {redirectUri: string, firstParty: boolean}) => string
48
+ // Pre-registered app clients: no consent screen, silent re-auth while the login session lives.
49
+ // Read once, on first use (config isn't readable at compose time).
50
+ firstPartyClients?: () => GGOAuthFirstPartyClient[]
51
+ // Let any client self-register (RFC 7591).
52
+ dynamicRegistration?: boolean
53
+ // Accept clients identified by a metadata document URL (CIMD, what MCP clients prefer), fetched
54
+ // only from https URLs on the default port under one of these prefixes (end each with "/"),
55
+ // e.g. a vendor's hosted client documents: "https://claude.ai/oauth/".
56
+ clientIdMetadataDocument?: {allowedUrlPrefixes: () => string[]}
57
+ // Absolute refresh lifetime: rotation never extends it (OAuth for browser apps, RFC 10017).
58
+ refreshTtlSec?: number
59
+ sessionTtlSec?: number
60
+ // Trust X-Forwarded-Proto (behind a TLS-terminating load balancer). X-Forwarded-Host is never
61
+ // trusted: clients can send it through a load balancer that doesn't set it.
62
+ proxy?: boolean
63
+ }
64
+
65
+ const BASE = "/oauth"
66
+ const OIDC_SCOPES = ["openid", "offline_access"]
67
+ const TICKET = "GGOAuthTicket"
68
+ // Claims resolved during a refresh before the presented token was consumed (see rotateRefreshToken).
69
+ const prefetchedClaims = new WeakMap<object, {resource: string, claims: object}>()
70
+
71
+ type Ticket = {uid: string, result: InteractionResults}
72
+
73
+ // OAuth 2.1 authorization server on a GGHttpServer. Protocol (PKCE, DCR, CIMD, resource indicators,
74
+ // RFC 9207 iss, refresh rotation + reuse revocation, revocation, discovery) is panva oidc-provider;
75
+ // this class owns the grest-ts seams: mounting, storage, token claims and the login/consent handoff.
76
+ export class GGOAuthServer {
77
+
78
+ private _provider: Provider | undefined
79
+ private _callback: ReturnType<Provider["callback"]> | undefined
80
+ private _firstParty: Map<string, GGOAuthFirstPartyClient> | undefined
81
+ private readonly options: GGOAuthServerOptions
82
+
83
+ constructor(options: GGOAuthServerOptions) {
84
+ // Resources sharing a signer are told apart only by audience: a token for one must never
85
+ // verify as another's (e.g. an app's MCP token as the first-party API token).
86
+ const audiences = options.resources.map(r => r.accessToken.audience)
87
+ if (audiences.some(a => !a) || new Set(audiences).size !== audiences.length) {
88
+ throw new Error("GGOAuthServer: every resource's accessToken needs its own audience")
89
+ }
90
+ this.options = options
91
+ }
92
+
93
+ private get firstParty(): Map<string, GGOAuthFirstPartyClient> {
94
+ this._firstParty ??= new Map((this.options.firstPartyClients?.() ?? []).map(c => [c.clientId, c]))
95
+ return this._firstParty
96
+ }
97
+
98
+ public mount(server: GGHttpServer): void {
99
+ const handle = async (req: IncomingMessage, res: ServerResponse) => {
100
+ delete req.headers["x-forwarded-host"]
101
+ const path = new URL(req.url ?? "/", "http://x").pathname
102
+ const interaction = path.match(/^\/oauth\/interaction\/([^/]+)(\/complete)?$/)
103
+ if (interaction && req.method === "GET") {
104
+ if (interaction[2]) return this.finishInteraction(req, res, interaction[1])
105
+ const found = await this.provider().Interaction.find(interaction[1])
106
+ if (!found) {
107
+ res.writeHead(400, {"content-type": "text/plain"})
108
+ res.end("Sign-in request expired")
109
+ return
110
+ }
111
+ const firstParty = this.firstParty.has(String(found.params.client_id))
112
+ res.writeHead(303, {location: this.options.interactionUrl(interaction[1], {redirectUri: String(found.params.redirect_uri), firstParty})})
113
+ res.end()
114
+ return
115
+ }
116
+ this._callback ??= this.provider().callback()
117
+ await this._callback(req, res)
118
+ }
119
+ for (const method of ["GET", "POST"] as const) server.registerRoute(method, `${BASE}/*`, handle, "GGOAuthServer")
120
+ server.registerRoute("GET", "/.well-known/openid-configuration", handle, "GGOAuthServer")
121
+ server.registerRoute("GET", "/.well-known/oauth-authorization-server", handle, "GGOAuthServer")
122
+
123
+ server.announce(`${BASE}/`, "GGOAuthServer")
124
+ server.announce("/.well-known/openid-configuration", "GGOAuthServer")
125
+ server.announce("/.well-known/oauth-authorization-server", "GGOAuthServer")
126
+ }
127
+
128
+ // Serves `path` as an OAuth-protected resource: requests without a valid token for `resource` get
129
+ // 401 + `WWW-Authenticate: Bearer resource_metadata=…`, and its RFC 9728 metadata is published —
130
+ // the discovery handshake MCP clients run before signing in.
131
+ public protect<C extends object>(
132
+ server: GGHttpServer,
133
+ resource: GGOAuthResource<C>,
134
+ path: string,
135
+ handle: (req: IncomingMessage, res: ServerResponse, token: GGAccessPayload<C>) => Promise<void>,
136
+ ): void {
137
+ const metadataPath = `/.well-known/oauth-protected-resource${path}`
138
+ server.registerRoute("GET", metadataPath, async (_req, res) => {
139
+ res.writeHead(200, {"content-type": "application/json"})
140
+ res.end(JSON.stringify({
141
+ resource: resource.resource(),
142
+ authorization_servers: [this.options.issuer()],
143
+ scopes_supported: resource.scopes,
144
+ bearer_methods_supported: ["header"],
145
+ }))
146
+ }, "GGOAuthServer")
147
+ const guarded = async (req: IncomingMessage, res: ServerResponse) => {
148
+ const auth = req.headers.authorization
149
+ const token = auth?.toLowerCase().startsWith("bearer ")
150
+ ? await resource.accessToken.verify(auth.slice(7)).catch(() => undefined)
151
+ : undefined
152
+ if (!token) {
153
+ const metadataUrl = `${new URL(resource.resource()).origin}${metadataPath}`
154
+ res.writeHead(401, {
155
+ "www-authenticate": `Bearer resource_metadata="${metadataUrl}", scope="${resource.scopes.join(" ")}"${auth ? `, error="invalid_token"` : ""}`,
156
+ })
157
+ res.end()
158
+ return
159
+ }
160
+ await handle(req, res, token)
161
+ }
162
+ for (const method of ["GET", "POST", "DELETE"] as const) server.registerRoute(method, path, guarded, "GGOAuthServer")
163
+ server.announce(path, "GGOAuthServer")
164
+ server.announce(metadataPath, "GGOAuthServer")
165
+ }
166
+
167
+ // Implementation of GGOAuthInteractionContract; `subject` reads the signed-in user from the app's wire.
168
+ public interactionApi(subject: () => string): typeof GGOAuthInteractionContract.infer {
169
+ return {
170
+ get: async ({uid}) => this.describeInteraction(uid),
171
+ approve: async ({uid}) => ({redirectUrl: await this.approve(uid, subject())}),
172
+ deny: async ({uid}) => ({redirectUrl: await this.ticket(await this.findInteraction(uid), {error: "access_denied", error_description: "The user denied the request"})}),
173
+ }
174
+ }
175
+
176
+ public listGrants = async (subject: string): Promise<Array<{grantId: string, clientId: string, clientName: string, createdAt: number}>> => {
177
+ const p = this.provider()
178
+ const grants = await Promise.all((await this.options.store.listIdsByAccount("Grant", subject)).map(id => p.Grant.find(id)))
179
+ return await Promise.all(grants
180
+ .filter(g => g?.clientId && g.accountId === subject)
181
+ .map(async g => ({
182
+ grantId: g!.jti,
183
+ clientId: g!.clientId!,
184
+ // A client metadata document may be unreachable; the grant must still be listed (and revocable).
185
+ clientName: String((await p.Client.find(g!.clientId!).catch(() => undefined))?.clientName ?? g!.clientId),
186
+ createdAt: (g!.iat ?? 0) * 1000,
187
+ })))
188
+ }
189
+
190
+ // Kills the grant's refresh tokens and codes now; already-issued access tokens live out their TTL.
191
+ public revokeGrant = async (subject: string, grantId: string): Promise<void> => {
192
+ const grant = await this.provider().Grant.find(grantId)
193
+ if (!grant || grant.accountId !== subject) throw new NOT_FOUND()
194
+ await this.options.store.revokeByGrantId(grantId)
195
+ // The grant row carries its id as jti, not grantId, so revokeByGrantId leaves it.
196
+ await this.options.store.destroy("Grant", grantId)
197
+ }
198
+
199
+ // "Log out everywhere": every grant and login session of the subject.
200
+ public revokeAll = async (subject: string): Promise<void> => {
201
+ const store = this.options.store
202
+ const [grantIds, sessionIds] = await Promise.all([store.listIdsByAccount("Grant", subject), store.listIdsByAccount("Session", subject)])
203
+ await Promise.all([
204
+ ...grantIds.map(id => Promise.all([store.revokeByGrantId(id), store.destroy("Grant", id)])),
205
+ ...sessionIds.map(id => store.destroy("Session", id)),
206
+ ])
207
+ }
208
+
209
+ private async describeInteraction(uid: string): Promise<GGOAuthInteraction> {
210
+ const interaction = await this.findInteraction(uid)
211
+ const clientId = String(interaction.params.client_id)
212
+ const client = await this.provider().Client.find(clientId)
213
+ if (!client) throw new NOT_FOUND({debugMessage: "Unknown OAuth client"})
214
+ const hosts = [...new Set((client.redirectUris ?? []).map(u => new URL(u).host))]
215
+ return {
216
+ uid,
217
+ client: {
218
+ clientId,
219
+ name: this.firstParty.get(clientId)?.name ?? String(client.clientName ?? clientId),
220
+ firstParty: this.firstParty.has(clientId),
221
+ redirectHosts: hosts,
222
+ loopbackOnly: hosts.length > 0 && hosts.every(h => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h)),
223
+ },
224
+ scopes: requestedScopes(interaction.params),
225
+ resource: this.requestedResource(interaction.params, clientId).resource(),
226
+ }
227
+ }
228
+
229
+ private async approve(uid: string, subject: string): Promise<string> {
230
+ const p = this.provider()
231
+ const interaction = await this.findInteraction(uid)
232
+ const clientId = String(interaction.params.client_id)
233
+ const existing = interaction.grantId ? await p.Grant.find(interaction.grantId) : undefined
234
+ const grant = existing?.accountId === subject ? existing : new p.Grant({accountId: subject, clientId})
235
+ this.grantRequested(grant, interaction.params)
236
+ const grantId = await grant.save()
237
+ return this.ticket(interaction, {login: {accountId: subject}, consent: {grantId}})
238
+ }
239
+
240
+ private async findInteraction(uid: string): Promise<InstanceType<Provider["Interaction"]>> {
241
+ const interaction = await this.provider().Interaction.find(uid)
242
+ if (!interaction) throw new NOT_FOUND({debugMessage: "Unknown or expired OAuth interaction"})
243
+ return interaction
244
+ }
245
+
246
+ // The result is applied only when the browser that started the flow (it holds the interaction
247
+ // cookie) follows the redirect: a user approving someone else's flow can't hand them a code.
248
+ private async ticket(interaction: InstanceType<Provider["Interaction"]>, result: InteractionResults): Promise<string> {
249
+ const uid = interaction.uid
250
+ const id = randomBytes(24).toString("base64url")
251
+ await this.options.store.upsert(TICKET, id, {uid, result} satisfies Ticket, 120)
252
+ // Back to the origin the browser started on (its interaction cookie lives there) — but only a
253
+ // known one: returnTo comes from the request's Host, which a client can set to its own server.
254
+ const started = new URL(interaction.returnTo).origin
255
+ const known = [this.options.issuer(), ...(this.options.origins?.() ?? [])].map(o => new URL(o).origin)
256
+ return `${known.includes(started) ? started : known[0]}${BASE}/interaction/${uid}/complete?ticket=${id}`
257
+ }
258
+
259
+ private async finishInteraction(req: IncomingMessage, res: ServerResponse, uid: string): Promise<void> {
260
+ const id = new URL(req.url ?? "/", "http://x").searchParams.get("ticket") ?? ""
261
+ const ticket = id ? await this.options.store.find(TICKET, id) as Ticket | undefined : undefined
262
+ if (id) await this.options.store.destroy(TICKET, id)
263
+ if (!ticket || ticket.uid !== uid) {
264
+ res.writeHead(400, {"content-type": "text/plain"})
265
+ res.end("Invalid or expired sign-in link")
266
+ return
267
+ }
268
+ try {
269
+ await this.provider().interactionFinished(req, res, ticket.result, {mergeWithLastSubmission: false})
270
+ } catch (err) {
271
+ if (!(err instanceof oidcErrors.SessionNotFound)) throw err
272
+ res.writeHead(400, {"content-type": "text/plain"})
273
+ res.end("This sign-in was started in another browser")
274
+ }
275
+ }
276
+
277
+ private grantRequested(grant: InstanceType<Provider["Grant"]>, params: Record<string, unknown>): void {
278
+ const requested = requestedScopes(params)
279
+ const oidc = requested.filter(s => OIDC_SCOPES.includes(s))
280
+ if (oidc.length) grant.addOIDCScope(oidc.join(" "))
281
+ const resource = this.requestedResource(params, String(grant.clientId))
282
+ const resourceScopes = requested.filter(s => resource.scopes.includes(s))
283
+ if (resourceScopes.length) grant.addResourceScope(resource.resource(), resourceScopes.join(" "))
284
+ }
285
+
286
+ private requestedResource(params: Record<string, unknown>, clientId: string): GGOAuthResource<any> {
287
+ const indicator = typeof params.resource === "string" ? params.resource : undefined
288
+ return (indicator && this.resource(indicator)) || this.defaultResource(clientId)
289
+ }
290
+
291
+ private resource(indicator: string | undefined): GGOAuthResource<any> | undefined {
292
+ return this.options.resources.find(r => r.resource() === indicator)
293
+ }
294
+
295
+ private clientOf(ctx: KoaContextWithOIDC): {clientId: string, name: string} {
296
+ const client = ctx.oidc.client!
297
+ return {clientId: client.clientId, name: this.firstParty.get(client.clientId)?.name ?? String(client.clientName ?? client.clientId)}
298
+ }
299
+
300
+ private usableResource(indicator: string, clientId: string): GGOAuthResource<any> {
301
+ const resource = this.resource(indicator)
302
+ if (!resource) throw new oidcErrors.InvalidTarget()
303
+ if (resource.firstPartyOnly && !this.firstParty.has(clientId)) throw new oidcErrors.InvalidTarget("resource not available to this client")
304
+ return resource
305
+ }
306
+
307
+ private defaultResource(clientId: string): GGOAuthResource<any> {
308
+ const firstParty = this.firstParty.has(clientId)
309
+ return this.options.resources.find(r => firstParty || !r.firstPartyOnly) ?? this.options.resources[0]
310
+ }
311
+
312
+ private provider(): Provider {
313
+ if (this._provider) return this._provider
314
+ const o = this.options
315
+ const refreshTtlSec = o.refreshTtlSec ?? 30 * 24 * 3600
316
+ const firstParty = this.firstParty
317
+
318
+ const config: Configuration = {
319
+ adapter: toOidcAdapter(o.store),
320
+ jwks: {keys: [deriveEs256Jwk(o.secret())]},
321
+ cookies: {keys: [hkdf(o.secret(), "@grest-ts/oauth cookies").toString("base64url")]},
322
+ clients: [...firstParty.values()].map(c => ({
323
+ client_id: c.clientId,
324
+ client_name: c.name,
325
+ redirect_uris: c.redirectUris,
326
+ post_logout_redirect_uris: c.postLogoutRedirectUris ?? [],
327
+ token_endpoint_auth_method: "none",
328
+ grant_types: ["authorization_code", "refresh_token"],
329
+ response_types: ["code"],
330
+ application_type: "web",
331
+ })),
332
+ clientDefaults: {
333
+ grant_types: ["authorization_code", "refresh_token"],
334
+ response_types: ["code"],
335
+ token_endpoint_auth_method: "none",
336
+ id_token_signed_response_alg: "ES256",
337
+ // Undeclared = native: CLI clients (e.g. Claude Code's metadata document) redirect to a
338
+ // loopback address on a per-run port, which oidc-provider only port-matches for native.
339
+ application_type: "native",
340
+ },
341
+ routes: {
342
+ authorization: `${BASE}/authorize`, token: `${BASE}/token`, registration: `${BASE}/register`,
343
+ jwks: `${BASE}/jwks`, revocation: `${BASE}/revoke`, end_session: `${BASE}/logout`, userinfo: `${BASE}/userinfo`,
344
+ introspection: `${BASE}/introspect`, pushed_authorization_request: `${BASE}/par`, code_verification: `${BASE}/device`,
345
+ device_authorization: `${BASE}/device/auth`, backchannel_authentication: `${BASE}/backchannel`, challenge: `${BASE}/challenge`,
346
+ },
347
+ interactions: {url: (_ctx, interaction) => `${BASE}/interaction/${interaction.uid}`},
348
+ scopes: OIDC_SCOPES,
349
+ responseTypes: ["code"],
350
+ pkce: {required: () => true},
351
+ features: {
352
+ devInteractions: {enabled: false},
353
+ revocation: {enabled: true},
354
+ registration: {enabled: !!o.dynamicRegistration},
355
+ ...(o.clientIdMetadataDocument ? {clientIdMetadataDocument: {
356
+ enabled: true,
357
+ ack: "draft-02",
358
+ allowFetch: async (_ctx: unknown, clientId: string) => isAllowedDocumentUrl(clientId, o.clientIdMetadataDocument!.allowedUrlPrefixes()),
359
+ }} : {}),
360
+ rpInitiatedLogout: {
361
+ enabled: true,
362
+ // Skip the provider's "do you want to sign out?" page. `logout=yes` is that page's
363
+ // button value: without it only the requesting client is signed out, not the session.
364
+ logoutSource: async (ctx, form) => {
365
+ if (!form.includes("</form>")) throw new Error("GGOAuthServer: oidc-provider's logout form changed; can't confirm logout")
366
+ // Any page can link here; only a first-party client's own page (its redirect origin as
367
+ // Referer) skips the confirmation click.
368
+ const client = ctx.oidc.client && firstParty.get(ctx.oidc.client.clientId)
369
+ const referer = URL.parse(ctx.get("referer"))?.origin
370
+ if (client && referer && client.redirectUris.some(u => new URL(u).origin === referer)) {
371
+ const confirmed = form.replace("</form>", `<input type="hidden" name="logout" value="yes"/></form>`)
372
+ ctx.body = `<!DOCTYPE html><html><body>${confirmed}<script>document.forms[0].submit()</script></body></html>`
373
+ return
374
+ }
375
+ ctx.body = `<!DOCTYPE html><html><body>${form.replace("</form>", `<button name="logout" value="yes">Sign out</button></form>`)}</body></html>`
376
+ },
377
+ },
378
+ resourceIndicators: {
379
+ enabled: true,
380
+ defaultResource: (_ctx, client) => this.defaultResource(client.clientId).resource(),
381
+ useGrantedResource: () => true,
382
+ getResourceServerInfo: (_ctx, indicator, client) => {
383
+ const resource = this.usableResource(indicator, client.clientId)
384
+ const jws = resource.accessToken.signer.jwsKey?.()
385
+ if (!jws) throw new Error("GGOAuthServer: a resource's accessToken signer must implement jwsKey() (e.g. HmacSigner)")
386
+ return {
387
+ scope: resource.scopes.join(" "),
388
+ audience: resource.accessToken.audience ?? indicator,
389
+ accessTokenTTL: Math.floor(resource.accessToken.accessTtlMs / 1000),
390
+ accessTokenFormat: "jwt",
391
+ jwt: {sign: {alg: jws.alg as "HS256", key: Buffer.from(jws.key)}},
392
+ }
393
+ },
394
+ },
395
+ },
396
+ // First-party clients never see a consent screen: an existing login session is enough.
397
+ loadExistingGrant: async (ctx) => {
398
+ const client = ctx.oidc.client!
399
+ const session = ctx.oidc.session!
400
+ const grantId = ctx.oidc.result?.consent?.grantId || session.grantIdFor(client.clientId)
401
+ if (grantId) return ctx.oidc.provider.Grant.find(grantId)
402
+ if (!firstParty.has(client.clientId) || !session.accountId) return undefined
403
+ const grant = new ctx.oidc.provider.Grant({accountId: session.accountId, clientId: client.clientId})
404
+ this.grantRequested(grant, ctx.oidc.params as Record<string, unknown>)
405
+ await grant.save()
406
+ session.ensureClientContainer(client.clientId)
407
+ session.grantIdFor(client.clientId, grant.jti)
408
+ return grant
409
+ },
410
+ // MCP clients don't reliably request offline_access; every code-flow client gets a refresh token.
411
+ issueRefreshToken: async (_ctx, client) => client.grantTypeAllowed("refresh_token"),
412
+ // Only first-party sign-ins end with the login session: signing out of the app must not
413
+ // disconnect the other apps the user connected (revoked explicitly instead).
414
+ expiresWithSession: async (ctx) => firstParty.has(ctx.oidc.client!.clientId),
415
+ // Runs before the presented refresh token is consumed. Resolve the claims here, so a failure
416
+ // (store/IdP down) leaves it redeemable — after consumption, the client's retry is reuse and
417
+ // revokes the grant.
418
+ rotateRefreshToken: async (ctx) => {
419
+ const rt = ctx.oidc.entities.RefreshToken!
420
+ const clientId = String(rt.clientId)
421
+ // Which resource the new access token will be for, as oidc-provider resolves it (the
422
+ // request's resource first, then the granted one), validated now rather than after consume.
423
+ const granted = [rt.resource ?? []].flat() as string[]
424
+ const requested = ctx.oidc.params?.resource
425
+ if (typeof requested === "string" && !granted.includes(requested)) throw new oidcErrors.InvalidTarget()
426
+ const indicator = typeof requested === "string" ? requested : granted.length === 1 ? granted[0] : undefined
427
+ const resource = indicator ? this.usableResource(indicator, clientId) : this.defaultResource(clientId)
428
+ const claims = await resource.claims(String(rt.accountId), this.clientOf(ctx))
429
+ if (!claims) throw new oidcErrors.InvalidGrant("subject is no longer valid")
430
+ prefetchedClaims.set(ctx, {resource: resource.resource(), claims})
431
+ return true
432
+ },
433
+ extraTokenClaims: async (_ctx: KoaContextWithOIDC, token) => {
434
+ if (token.kind !== "AccessToken") return undefined
435
+ const at = token as InstanceType<Provider["AccessToken"]>
436
+ const resource = this.resource(at.resourceServer?.identifier())
437
+ if (!resource) return undefined
438
+ const prefetched = prefetchedClaims.get(_ctx)
439
+ const claims = prefetched?.resource === resource.resource() ? prefetched.claims : await resource.claims(String(at.accountId), this.clientOf(_ctx))
440
+ if (!claims) throw new oidcErrors.InvalidGrant("subject is no longer valid")
441
+ return {data: resource.accessToken.encodeClaims(claims)}
442
+ },
443
+ findAccount: async (_ctx, sub) => ({accountId: sub, claims: async () => ({sub})}),
444
+ ttl: {
445
+ AccessToken: (_ctx, token) => {
446
+ const resource = this.resource(token.resourceServer?.identifier())
447
+ return resource ? Math.floor(resource.accessToken.accessTtlMs / 1000) : 600
448
+ },
449
+ AuthorizationCode: 60,
450
+ Interaction: 30 * 60,
451
+ Session: o.sessionTtlSec ?? 30 * 24 * 3600,
452
+ Grant: 365 * 24 * 3600,
453
+ RefreshToken: (ctx, token) => {
454
+ const rotated = ctx?.oidc.entities.RotatedRefreshToken
455
+ return rotated && !token.isSenderConstrained() ? rotated.remainingTTL : refreshTtlSec
456
+ },
457
+ },
458
+ renderError: async (ctx, out) => {
459
+ ctx.type = "text/plain"
460
+ ctx.body = `${out.error}: ${out.error_description ?? ""}`
461
+ },
462
+ }
463
+ const provider = new Provider(o.issuer(), config)
464
+ provider.proxy = !!o.proxy
465
+ // Resource scopes aren't OIDC scopes, so the provider leaves them out of its metadata.
466
+ provider.use(async (ctx, next) => {
467
+ await next()
468
+ if (ctx.oidc?.route === "discovery") (ctx.body as {scopes_supported: string[]}).scopes_supported = [...OIDC_SCOPES, ...new Set(o.resources.flatMap(r => r.scopes))]
469
+ })
470
+ this._provider = provider
471
+ return provider
472
+ }
473
+ }
474
+
475
+
476
+ function isAllowedDocumentUrl(clientId: string, prefixes: string[]): boolean {
477
+ const url = URL.parse(clientId)
478
+ if (!url || url.protocol !== "https:" || url.port !== "" || url.username || url.password) return false
479
+ return prefixes.some(prefix => (url.origin + url.pathname).startsWith(prefix))
480
+ }
481
+
482
+ function requestedScopes(params: Record<string, unknown>): string[] {
483
+ return String(params.scope ?? "").split(" ").filter(Boolean)
484
+ }
485
+
486
+ function hkdf(secret: string, info: string): Buffer {
487
+ return Buffer.from(hkdfSync("sha256", secret, "", info, 32))
488
+ }
489
+
490
+ // Deterministic, so every instance publishes the same key without distributing a key file.
491
+ function deriveEs256Jwk(secret: string) {
492
+ const d = hkdf(secret, "@grest-ts/oauth es256")
493
+ const ecdh = createECDH("prime256v1")
494
+ ecdh.setPrivateKey(d)
495
+ const point = ecdh.getPublicKey()
496
+ const x = point.subarray(1, 33).toString("base64url")
497
+ const y = point.subarray(33).toString("base64url")
498
+ const kid = createHash("sha256").update(x + y).digest("base64url").slice(0, 16)
499
+ return {kty: "EC", crv: "P-256", d: d.toString("base64url"), x, y, alg: "ES256", use: "sig", kid}
500
+ }
@@ -0,0 +1,93 @@
1
+ import {IsNumber, IsObject, IsString} from "@grest-ts/schema"
2
+ import {type GGDynamoDb, GGDynamoDbTable} from "@grest-ts/db-dynamodb"
3
+ import {errors as oidcErrors} from "oidc-provider"
4
+ import type {GGOAuthPayload, GGOAuthStore} from "./GGOAuthStore"
5
+
6
+ const IsOAuthRow = IsObject({
7
+ pk: IsString.nonEmpty,
8
+ model: IsString.nonEmpty,
9
+ id: IsString.nonEmpty,
10
+ // oidc-provider's payload as JSON: opaque, nested, may hold undefined values.
11
+ payload: IsString,
12
+ grantId: IsString.orUndefined,
13
+ uid: IsString.orUndefined,
14
+ accountId: IsString.orUndefined,
15
+ /** Epoch seconds; set once (conditional write), so only one of two concurrent redemptions wins. */
16
+ consumedAt: IsNumber.min(0).orUndefined,
17
+ /** Epoch seconds; drives DynamoDB TTL. TTL deletion lags, so reads check it too. */
18
+ expiresAtSec: IsNumber.min(0).orUndefined,
19
+ })
20
+ type OAuthRow = typeof IsOAuthRow.infer
21
+
22
+ // Table to create (TTL attribute `expiresAtSec`): HASH `pk` (S), plus GSIs projecting ALL —
23
+ // `grantId-index` (grantId), `uid-index` (uid), `accountId-index` (accountId).
24
+ // accountId is written only for the models looked up by account, so the index stays small: every
25
+ // spent refresh token is kept until expiry (reuse detection) and would otherwise land in it.
26
+ const BY_ACCOUNT = new Set(["Grant", "Session"])
27
+ const REDEEMABLE = new Set(["AuthorizationCode", "RefreshToken"])
28
+
29
+ export class DynamoOAuthStore extends GGDynamoDbTable<OAuthRow, "pk"> implements GGOAuthStore {
30
+
31
+ constructor(db: GGDynamoDb, tableName: string) {
32
+ super(db, tableName, IsOAuthRow, "pk")
33
+ }
34
+
35
+ public upsert = async (model: string, id: string, payload: GGOAuthPayload, expiresInSec: number | undefined): Promise<void> => {
36
+ await this.put({
37
+ pk: `${model}#${id}`,
38
+ model,
39
+ id,
40
+ payload: JSON.stringify(payload),
41
+ grantId: payload.grantId,
42
+ uid: payload.uid,
43
+ accountId: BY_ACCOUNT.has(model) ? payload.accountId : undefined,
44
+ expiresAtSec: expiresInSec ? Math.floor(Date.now() / 1000) + expiresInSec : undefined,
45
+ })
46
+ }
47
+
48
+ public find = async (model: string, id: string): Promise<GGOAuthPayload | undefined> => this.payloadOf(await this.get(`${model}#${id}`))
49
+
50
+ public findByUid = async (model: string, uid: string): Promise<GGOAuthPayload | undefined> =>
51
+ this.payloadOf(await this.queryOne("uid-index", "uid = :uid", {":uid": uid, ":model": model}, {filter: "#m = :model", names: {"#m": "model"}}))
52
+
53
+ public consume = async (model: string, id: string): Promise<void> => {
54
+ const row = await this.get(`${model}#${id}`)
55
+ if (!row) {
56
+ // A code or refresh token revoked since it was read (e.g. the app was disconnected) must not be redeemed.
57
+ if (REDEEMABLE.has(model)) throw new oidcErrors.InvalidGrant(`${model} revoked`)
58
+ return
59
+ }
60
+ const consumedAt = Math.floor(Date.now() / 1000)
61
+ const payload = JSON.stringify({...JSON.parse(row.payload), consumed: consumedAt})
62
+ // attribute_exists(pk): a row revoked meanwhile stays gone instead of being re-created.
63
+ if (!await this.putConditional({...row, payload, consumedAt}, "attribute_exists(pk) AND attribute_not_exists(consumedAt)")) {
64
+ throw new oidcErrors.InvalidGrant(`${model} already used`)
65
+ }
66
+ }
67
+
68
+ public destroy = async (model: string, id: string): Promise<void> => {
69
+ await this.delete(`${model}#${id}`)
70
+ }
71
+
72
+ // A long-lived grant accumulates one spent refresh token per rotation (kept for reuse detection), so page.
73
+ public revokeByGrantId = async (grantId: string): Promise<void> => {
74
+ let cursor: string | undefined
75
+ do {
76
+ const page = await this.queryPage("grantId-index", "grantId = :g", {":g": grantId}, {cursor, limit: 100})
77
+ await Promise.all(page.items.map(row => this.delete(row.pk)))
78
+ cursor = page.cursor
79
+ } while (cursor)
80
+ }
81
+
82
+ public listIdsByAccount = async (model: string, accountId: string): Promise<string[]> =>
83
+ (await this.query("accountId-index", "accountId = :a", {":a": accountId, ":model": model}, {filter: "#m = :model", names: {"#m": "model"}}))
84
+ .filter(row => this.live(row)).map(row => row.id)
85
+
86
+ private payloadOf(row: OAuthRow | undefined): GGOAuthPayload | undefined {
87
+ return row && this.live(row) ? JSON.parse(row.payload) as GGOAuthPayload : undefined
88
+ }
89
+
90
+ private live(row: OAuthRow): boolean {
91
+ return row.expiresAtSec === undefined || row.expiresAtSec > Date.now() / 1000
92
+ }
93
+ }
@@ -0,0 +1,41 @@
1
+ import type {AdapterConstructor} from "oidc-provider"
2
+
3
+ // oidc-provider model state (sessions, grants, codes, refresh tokens, registered clients,
4
+ // interactions). Payloads are opaque JSON owned by oidc-provider; expiry is enforced by the store.
5
+ export type GGOAuthPayload = Record<string, unknown> & {
6
+ grantId?: string
7
+ uid?: string
8
+ accountId?: string
9
+ consumed?: number
10
+ }
11
+
12
+ export interface GGOAuthStore {
13
+ upsert(model: string, id: string, payload: GGOAuthPayload, expiresInSec: number | undefined): Promise<void>
14
+ find(model: string, id: string): Promise<GGOAuthPayload | undefined>
15
+ findByUid(model: string, uid: string): Promise<GGOAuthPayload | undefined>
16
+ // Mark consumed (not delete): a second use of a code / rotated refresh token is detected as replay.
17
+ // Must be atomic — throw oidc-provider's InvalidGrant if it was already consumed, so of two
18
+ // concurrent redemptions one fails cleanly (a 5xx would be retried, and the retry is reuse).
19
+ consume(model: string, id: string): Promise<void>
20
+ destroy(model: string, id: string): Promise<void>
21
+ // Every model row carrying this grantId (codes, refresh tokens, the grant itself).
22
+ revokeByGrantId(grantId: string): Promise<void>
23
+ // Ids of this account's rows of one model (Grant, Session) — for "connected apps" and revoke-all.
24
+ listIdsByAccount(model: string, accountId: string): Promise<string[]>
25
+ }
26
+
27
+ // oidc-provider's adapter shape: `new Adapter(modelName)` per model.
28
+ export function toOidcAdapter(store: GGOAuthStore): AdapterConstructor {
29
+ return class GGOAuthAdapter {
30
+ private readonly model: string
31
+ constructor(model: string) { this.model = model }
32
+ upsert = (id: string, payload: GGOAuthPayload, expiresIn?: number) => store.upsert(this.model, id, payload, expiresIn)
33
+ find = (id: string) => store.find(this.model, id)
34
+ findByUid = (uid: string) => store.findByUid(this.model, uid)
35
+ // Device flow is never enabled.
36
+ findByUserCode = async () => undefined
37
+ consume = (id: string) => store.consume(this.model, id)
38
+ destroy = (id: string) => store.destroy(this.model, id)
39
+ revokeByGrantId = (grantId: string) => store.revokeByGrantId(grantId)
40
+ }
41
+ }