@grest-ts/oauth 0.0.0-stage → 0.0.89

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 +162 -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 +417 -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 +389 -0
  48. package/src/node/GGOAuthServer.ts +485 -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,485 @@
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 these hosts — e.g. a vendor's hosted client documents.
55
+ clientIdMetadataDocument?: {allowedHosts: () => string[]}
56
+ // Absolute refresh lifetime: rotation never extends it (OAuth for browser apps, RFC 10017).
57
+ refreshTtlSec?: number
58
+ sessionTtlSec?: number
59
+ // Trust X-Forwarded-* (behind a TLS-terminating load balancer).
60
+ proxy?: boolean
61
+ }
62
+
63
+ const BASE = "/oauth"
64
+ const OIDC_SCOPES = ["openid", "offline_access"]
65
+ const TICKET = "GGOAuthTicket"
66
+ // Claims resolved during a refresh before the presented token was consumed (see rotateRefreshToken).
67
+ const prefetchedClaims = new WeakMap<object, {resource: string, claims: object}>()
68
+
69
+ type Ticket = {uid: string, result: InteractionResults}
70
+
71
+ // OAuth 2.1 authorization server on a GGHttpServer. Protocol (PKCE, DCR, CIMD, resource indicators,
72
+ // RFC 9207 iss, refresh rotation + reuse revocation, revocation, discovery) is panva oidc-provider;
73
+ // this class owns the grest-ts seams: mounting, storage, token claims and the login/consent handoff.
74
+ export class GGOAuthServer {
75
+
76
+ private _provider: Provider | undefined
77
+ private _callback: ReturnType<Provider["callback"]> | undefined
78
+ private _firstParty: Map<string, GGOAuthFirstPartyClient> | undefined
79
+ private readonly options: GGOAuthServerOptions
80
+
81
+ constructor(options: GGOAuthServerOptions) {
82
+ this.options = options
83
+ }
84
+
85
+ private get firstParty(): Map<string, GGOAuthFirstPartyClient> {
86
+ this._firstParty ??= new Map((this.options.firstPartyClients?.() ?? []).map(c => [c.clientId, c]))
87
+ return this._firstParty
88
+ }
89
+
90
+ public mount(server: GGHttpServer): void {
91
+ const handle = async (req: IncomingMessage, res: ServerResponse) => {
92
+ const path = new URL(req.url ?? "/", "http://x").pathname
93
+ const interaction = path.match(/^\/oauth\/interaction\/([^/]+)(\/complete)?$/)
94
+ if (interaction && req.method === "GET") {
95
+ if (interaction[2]) return this.finishInteraction(req, res, interaction[1])
96
+ const found = await this.provider().Interaction.find(interaction[1])
97
+ if (!found) {
98
+ res.writeHead(400, {"content-type": "text/plain"})
99
+ res.end("Sign-in request expired")
100
+ return
101
+ }
102
+ const firstParty = this.firstParty.has(String(found.params.client_id))
103
+ res.writeHead(303, {location: this.options.interactionUrl(interaction[1], {redirectUri: String(found.params.redirect_uri), firstParty})})
104
+ res.end()
105
+ return
106
+ }
107
+ this._callback ??= this.provider().callback()
108
+ await this._callback(req, res)
109
+ }
110
+ for (const method of ["GET", "POST"] as const) server.registerRoute(method, `${BASE}/*`, handle, "GGOAuthServer")
111
+ server.registerRoute("GET", "/.well-known/openid-configuration", handle, "GGOAuthServer")
112
+ server.registerRoute("GET", "/.well-known/oauth-authorization-server", handle, "GGOAuthServer")
113
+
114
+ server.announce(`${BASE}/`, "GGOAuthServer")
115
+ server.announce("/.well-known/openid-configuration", "GGOAuthServer")
116
+ server.announce("/.well-known/oauth-authorization-server", "GGOAuthServer")
117
+ }
118
+
119
+ // Serves `path` as an OAuth-protected resource: requests without a valid token for `resource` get
120
+ // 401 + `WWW-Authenticate: Bearer resource_metadata=…`, and its RFC 9728 metadata is published —
121
+ // the discovery handshake MCP clients run before signing in.
122
+ public protect<C extends object>(
123
+ server: GGHttpServer,
124
+ resource: GGOAuthResource<C>,
125
+ path: string,
126
+ handle: (req: IncomingMessage, res: ServerResponse, token: GGAccessPayload<C>) => Promise<void>,
127
+ ): void {
128
+ const metadataPath = `/.well-known/oauth-protected-resource${path}`
129
+ server.registerRoute("GET", metadataPath, async (_req, res) => {
130
+ res.writeHead(200, {"content-type": "application/json"})
131
+ res.end(JSON.stringify({
132
+ resource: resource.resource(),
133
+ authorization_servers: [this.options.issuer()],
134
+ scopes_supported: resource.scopes,
135
+ bearer_methods_supported: ["header"],
136
+ }))
137
+ }, "GGOAuthServer")
138
+ const guarded = async (req: IncomingMessage, res: ServerResponse) => {
139
+ const auth = req.headers.authorization
140
+ const token = auth?.toLowerCase().startsWith("bearer ")
141
+ ? await resource.accessToken.verify(auth.slice(7)).catch(() => undefined)
142
+ : undefined
143
+ if (!token) {
144
+ const metadataUrl = `${new URL(resource.resource()).origin}${metadataPath}`
145
+ res.writeHead(401, {
146
+ "www-authenticate": `Bearer resource_metadata="${metadataUrl}", scope="${resource.scopes.join(" ")}"${auth ? `, error="invalid_token"` : ""}`,
147
+ })
148
+ res.end()
149
+ return
150
+ }
151
+ await handle(req, res, token)
152
+ }
153
+ for (const method of ["GET", "POST", "DELETE"] as const) server.registerRoute(method, path, guarded, "GGOAuthServer")
154
+ server.announce(path, "GGOAuthServer")
155
+ server.announce(metadataPath, "GGOAuthServer")
156
+ }
157
+
158
+ // Implementation of GGOAuthInteractionContract; `subject` reads the signed-in user from the app's wire.
159
+ public interactionApi(subject: () => string): typeof GGOAuthInteractionContract.infer {
160
+ return {
161
+ get: async ({uid}) => this.describeInteraction(uid),
162
+ approve: async ({uid}) => ({redirectUrl: await this.approve(uid, subject())}),
163
+ deny: async ({uid}) => ({redirectUrl: await this.ticket(await this.findInteraction(uid), {error: "access_denied", error_description: "The user denied the request"})}),
164
+ }
165
+ }
166
+
167
+ public listGrants = async (subject: string): Promise<Array<{grantId: string, clientId: string, clientName: string, createdAt: number}>> => {
168
+ const p = this.provider()
169
+ const grants = await Promise.all((await this.options.store.listIdsByAccount("Grant", subject)).map(id => p.Grant.find(id)))
170
+ return await Promise.all(grants
171
+ .filter(g => g?.clientId && g.accountId === subject)
172
+ .map(async g => ({
173
+ grantId: g!.jti,
174
+ clientId: g!.clientId!,
175
+ // A client metadata document may be unreachable; the grant must still be listed (and revocable).
176
+ clientName: String((await p.Client.find(g!.clientId!).catch(() => undefined))?.clientName ?? g!.clientId),
177
+ createdAt: (g!.iat ?? 0) * 1000,
178
+ })))
179
+ }
180
+
181
+ // Kills the grant's refresh tokens and codes now; already-issued access tokens live out their TTL.
182
+ public revokeGrant = async (subject: string, grantId: string): Promise<void> => {
183
+ const grant = await this.provider().Grant.find(grantId)
184
+ if (!grant || grant.accountId !== subject) throw new NOT_FOUND()
185
+ await this.options.store.revokeByGrantId(grantId)
186
+ // The grant row carries its id as jti, not grantId, so revokeByGrantId leaves it.
187
+ await this.options.store.destroy("Grant", grantId)
188
+ }
189
+
190
+ // "Log out everywhere": every grant and login session of the subject.
191
+ public revokeAll = async (subject: string): Promise<void> => {
192
+ const store = this.options.store
193
+ const [grantIds, sessionIds] = await Promise.all([store.listIdsByAccount("Grant", subject), store.listIdsByAccount("Session", subject)])
194
+ await Promise.all([
195
+ ...grantIds.map(id => Promise.all([store.revokeByGrantId(id), store.destroy("Grant", id)])),
196
+ ...sessionIds.map(id => store.destroy("Session", id)),
197
+ ])
198
+ }
199
+
200
+ private async describeInteraction(uid: string): Promise<GGOAuthInteraction> {
201
+ const interaction = await this.findInteraction(uid)
202
+ const clientId = String(interaction.params.client_id)
203
+ const client = await this.provider().Client.find(clientId)
204
+ if (!client) throw new NOT_FOUND({debugMessage: "Unknown OAuth client"})
205
+ const hosts = [...new Set((client.redirectUris ?? []).map(u => new URL(u).host))]
206
+ return {
207
+ uid,
208
+ client: {
209
+ clientId,
210
+ name: this.firstParty.get(clientId)?.name ?? String(client.clientName ?? clientId),
211
+ firstParty: this.firstParty.has(clientId),
212
+ redirectHosts: hosts,
213
+ loopbackOnly: hosts.length > 0 && hosts.every(h => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h)),
214
+ },
215
+ scopes: requestedScopes(interaction.params),
216
+ resource: this.requestedResource(interaction.params, clientId).resource(),
217
+ }
218
+ }
219
+
220
+ private async approve(uid: string, subject: string): Promise<string> {
221
+ const p = this.provider()
222
+ const interaction = await this.findInteraction(uid)
223
+ const clientId = String(interaction.params.client_id)
224
+ const existing = interaction.grantId ? await p.Grant.find(interaction.grantId) : undefined
225
+ const grant = existing?.accountId === subject ? existing : new p.Grant({accountId: subject, clientId})
226
+ this.grantRequested(grant, interaction.params)
227
+ const grantId = await grant.save()
228
+ return this.ticket(interaction, {login: {accountId: subject}, consent: {grantId}})
229
+ }
230
+
231
+ private async findInteraction(uid: string): Promise<InstanceType<Provider["Interaction"]>> {
232
+ const interaction = await this.provider().Interaction.find(uid)
233
+ if (!interaction) throw new NOT_FOUND({debugMessage: "Unknown or expired OAuth interaction"})
234
+ return interaction
235
+ }
236
+
237
+ // The result is applied only when the browser that started the flow (it holds the interaction
238
+ // cookie) follows the redirect: a user approving someone else's flow can't hand them a code.
239
+ private async ticket(interaction: InstanceType<Provider["Interaction"]>, result: InteractionResults): Promise<string> {
240
+ const uid = interaction.uid
241
+ const id = randomBytes(24).toString("base64url")
242
+ await this.options.store.upsert(TICKET, id, {uid, result} satisfies Ticket, 120)
243
+ // Back to the origin the browser started on (its interaction cookie lives there) — but only a
244
+ // known one: returnTo comes from the request's Host, which a client can set to its own server.
245
+ const started = new URL(interaction.returnTo).origin
246
+ const known = [this.options.issuer(), ...(this.options.origins?.() ?? [])].map(o => new URL(o).origin)
247
+ return `${known.includes(started) ? started : known[0]}${BASE}/interaction/${uid}/complete?ticket=${id}`
248
+ }
249
+
250
+ private async finishInteraction(req: IncomingMessage, res: ServerResponse, uid: string): Promise<void> {
251
+ const id = new URL(req.url ?? "/", "http://x").searchParams.get("ticket") ?? ""
252
+ const ticket = id ? await this.options.store.find(TICKET, id) as Ticket | undefined : undefined
253
+ if (id) await this.options.store.destroy(TICKET, id)
254
+ if (!ticket || ticket.uid !== uid) {
255
+ res.writeHead(400, {"content-type": "text/plain"})
256
+ res.end("Invalid or expired sign-in link")
257
+ return
258
+ }
259
+ try {
260
+ await this.provider().interactionFinished(req, res, ticket.result, {mergeWithLastSubmission: false})
261
+ } catch (err) {
262
+ if (!(err instanceof oidcErrors.SessionNotFound)) throw err
263
+ res.writeHead(400, {"content-type": "text/plain"})
264
+ res.end("This sign-in was started in another browser")
265
+ }
266
+ }
267
+
268
+ private grantRequested(grant: InstanceType<Provider["Grant"]>, params: Record<string, unknown>): void {
269
+ const requested = requestedScopes(params)
270
+ const oidc = requested.filter(s => OIDC_SCOPES.includes(s))
271
+ if (oidc.length) grant.addOIDCScope(oidc.join(" "))
272
+ const resource = this.requestedResource(params, String(grant.clientId))
273
+ const resourceScopes = requested.filter(s => resource.scopes.includes(s))
274
+ if (resourceScopes.length) grant.addResourceScope(resource.resource(), resourceScopes.join(" "))
275
+ }
276
+
277
+ private requestedResource(params: Record<string, unknown>, clientId: string): GGOAuthResource<any> {
278
+ const indicator = typeof params.resource === "string" ? params.resource : undefined
279
+ return (indicator && this.resource(indicator)) || this.defaultResource(clientId)
280
+ }
281
+
282
+ private resource(indicator: string | undefined): GGOAuthResource<any> | undefined {
283
+ return this.options.resources.find(r => r.resource() === indicator)
284
+ }
285
+
286
+ private clientOf(ctx: KoaContextWithOIDC): {clientId: string, name: string} {
287
+ const client = ctx.oidc.client!
288
+ return {clientId: client.clientId, name: this.firstParty.get(client.clientId)?.name ?? String(client.clientName ?? client.clientId)}
289
+ }
290
+
291
+ private usableResource(indicator: string, clientId: string): GGOAuthResource<any> {
292
+ const resource = this.resource(indicator)
293
+ if (!resource) throw new oidcErrors.InvalidTarget()
294
+ if (resource.firstPartyOnly && !this.firstParty.has(clientId)) throw new oidcErrors.InvalidTarget("resource not available to this client")
295
+ return resource
296
+ }
297
+
298
+ private defaultResource(clientId: string): GGOAuthResource<any> {
299
+ const firstParty = this.firstParty.has(clientId)
300
+ return this.options.resources.find(r => firstParty || !r.firstPartyOnly) ?? this.options.resources[0]
301
+ }
302
+
303
+ private provider(): Provider {
304
+ if (this._provider) return this._provider
305
+ const o = this.options
306
+ const refreshTtlSec = o.refreshTtlSec ?? 30 * 24 * 3600
307
+ const firstParty = this.firstParty
308
+
309
+ const config: Configuration = {
310
+ adapter: toOidcAdapter(o.store),
311
+ jwks: {keys: [deriveEs256Jwk(o.secret())]},
312
+ cookies: {keys: [hkdf(o.secret(), "@grest-ts/oauth cookies").toString("base64url")]},
313
+ clients: [...firstParty.values()].map(c => ({
314
+ client_id: c.clientId,
315
+ client_name: c.name,
316
+ redirect_uris: c.redirectUris,
317
+ post_logout_redirect_uris: c.postLogoutRedirectUris ?? [],
318
+ token_endpoint_auth_method: "none",
319
+ grant_types: ["authorization_code", "refresh_token"],
320
+ response_types: ["code"],
321
+ application_type: "web",
322
+ })),
323
+ clientDefaults: {
324
+ grant_types: ["authorization_code", "refresh_token"],
325
+ response_types: ["code"],
326
+ token_endpoint_auth_method: "none",
327
+ id_token_signed_response_alg: "ES256",
328
+ // Undeclared = native: CLI clients (e.g. Claude Code's metadata document) redirect to a
329
+ // loopback address on a per-run port, which oidc-provider only port-matches for native.
330
+ application_type: "native",
331
+ },
332
+ routes: {
333
+ authorization: `${BASE}/authorize`, token: `${BASE}/token`, registration: `${BASE}/register`,
334
+ jwks: `${BASE}/jwks`, revocation: `${BASE}/revoke`, end_session: `${BASE}/logout`, userinfo: `${BASE}/userinfo`,
335
+ introspection: `${BASE}/introspect`, pushed_authorization_request: `${BASE}/par`, code_verification: `${BASE}/device`,
336
+ device_authorization: `${BASE}/device/auth`, backchannel_authentication: `${BASE}/backchannel`, challenge: `${BASE}/challenge`,
337
+ },
338
+ interactions: {url: (_ctx, interaction) => `${BASE}/interaction/${interaction.uid}`},
339
+ scopes: OIDC_SCOPES,
340
+ responseTypes: ["code"],
341
+ pkce: {required: () => true},
342
+ features: {
343
+ devInteractions: {enabled: false},
344
+ revocation: {enabled: true},
345
+ registration: {enabled: !!o.dynamicRegistration},
346
+ ...(o.clientIdMetadataDocument ? {clientIdMetadataDocument: {
347
+ enabled: true,
348
+ ack: "draft-02",
349
+ allowFetch: async (_ctx: unknown, clientId: string) => o.clientIdMetadataDocument!.allowedHosts().includes(new URL(clientId).hostname),
350
+ }} : {}),
351
+ rpInitiatedLogout: {
352
+ enabled: true,
353
+ // Skip the provider's "do you want to sign out?" page. `logout=yes` is that page's
354
+ // button value: without it only the requesting client is signed out, not the session.
355
+ logoutSource: async (ctx, form) => {
356
+ if (!form.includes("</form>")) throw new Error("GGOAuthServer: oidc-provider's logout form changed; can't confirm logout")
357
+ // Any page can link here; only a first-party client's own page (its redirect origin as
358
+ // Referer) skips the confirmation click.
359
+ const client = ctx.oidc.client && firstParty.get(ctx.oidc.client.clientId)
360
+ const referer = URL.parse(ctx.get("referer"))?.origin
361
+ if (client && referer && client.redirectUris.some(u => new URL(u).origin === referer)) {
362
+ const confirmed = form.replace("</form>", `<input type="hidden" name="logout" value="yes"/></form>`)
363
+ ctx.body = `<!DOCTYPE html><html><body>${confirmed}<script>document.forms[0].submit()</script></body></html>`
364
+ return
365
+ }
366
+ ctx.body = `<!DOCTYPE html><html><body>${form.replace("</form>", `<button name="logout" value="yes">Sign out</button></form>`)}</body></html>`
367
+ },
368
+ },
369
+ resourceIndicators: {
370
+ enabled: true,
371
+ defaultResource: (_ctx, client) => this.defaultResource(client.clientId).resource(),
372
+ useGrantedResource: () => true,
373
+ getResourceServerInfo: (_ctx, indicator, client) => {
374
+ const resource = this.usableResource(indicator, client.clientId)
375
+ const jws = resource.accessToken.signer.jwsKey?.()
376
+ if (!jws) throw new Error("GGOAuthServer: a resource's accessToken signer must implement jwsKey() (e.g. HmacSigner)")
377
+ return {
378
+ scope: resource.scopes.join(" "),
379
+ audience: resource.accessToken.audience ?? indicator,
380
+ accessTokenTTL: Math.floor(resource.accessToken.accessTtlMs / 1000),
381
+ accessTokenFormat: "jwt",
382
+ jwt: {sign: {alg: jws.alg as "HS256", key: Buffer.from(jws.key)}},
383
+ }
384
+ },
385
+ },
386
+ },
387
+ // First-party clients never see a consent screen: an existing login session is enough.
388
+ loadExistingGrant: async (ctx) => {
389
+ const client = ctx.oidc.client!
390
+ const session = ctx.oidc.session!
391
+ const grantId = ctx.oidc.result?.consent?.grantId || session.grantIdFor(client.clientId)
392
+ if (grantId) return ctx.oidc.provider.Grant.find(grantId)
393
+ if (!firstParty.has(client.clientId) || !session.accountId) return undefined
394
+ const grant = new ctx.oidc.provider.Grant({accountId: session.accountId, clientId: client.clientId})
395
+ this.grantRequested(grant, ctx.oidc.params as Record<string, unknown>)
396
+ await grant.save()
397
+ session.ensureClientContainer(client.clientId)
398
+ session.grantIdFor(client.clientId, grant.jti)
399
+ return grant
400
+ },
401
+ // MCP clients don't reliably request offline_access; every code-flow client gets a refresh token.
402
+ issueRefreshToken: async (_ctx, client) => client.grantTypeAllowed("refresh_token"),
403
+ // Only first-party sign-ins end with the login session: signing out of the app must not
404
+ // disconnect the other apps the user connected (revoked explicitly instead).
405
+ expiresWithSession: async (ctx) => firstParty.has(ctx.oidc.client!.clientId),
406
+ // Runs before the presented refresh token is consumed. Resolve the claims here, so a failure
407
+ // (store/IdP down) leaves it redeemable — after consumption, the client's retry is reuse and
408
+ // revokes the grant.
409
+ rotateRefreshToken: async (ctx) => {
410
+ const rt = ctx.oidc.entities.RefreshToken!
411
+ const clientId = String(rt.clientId)
412
+ // Which resource the new access token will be for, as oidc-provider resolves it (the
413
+ // request's resource first, then the granted one), validated now rather than after consume.
414
+ const granted = [rt.resource ?? []].flat() as string[]
415
+ const requested = ctx.oidc.params?.resource
416
+ if (typeof requested === "string" && !granted.includes(requested)) throw new oidcErrors.InvalidTarget()
417
+ const indicator = typeof requested === "string" ? requested : granted.length === 1 ? granted[0] : undefined
418
+ const resource = indicator ? this.usableResource(indicator, clientId) : this.defaultResource(clientId)
419
+ const claims = await resource.claims(String(rt.accountId), this.clientOf(ctx))
420
+ if (!claims) throw new oidcErrors.InvalidGrant("subject is no longer valid")
421
+ prefetchedClaims.set(ctx, {resource: resource.resource(), claims})
422
+ return true
423
+ },
424
+ extraTokenClaims: async (_ctx: KoaContextWithOIDC, token) => {
425
+ if (token.kind !== "AccessToken") return undefined
426
+ const at = token as InstanceType<Provider["AccessToken"]>
427
+ const resource = this.resource(at.resourceServer?.identifier())
428
+ if (!resource) return undefined
429
+ const prefetched = prefetchedClaims.get(_ctx)
430
+ const claims = prefetched?.resource === resource.resource() ? prefetched.claims : await resource.claims(String(at.accountId), this.clientOf(_ctx))
431
+ if (!claims) throw new oidcErrors.InvalidGrant("subject is no longer valid")
432
+ return {data: resource.accessToken.encodeClaims(claims)}
433
+ },
434
+ findAccount: async (_ctx, sub) => ({accountId: sub, claims: async () => ({sub})}),
435
+ ttl: {
436
+ AccessToken: (_ctx, token) => {
437
+ const resource = this.resource(token.resourceServer?.identifier())
438
+ return resource ? Math.floor(resource.accessToken.accessTtlMs / 1000) : 600
439
+ },
440
+ AuthorizationCode: 60,
441
+ Interaction: 30 * 60,
442
+ Session: o.sessionTtlSec ?? 30 * 24 * 3600,
443
+ Grant: 365 * 24 * 3600,
444
+ RefreshToken: (ctx, token) => {
445
+ const rotated = ctx?.oidc.entities.RotatedRefreshToken
446
+ return rotated && !token.isSenderConstrained() ? rotated.remainingTTL : refreshTtlSec
447
+ },
448
+ },
449
+ renderError: async (ctx, out) => {
450
+ ctx.type = "text/plain"
451
+ ctx.body = `${out.error}: ${out.error_description ?? ""}`
452
+ },
453
+ }
454
+ const provider = new Provider(o.issuer(), config)
455
+ provider.proxy = !!o.proxy
456
+ // Resource scopes aren't OIDC scopes, so the provider leaves them out of its metadata.
457
+ provider.use(async (ctx, next) => {
458
+ await next()
459
+ if (ctx.oidc?.route === "discovery") (ctx.body as {scopes_supported: string[]}).scopes_supported = [...OIDC_SCOPES, ...new Set(o.resources.flatMap(r => r.scopes))]
460
+ })
461
+ this._provider = provider
462
+ return provider
463
+ }
464
+ }
465
+
466
+
467
+ function requestedScopes(params: Record<string, unknown>): string[] {
468
+ return String(params.scope ?? "").split(" ").filter(Boolean)
469
+ }
470
+
471
+ function hkdf(secret: string, info: string): Buffer {
472
+ return Buffer.from(hkdfSync("sha256", secret, "", info, 32))
473
+ }
474
+
475
+ // Deterministic, so every instance publishes the same key without distributing a key file.
476
+ function deriveEs256Jwk(secret: string) {
477
+ const d = hkdf(secret, "@grest-ts/oauth es256")
478
+ const ecdh = createECDH("prime256v1")
479
+ ecdh.setPrivateKey(d)
480
+ const point = ecdh.getPublicKey()
481
+ const x = point.subarray(1, 33).toString("base64url")
482
+ const y = point.subarray(33).toString("base64url")
483
+ const kid = createHash("sha256").update(x + y).digest("base64url").slice(0, 16)
484
+ return {kty: "EC", crv: "P-256", d: d.toString("base64url"), x, y, alg: "ES256", use: "sig", kid}
485
+ }
@@ -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
+ }