@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/README.md +7 -4
- package/dist/src/_dedupCheck.js +1 -1
- package/dist/src/node/GGOAuthServer.d.ts +1 -1
- package/dist/src/node/GGOAuthServer.d.ts.map +1 -1
- package/dist/src/node/GGOAuthServer.js +14 -1
- package/dist/src/node/GGOAuthServer.js.map +1 -1
- package/dist/tsconfig.publish.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/_dedupCheck.ts +1 -1
- package/src/node/GGOAuthServer.test.ts +32 -5
- package/src/node/GGOAuthServer.ts +19 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grest-ts/oauth",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
69
|
-
"@grest-ts/db-dynamodb": "0.0.
|
|
70
|
-
"@grest-ts/http": "0.0.
|
|
71
|
-
"@grest-ts/schema": "0.0.
|
|
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
|
}
|
package/src/_dedupCheck.ts
CHANGED
|
@@ -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.
|
|
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: {
|
|
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
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
55
|
-
|
|
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
|
|
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!.
|
|
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
|
}
|