@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,416 @@
1
+ import {createHash, randomBytes} from "node:crypto"
2
+ import {createServer} from "node:net"
3
+ import {GGRuntime} from "@grest-ts/runtime"
4
+ import {GGHeader, GGHttp, GGHttpSchema, GGHttpServer, GGRpc} from "@grest-ts/http"
5
+ import {GGContextKey} from "@grest-ts/context"
6
+ import {IsArray, IsObject, IsString, NOT_AUTHORIZED} from "@grest-ts/schema"
7
+ import {GGAuthAccessToken, HmacSigner} from "@grest-ts/auth"
8
+ import {GGOAuthServer, type GGOAuthResource} from "./GGOAuthServer"
9
+ import {InMemoryOAuthStore} from "./store/InMemoryOAuthStore"
10
+ import {GGOAuthInteractionContract} from "../shared/interactionContract"
11
+
12
+ const IsClaims = IsObject({permissions: IsArray(IsString)})
13
+ type Claims = typeof IsClaims.infer
14
+
15
+ const port = await freePort()
16
+ const ISSUER = `http://localhost:${port}`
17
+ const API = `${ISSUER}/api`
18
+ const APP_LOGIN = "https://app.example/oauth-login"
19
+ const FIRST_PARTY_REDIRECT = "https://app.example/callback"
20
+ const THIRD_PARTY_REDIRECT = "https://claude.ai/api/mcp/auth_callback"
21
+
22
+ // The app's existing first-party user token: OAuth must issue exactly this kind.
23
+ const userToken = new GGAuthAccessToken({signer: new HmacSigner("app-token-secret"), claimSchema: IsClaims, audience: "app-user"})
24
+ const userPermissions = new Map<string, string[] | undefined>([["alice", ["read", "write"]]])
25
+ // Set to make the next claims lookup fail as if the store/IdP were down.
26
+ let failClaimsOnce = false
27
+
28
+ const USER_WIRE = new GGHeader("authorization", {scheme: "bearer"})
29
+ const SUBJECT = new GGContextKey<{sub: string}>("oauthTestSubject", IsObject({sub: IsString}))
30
+ const USER_WIRE_HANDLER = USER_WIRE.define(() => ({
31
+ process: async () => {
32
+ const raw = USER_WIRE.get()
33
+ if (!raw) throw new NOT_AUTHORIZED()
34
+ SUBJECT.set({sub: (await userToken.verify(raw)).sub})
35
+ },
36
+ }))
37
+ const InteractionApi = new GGHttpSchema({
38
+ contract: GGOAuthInteractionContract,
39
+ pathPrefix: "api/oauth/interaction",
40
+ use: [USER_WIRE],
41
+ routes: {get: GGRpc.GET("get"), approve: GGRpc.POST("approve"), deny: GGRpc.POST("deny")},
42
+ })
43
+
44
+ // A second resource, as for an MCP server: its own audience, and tokens that grant no permissions.
45
+ const MCP = `${ISSUER}/mcp`
46
+ const mcpToken = new GGAuthAccessToken({signer: new HmacSigner("app-token-secret"), claimSchema: IsClaims, audience: "app-mcp"})
47
+ const mcp: GGOAuthResource<Claims> = {
48
+ resource: () => MCP,
49
+ accessToken: mcpToken,
50
+ scopes: ["mcp"],
51
+ claims: async (sub) => userPermissions.get(sub) ? {permissions: []} : undefined,
52
+ }
53
+ // What a client asks for: the first-party app its API, anyone else the MCP resource.
54
+ const target = (clientId: string) => clientId === "app" ? {resource: API, scope: "api"} : {resource: MCP, scope: "mcp"}
55
+
56
+ let oauth!: GGOAuthServer
57
+
58
+ class OAuthTestRuntime extends GGRuntime {
59
+ public static readonly NAME = "oauth-test"
60
+
61
+ protected compose(): void {
62
+ const server = new GGHttpServer({port})
63
+ USER_WIRE_HANDLER.create(undefined)
64
+ oauth = new GGOAuthServer({
65
+ issuer: () => ISSUER,
66
+ store: new InMemoryOAuthStore(),
67
+ secret: () => "oauth-test-secret",
68
+ resources: [{
69
+ resource: () => API,
70
+ accessToken: userToken,
71
+ scopes: ["api"],
72
+ firstPartyOnly: true,
73
+ claims: async (sub) => {
74
+ if (failClaimsOnce) {
75
+ failClaimsOnce = false
76
+ throw new Error("store unavailable")
77
+ }
78
+ const permissions = userPermissions.get(sub)
79
+ return permissions ? {permissions} : undefined
80
+ },
81
+ }, mcp],
82
+ interactionUrl: (uid) => `${APP_LOGIN}?uid=${uid}`,
83
+ firstPartyClients: () => [{clientId: "app", name: "The App", redirectUris: [FIRST_PARTY_REDIRECT], postLogoutRedirectUris: ["https://app.example/"]}],
84
+ dynamicRegistration: true,
85
+ clientIdMetadataDocument: {allowedUrlPrefixes: () => ["https://clients.example/oauth/"]},
86
+ proxy: true,
87
+ })
88
+ oauth.mount(server)
89
+ oauth.protect(server, mcp, "/mcp", async (_req, res, token) => {
90
+ res.writeHead(200, {"content-type": "application/json"})
91
+ res.end(JSON.stringify({hello: token.sub}))
92
+ })
93
+ new GGHttp(server).http(InteractionApi, oauth.interactionApi(() => SUBJECT.get()!.sub))
94
+ }
95
+ }
96
+
97
+ // A browser: keeps cookies, never follows redirects on its own.
98
+ class Browser {
99
+ private readonly jar = new Map<string, string>()
100
+
101
+ async get(url: string, init: RequestInit = {}): Promise<Response> {
102
+ const res = await fetch(new URL(url, ISSUER), {...init, redirect: "manual", headers: {...init.headers as Record<string, string>, ...(this.jar.size ? {cookie: [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ")} : {})}})
103
+ for (const c of res.headers.getSetCookie()) {
104
+ const [kv] = c.split(";")
105
+ const i = kv.indexOf("=")
106
+ this.jar.set(kv.slice(0, i), kv.slice(i + 1))
107
+ }
108
+ return res
109
+ }
110
+
111
+ // Follow redirects until one leaves the authorization server; returns that URL.
112
+ async navigate(url: string): Promise<URL> {
113
+ let next = new URL(url, ISSUER)
114
+ while (next.origin === ISSUER) {
115
+ const res = await this.get(next.href)
116
+ if (res.status !== 303 && res.status !== 302) throw new Error(`${res.status} at ${next.href}: ${await res.text()}`)
117
+ next = new URL(res.headers.get("location")!, ISSUER)
118
+ }
119
+ return next
120
+ }
121
+ }
122
+
123
+ const pkce = () => {
124
+ const verifier = randomBytes(32).toString("base64url")
125
+ return {verifier, challenge: createHash("sha256").update(verifier).digest("base64url")}
126
+ }
127
+
128
+ function authorizeUrl(clientId: string, redirectUri: string, challenge: string, extra: Record<string, string> = {}): string {
129
+ const url = new URL(`${ISSUER}/oauth/authorize`)
130
+ url.search = new URLSearchParams({client_id: clientId, response_type: "code", redirect_uri: redirectUri, state: "st", code_challenge: challenge, code_challenge_method: "S256", ...target(clientId), ...extra}).toString()
131
+ return url.href
132
+ }
133
+
134
+ async function token(body: Record<string, string>): Promise<Record<string, any>> {
135
+ const res = await fetch(`${ISSUER}/oauth/token`, {method: "POST", body: new URLSearchParams(body)})
136
+ return await res.json()
137
+ }
138
+
139
+ const interaction = (bearer: string) => async (method: "get" | "approve" | "deny", uid: string) => {
140
+ const res = await fetch(`${API}/oauth/interaction/${method}${method === "get" ? `?uid=${uid}` : ""}`, {
141
+ method: method === "get" ? "GET" : "POST",
142
+ headers: {authorization: `Bearer ${bearer}`, ...(method === "get" ? {} : {"content-type": "application/json"})},
143
+ body: method === "get" ? undefined : JSON.stringify({uid}),
144
+ })
145
+ const json = await res.json() as {success: boolean, data: any}
146
+ return json
147
+ }
148
+
149
+ async function signIn(browser: Browser, clientId: string, redirectUri: string, sub = "alice") {
150
+ const {verifier, challenge} = pkce()
151
+ const login = await browser.navigate(authorizeUrl(clientId, redirectUri, challenge))
152
+ expect(login.origin + login.pathname).toBe(APP_LOGIN)
153
+ const uid = login.searchParams.get("uid")!
154
+ const call = interaction((await userToken.issue(sub, {permissions: []})).token)
155
+ const details = await call("get", uid)
156
+ const approved = await call("approve", uid)
157
+ const callback = await browser.navigate(approved.data.redirectUrl)
158
+ expect(callback.origin + callback.pathname).toBe(redirectUri)
159
+ expect(callback.searchParams.get("iss")).toBe(ISSUER)
160
+ const tokens = await token({grant_type: "authorization_code", code: callback.searchParams.get("code")!, redirect_uri: redirectUri, client_id: clientId, code_verifier: verifier, resource: target(clientId).resource})
161
+ return {details: details.data, tokens}
162
+ }
163
+
164
+ // A web client unless it only redirects to loopback (a CLI, which leaves application_type out).
165
+ async function register(redirectUris = [THIRD_PARTY_REDIRECT]): Promise<string> {
166
+ const native = redirectUris.every(u => new URL(u).protocol === "http:")
167
+ const res = await fetch(`${ISSUER}/oauth/register`, {
168
+ method: "POST", headers: {"content-type": "application/json"},
169
+ body: JSON.stringify({client_name: "Claude", redirect_uris: redirectUris, token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], ...(native ? {} : {application_type: "web"})}),
170
+ })
171
+ expect(res.status).toBe(201)
172
+ return (await res.json()).client_id
173
+ }
174
+
175
+ describe("GGOAuthServer resources", () => {
176
+ const base = {issuer: () => ISSUER, store: new InMemoryOAuthStore(), secret: () => "s", interactionUrl: () => "", scopes: ["x"] as [string]}
177
+ const resource = (audience: string | undefined) => ({
178
+ resource: () => `${ISSUER}/${audience ?? "none"}`,
179
+ accessToken: new GGAuthAccessToken({signer: new HmacSigner("k"), claimSchema: IsClaims, audience}),
180
+ scopes: ["x"] as [string],
181
+ claims: async () => ({permissions: []}),
182
+ })
183
+
184
+ test("each resource needs its own audience (shared signer)", () => {
185
+ expect(() => new GGOAuthServer({...base, resources: [resource("a"), resource(undefined)]})).toThrow(/own audience/)
186
+ expect(() => new GGOAuthServer({...base, resources: [resource("a"), resource("a")]})).toThrow(/own audience/)
187
+ expect(() => new GGOAuthServer({...base, resources: [resource("a"), resource("b")]})).not.toThrow()
188
+ })
189
+ })
190
+
191
+ describe("GGOAuthServer", () => {
192
+ let runtime: OAuthTestRuntime
193
+ beforeAll(async () => { runtime = await new OAuthTestRuntime().start() })
194
+ afterAll(async () => { await runtime.teardown() })
195
+ beforeEach(() => { userPermissions.set("alice", ["read", "write"]) })
196
+
197
+ test("publishes RFC 8414 metadata with what MCP clients require", async () => {
198
+ const meta = await (await fetch(`${ISSUER}/.well-known/oauth-authorization-server`)).json()
199
+ expect(meta).toMatchObject({
200
+ issuer: ISSUER,
201
+ authorization_endpoint: `${ISSUER}/oauth/authorize`,
202
+ token_endpoint: `${ISSUER}/oauth/token`,
203
+ registration_endpoint: `${ISSUER}/oauth/register`,
204
+ code_challenge_methods_supported: ["S256"],
205
+ authorization_response_iss_parameter_supported: true,
206
+ client_id_metadata_document_supported: true,
207
+ })
208
+ expect(meta.token_endpoint_auth_methods_supported).toContain("none")
209
+ expect(meta.response_types_supported).toEqual(["code"])
210
+ expect(meta.scopes_supported).toEqual(["openid", "offline_access", "api", "mcp"])
211
+ const jwks = await (await fetch(meta.jwks_uri)).json()
212
+ expect(jwks.keys).toEqual([expect.objectContaining({kty: "EC", crv: "P-256", alg: "ES256"})])
213
+ expect(jwks.keys[0].d).toBeUndefined()
214
+ })
215
+
216
+ test("first-party sign-in issues the app's own access token, then re-auths silently", async () => {
217
+ const browser = new Browser()
218
+ const {details, tokens} = await signIn(browser, "app", FIRST_PARTY_REDIRECT)
219
+ expect(details.client).toMatchObject({clientId: "app", name: "The App", firstParty: true, redirectHosts: ["app.example"]})
220
+ const verified = await userToken.verify(tokens.access_token)
221
+ expect(verified.sub).toBe("alice")
222
+ expect(verified.data).toEqual({permissions: ["read", "write"]})
223
+ expect(tokens.refresh_token).toEqual(expect.any(String))
224
+
225
+ const again = await browser.navigate(authorizeUrl("app", FIRST_PARTY_REDIRECT, pkce().challenge))
226
+ expect(again.origin + again.pathname).toBe(FIRST_PARTY_REDIRECT)
227
+ expect(again.searchParams.get("code")).toEqual(expect.any(String))
228
+ })
229
+
230
+ test("third-party client registers, needs consent once, and gets a token only for its resource", async () => {
231
+ const clientId = await register()
232
+ const browser = new Browser()
233
+ const {details, tokens} = await signIn(browser, clientId, THIRD_PARTY_REDIRECT)
234
+ expect(details).toMatchObject({resource: MCP, client: {name: "Claude", firstParty: false, redirectHosts: ["claude.ai"], loopbackOnly: false}})
235
+ expect((await mcpToken.verify(tokens.access_token)).data.permissions).toEqual([])
236
+ await expect(userToken.verify(tokens.access_token)).rejects.toThrow()
237
+
238
+ const again = await browser.navigate(authorizeUrl(clientId, THIRD_PARTY_REDIRECT, pkce().challenge))
239
+ expect(again.searchParams.get("code")).toEqual(expect.any(String))
240
+
241
+ expect(await oauth.listGrants("alice")).toEqual(expect.arrayContaining([expect.objectContaining({clientId, clientName: "Claude"})]))
242
+ })
243
+
244
+ test("a third-party client can't get a token for a first-party-only resource", async () => {
245
+ const clientId = await register()
246
+ const res = await new Browser().get(authorizeUrl(clientId, THIRD_PARTY_REDIRECT, pkce().challenge, {resource: API, scope: "api"}))
247
+ expect(res.status).toBe(303)
248
+ expect(new URL(res.headers.get("location")!).searchParams.get("error")).toBe("invalid_target")
249
+ })
250
+
251
+ test("a protected resource challenges with its metadata, then serves a valid token", async () => {
252
+ const anon = await fetch(MCP, {method: "POST"})
253
+ expect(anon.status).toBe(401)
254
+ expect(anon.headers.get("www-authenticate")).toBe(`Bearer resource_metadata="${ISSUER}/.well-known/oauth-protected-resource/mcp", scope="mcp"`)
255
+ const prm = await (await fetch(`${ISSUER}/.well-known/oauth-protected-resource/mcp`)).json()
256
+ expect(prm).toEqual({resource: MCP, authorization_servers: [ISSUER], scopes_supported: ["mcp"], bearer_methods_supported: ["header"]})
257
+
258
+ const app = await signIn(new Browser(), "app", FIRST_PARTY_REDIRECT)
259
+ expect((await fetch(MCP, {method: "POST", headers: {authorization: `Bearer ${app.tokens.access_token}`}})).status).toBe(401)
260
+ const {tokens} = await signIn(new Browser(), await register(), THIRD_PARTY_REDIRECT)
261
+ expect(await (await fetch(MCP, {method: "POST", headers: {authorization: `Bearer ${tokens.access_token}`}})).json()).toEqual({hello: "alice"})
262
+ })
263
+
264
+ test("a loopback (CLI) client matches on any port and is asked for consent every time", async () => {
265
+ const clientId = await register(["http://127.0.0.1/callback", "http://localhost/callback"])
266
+ const login = await new Browser().navigate(authorizeUrl(clientId, "http://localhost:51234/callback", pkce().challenge))
267
+ expect(login.origin + login.pathname).toBe(APP_LOGIN)
268
+ const browser = new Browser()
269
+ const {details} = await signIn(browser, clientId, "http://127.0.0.1:40123/callback")
270
+ expect(details.client.loopbackOnly).toBe(true)
271
+ // Any local process can claim a loopback redirect, so an existing grant isn't reused silently.
272
+ const again = await browser.navigate(authorizeUrl(clientId, "http://127.0.0.1:40999/callback", pkce().challenge))
273
+ expect(again.origin + again.pathname).toBe(APP_LOGIN)
274
+ })
275
+
276
+ test("client metadata documents are fetched only from allowed URL prefixes", async () => {
277
+ for (const clientId of [
278
+ "https://elsewhere.example/oauth/client.json",
279
+ "https://clients.example/other/client.json",
280
+ "https://clients.example/oauthx/client.json",
281
+ "https://clients.example:8443/oauth/client.json",
282
+ "https://user@clients.example/oauth/client.json",
283
+ ]) {
284
+ const res = await new Browser().get(authorizeUrl(clientId, THIRD_PARTY_REDIRECT, pkce().challenge))
285
+ expect(res.status, clientId).toBe(400)
286
+ expect(await res.text(), clientId).toContain("invalid_client")
287
+ }
288
+ })
289
+
290
+ test("deny sends access_denied to the client", async () => {
291
+ const clientId = await register()
292
+ const browser = new Browser()
293
+ const login = await browser.navigate(authorizeUrl(clientId, THIRD_PARTY_REDIRECT, pkce().challenge))
294
+ const denied = await interaction((await userToken.issue("alice", {permissions: []})).token)("deny", login.searchParams.get("uid")!)
295
+ const callback = await browser.navigate(denied.data.redirectUrl)
296
+ expect(callback.searchParams.get("error")).toBe("access_denied")
297
+ })
298
+
299
+ test("an approval only completes in the browser that started the flow", async () => {
300
+ const attacker = new Browser()
301
+ const login = await attacker.navigate(authorizeUrl("app", FIRST_PARTY_REDIRECT, pkce().challenge))
302
+ const approved = await interaction((await userToken.issue("alice", {permissions: []})).token)("approve", login.searchParams.get("uid")!)
303
+ const victim = await new Browser().get(approved.data.redirectUrl)
304
+ expect(victim.status).toBe(400)
305
+ expect(await attacker.get(approved.data.redirectUrl).then(r => r.status)).toBe(400)
306
+ })
307
+
308
+ test("refresh rotates, re-resolves claims, and treats replay as reuse", async () => {
309
+ const {tokens} = await signIn(new Browser(), "app", FIRST_PARTY_REDIRECT)
310
+ userPermissions.set("alice", ["read"])
311
+ const refreshed = await token({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: "app"})
312
+ expect((await userToken.verify(refreshed.access_token)).data.permissions).toEqual(["read"])
313
+ expect(refreshed.refresh_token).not.toBe(tokens.refresh_token)
314
+
315
+ expect((await token({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: "app"})).error).toBe("invalid_grant")
316
+ expect((await token({grant_type: "refresh_token", refresh_token: refreshed.refresh_token, client_id: "app"})).error).toBe("invalid_grant")
317
+ })
318
+
319
+ test("refresh fails once the subject is gone", async () => {
320
+ const {tokens} = await signIn(new Browser(), "app", FIRST_PARTY_REDIRECT)
321
+ userPermissions.set("alice", undefined)
322
+ expect((await token({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: "app"})).error).toBe("invalid_grant")
323
+ })
324
+
325
+ test("revoking a grant kills its refresh token; revokeAll ends the login session", async () => {
326
+ const clientId = await register()
327
+ const browser = new Browser()
328
+ const {tokens} = await signIn(browser, clientId, THIRD_PARTY_REDIRECT)
329
+ const grant = (await oauth.listGrants("alice")).find(g => g.clientId === clientId)!
330
+ await oauth.revokeGrant("alice", grant.grantId)
331
+ expect((await token({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: clientId})).error).toBe("invalid_grant")
332
+ expect((await oauth.listGrants("alice")).map(g => g.grantId)).not.toContain(grant.grantId)
333
+
334
+ const appBrowser = new Browser()
335
+ const app = await signIn(appBrowser, "app", FIRST_PARTY_REDIRECT)
336
+ await oauth.revokeAll("alice")
337
+ expect(await oauth.listGrants("alice")).toEqual([])
338
+ expect((await token({grant_type: "refresh_token", refresh_token: app.tokens.refresh_token, client_id: "app"})).error).toBe("invalid_grant")
339
+ const after = await appBrowser.navigate(authorizeUrl("app", FIRST_PARTY_REDIRECT, pkce().challenge))
340
+ expect(after.origin + after.pathname).toBe(APP_LOGIN)
341
+ })
342
+
343
+ test("logout ends the login session, but not other apps' access", async () => {
344
+ const browser = new Browser()
345
+ await signIn(browser, "app", FIRST_PARTY_REDIRECT)
346
+ const mcpClient = await register()
347
+ const connected = await signIn(browser, mcpClient, THIRD_PARTY_REDIRECT)
348
+ const logoutUrl = `/oauth/logout?client_id=app&post_logout_redirect_uri=${encodeURIComponent("https://app.example/")}`
349
+ // Linked from any other page: the user must click to confirm.
350
+ for (const referer of [undefined, "https://workspace.app.example/"]) {
351
+ const linked = await (await browser.get(logoutUrl, {headers: referer ? {referer} : {}})).text()
352
+ expect(linked).not.toContain("document.forms[0].submit()")
353
+ expect(linked).toContain(`<button name="logout" value="yes">`)
354
+ }
355
+ const page = await (await browser.get(logoutUrl, {headers: {referer: "https://app.example/some/page"}})).text()
356
+ expect(page).toContain("document.forms[0].submit()")
357
+ const action = page.match(/action="([^"]+)"/)![1]
358
+ const fields = Object.fromEntries([...page.matchAll(/<input type="hidden" name="([^"]+)" value="([^"]*)"/g)].map(m => [m[1], m[2]]))
359
+ expect(fields.logout).toBe("yes")
360
+ const done = await browser.get(action, {method: "POST", headers: {"content-type": "application/x-www-form-urlencoded"}, body: new URLSearchParams(fields).toString()})
361
+ expect(done.headers.get("location")).toBe("https://app.example/")
362
+
363
+ const after = await browser.navigate(authorizeUrl("app", FIRST_PARTY_REDIRECT, pkce().challenge))
364
+ expect(after.origin + after.pathname).toBe(APP_LOGIN)
365
+ expect((await token({grant_type: "refresh_token", refresh_token: connected.tokens.refresh_token, client_id: mcpClient})).access_token).toEqual(expect.any(String))
366
+ })
367
+
368
+ test("an approval link never points at a host the request named itself", async () => {
369
+ // A client can't set Host from fetch; X-Forwarded-Host (trusted behind a proxy) feeds the same value.
370
+ const res = await fetch(authorizeUrl("app", FIRST_PARTY_REDIRECT, pkce().challenge), {redirect: "manual", headers: {"x-forwarded-host": "evil.example"}})
371
+ const uid = new URL(res.headers.get("location")!, "https://evil.example").pathname.split("/").pop()!
372
+ const approved = await interaction((await userToken.issue("alice", {permissions: []})).token)("approve", uid)
373
+ expect(new URL(approved.data.redirectUrl).origin).toBe(ISSUER)
374
+
375
+ const meta = await (await fetch(`${ISSUER}/.well-known/oauth-authorization-server`, {headers: {"x-forwarded-host": "evil.example"}})).json()
376
+ expect(new URL(meta.token_endpoint).origin).toBe(ISSUER)
377
+ })
378
+
379
+ test("a refresh that fails before issuing (claims unavailable) can be retried", async () => {
380
+ const {tokens} = await signIn(new Browser(), "app", FIRST_PARTY_REDIRECT)
381
+ failClaimsOnce = true
382
+ const failed = await fetch(`${ISSUER}/oauth/token`, {method: "POST", body: new URLSearchParams({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: "app"})})
383
+ expect(failed.status).toBe(500)
384
+ expect((await token({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: "app"})).access_token).toEqual(expect.any(String))
385
+ })
386
+
387
+ test("a refresh asking for a resource the token can't have fails without using the token up", async () => {
388
+ const clientId = await register()
389
+ const {tokens} = await signIn(new Browser(), clientId, THIRD_PARTY_REDIRECT)
390
+ const wrong = await token({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: clientId, resource: API})
391
+ expect(wrong.error).toBe("invalid_target")
392
+ expect((await token({grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: clientId})).access_token).toEqual(expect.any(String))
393
+ })
394
+
395
+ test("of two concurrent refreshes with one token, only one succeeds", async () => {
396
+ const {tokens} = await signIn(new Browser(), "app", FIRST_PARTY_REDIRECT)
397
+ const body = {grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: "app"}
398
+ const results = await Promise.all([token(body), token(body)])
399
+ // Never two live lineages from one token; the loser gets invalid_grant (not a retryable 5xx).
400
+ expect(results.map(r => r.error ?? "ok").sort()).toEqual(["invalid_grant", "ok"])
401
+ })
402
+
403
+ test("the interaction API requires the app's signed-in user", async () => {
404
+ const res = await fetch(`${API}/oauth/interaction/get?uid=x`)
405
+ expect(res.status).toBe(401)
406
+ })
407
+ })
408
+
409
+ async function freePort(): Promise<number> {
410
+ return await new Promise((resolve) => {
411
+ const s = createServer().listen(0, () => {
412
+ const p = (s.address() as {port: number}).port
413
+ s.close(() => resolve(p))
414
+ })
415
+ })
416
+ }