@grest-ts/oauth 0.0.89 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grest-ts/oauth",
3
- "version": "0.0.89",
3
+ "version": "0.0.90",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "OAuth 2.1 authorization server (panva oidc-provider on GGHttpServer) issuing @grest-ts/auth tokens, plus a browser code+PKCE client for GGAuthSession",
@@ -65,9 +65,9 @@
65
65
  "oidc-provider": "9.13.0"
66
66
  },
67
67
  "peerDependencies": {
68
- "@grest-ts/auth": "0.0.89",
69
- "@grest-ts/db-dynamodb": "0.0.89",
70
- "@grest-ts/http": "0.0.89",
71
- "@grest-ts/schema": "0.0.89"
68
+ "@grest-ts/auth": "0.0.90",
69
+ "@grest-ts/db-dynamodb": "0.0.90",
70
+ "@grest-ts/http": "0.0.90",
71
+ "@grest-ts/schema": "0.0.90"
72
72
  }
73
73
  }
@@ -12,7 +12,7 @@
12
12
  const __KEY = Symbol.for("grest-ts:dedup:@grest-ts/oauth");
13
13
  const __slot = globalThis as Record<symbol, unknown>;
14
14
  const __existing = __slot[__KEY] as { path: string; version: string } | undefined;
15
- const __here = { path: import.meta.url, version: "0.0.89" };
15
+ const __here = { path: import.meta.url, version: "0.0.90" };
16
16
  // Same path + same version means the same physical package is being
17
17
  // re-evaluated by a different ESM runner (e.g. vitest projects share one
18
18
  // process but each project has its own module cache). That isn't a true
@@ -82,7 +82,7 @@ class OAuthTestRuntime extends GGRuntime {
82
82
  interactionUrl: (uid) => `${APP_LOGIN}?uid=${uid}`,
83
83
  firstPartyClients: () => [{clientId: "app", name: "The App", redirectUris: [FIRST_PARTY_REDIRECT], postLogoutRedirectUris: ["https://app.example/"]}],
84
84
  dynamicRegistration: true,
85
- clientIdMetadataDocument: {allowedHosts: () => ["clients.example"]},
85
+ clientIdMetadataDocument: {allowedUrlPrefixes: () => ["https://clients.example/oauth/"]},
86
86
  proxy: true,
87
87
  })
88
88
  oauth.mount(server)
@@ -172,6 +172,22 @@ async function register(redirectUris = [THIRD_PARTY_REDIRECT]): Promise<string>
172
172
  return (await res.json()).client_id
173
173
  }
174
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
+
175
191
  describe("GGOAuthServer", () => {
176
192
  let runtime: OAuthTestRuntime
177
193
  beforeAll(async () => { runtime = await new OAuthTestRuntime().start() })
@@ -257,10 +273,18 @@ describe("GGOAuthServer", () => {
257
273
  expect(again.origin + again.pathname).toBe(APP_LOGIN)
258
274
  })
259
275
 
260
- test("client metadata documents are fetched only from allowed hosts", async () => {
261
- const res = await new Browser().get(authorizeUrl("https://elsewhere.example/client.json", THIRD_PARTY_REDIRECT, pkce().challenge))
262
- expect(res.status).toBe(400)
263
- expect(await res.text()).toContain("invalid_client")
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
+ }
264
288
  })
265
289
 
266
290
  test("deny sends access_denied to the client", async () => {
@@ -347,6 +371,9 @@ describe("GGOAuthServer", () => {
347
371
  const uid = new URL(res.headers.get("location")!, "https://evil.example").pathname.split("/").pop()!
348
372
  const approved = await interaction((await userToken.issue("alice", {permissions: []})).token)("approve", uid)
349
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)
350
377
  })
351
378
 
352
379
  test("a refresh that fails before issuing (claims unavailable) can be retried", async () => {
@@ -51,12 +51,14 @@ export interface GGOAuthServerOptions {
51
51
  // Let any client self-register (RFC 7591).
52
52
  dynamicRegistration?: boolean
53
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[]}
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[]}
56
57
  // Absolute refresh lifetime: rotation never extends it (OAuth for browser apps, RFC 10017).
57
58
  refreshTtlSec?: number
58
59
  sessionTtlSec?: number
59
- // Trust X-Forwarded-* (behind a TLS-terminating load balancer).
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.
60
62
  proxy?: boolean
61
63
  }
62
64
 
@@ -79,6 +81,12 @@ export class GGOAuthServer {
79
81
  private readonly options: GGOAuthServerOptions
80
82
 
81
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
+ }
82
90
  this.options = options
83
91
  }
84
92
 
@@ -89,6 +97,7 @@ export class GGOAuthServer {
89
97
 
90
98
  public mount(server: GGHttpServer): void {
91
99
  const handle = async (req: IncomingMessage, res: ServerResponse) => {
100
+ delete req.headers["x-forwarded-host"]
92
101
  const path = new URL(req.url ?? "/", "http://x").pathname
93
102
  const interaction = path.match(/^\/oauth\/interaction\/([^/]+)(\/complete)?$/)
94
103
  if (interaction && req.method === "GET") {
@@ -346,7 +355,7 @@ export class GGOAuthServer {
346
355
  ...(o.clientIdMetadataDocument ? {clientIdMetadataDocument: {
347
356
  enabled: true,
348
357
  ack: "draft-02",
349
- allowFetch: async (_ctx: unknown, clientId: string) => o.clientIdMetadataDocument!.allowedHosts().includes(new URL(clientId).hostname),
358
+ allowFetch: async (_ctx: unknown, clientId: string) => isAllowedDocumentUrl(clientId, o.clientIdMetadataDocument!.allowedUrlPrefixes()),
350
359
  }} : {}),
351
360
  rpInitiatedLogout: {
352
361
  enabled: true,
@@ -464,6 +473,12 @@ export class GGOAuthServer {
464
473
  }
465
474
 
466
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
+
467
482
  function requestedScopes(params: Record<string, unknown>): string[] {
468
483
  return String(params.scope ?? "").split(" ").filter(Boolean)
469
484
  }