From a4ea5b06c06cd47edec5315e6c520b2669a3a568 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Fri, 21 Aug 2026 12:46:40 +0700 Subject: [PATCH 1/3] [service-utils] Scope service-key auth cache by teamId; drop timestampless cache entries --- .changeset/spicy-donuts-argue.md | 5 +++++ packages/service-utils/src/core/authorize/index.ts | 6 ++---- 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 .changeset/spicy-donuts-argue.md diff --git a/.changeset/spicy-donuts-argue.md b/.changeset/spicy-donuts-argue.md new file mode 100644 index 00000000000..53b28e299e0 --- /dev/null +++ b/.changeset/spicy-donuts-argue.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/service-utils": patch +--- + +Scope service-key authorization cache entries by teamId, and ignore cache entries that lack a timestamp diff --git a/packages/service-utils/src/core/authorize/index.ts b/packages/service-utils/src/core/authorize/index.ts index 087a68ae34f..ba99caa554d 100644 --- a/packages/service-utils/src/core/authorize/index.ts +++ b/packages/service-utils/src/core/authorize/index.ts @@ -49,8 +49,8 @@ export async function authorize( // Use a separate cache key per auth method. const cacheKey = authData.incomingServiceApiKey - ? // incoming service key + clientId case - `key-v2:service-key:${authData.incomingServiceApiKeyHash}:${authData.clientId ?? "client_default"}` + ? // incoming service key + teamId + clientId case + `key-v2:service-key:${authData.incomingServiceApiKeyHash}:${authData.teamId ?? "team_default"}:${authData.clientId ?? "client_default"}` : authData.secretKeyHash ? // secret key case `key-v2:secret-key:${authData.secretKeyHash}` @@ -80,8 +80,6 @@ export async function authorize( if (diff < cacheTtlMs) { teamAndProjectResponse = parsed.teamAndProjectResponse; } - } else { - teamAndProjectResponse = parsed; } } } catch { From 7e8644026f6e9681e03bc7b666af6aab25c21048 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Fri, 21 Aug 2026 12:52:29 +0700 Subject: [PATCH 2/3] [service-utils] Do not cache service-key auth without a tenant selector --- packages/service-utils/src/core/authorize/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/service-utils/src/core/authorize/index.ts b/packages/service-utils/src/core/authorize/index.ts index ba99caa554d..169f915debf 100644 --- a/packages/service-utils/src/core/authorize/index.ts +++ b/packages/service-utils/src/core/authorize/index.ts @@ -50,7 +50,10 @@ export async function authorize( // Use a separate cache key per auth method. const cacheKey = authData.incomingServiceApiKey ? // incoming service key + teamId + clientId case - `key-v2:service-key:${authData.incomingServiceApiKeyHash}:${authData.teamId ?? "team_default"}:${authData.clientId ?? "client_default"}` + // do not cache service-key requests that carry no tenant selector + authData.teamId || authData.clientId + ? `key-v2:service-key:${authData.incomingServiceApiKeyHash}:${authData.teamId ?? "team_default"}:${authData.clientId ?? "client_default"}` + : null : authData.secretKeyHash ? // secret key case `key-v2:secret-key:${authData.secretKeyHash}` From 40909e7374b6ea787cdfc6c89c55380fa27e14bf Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Wed, 26 Aug 2026 16:55:37 +0700 Subject: [PATCH 3/3] [Service Utils] authorize origin/bundle allowlists as a union A webview can present both an unlisted origin and a valid bundle id; the failed origin check must not preempt the bundle check. Reject only when whatever was presented matches neither allowlist. Co-Authored-By: Claude Fable 5 --- .changeset/allowlist-union.md | 5 ++ .../src/core/authorize/client.test.ts | 53 +++++++++++++++++++ .../src/core/authorize/client.ts | 45 +++++++++------- 3 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 .changeset/allowlist-union.md diff --git a/.changeset/allowlist-union.md b/.changeset/allowlist-union.md new file mode 100644 index 00000000000..da74ef686e2 --- /dev/null +++ b/.changeset/allowlist-union.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/service-utils": patch +--- + +Treat domain and bundle-id allowlists as a union: a request presenting both an unlisted origin and a valid bundle id (e.g. a webview or in-app browser) is now authorized by the bundle match instead of being rejected on the origin check. diff --git a/packages/service-utils/src/core/authorize/client.test.ts b/packages/service-utils/src/core/authorize/client.test.ts index e9f98986613..3cbca86ded6 100644 --- a/packages/service-utils/src/core/authorize/client.test.ts +++ b/packages/service-utils/src/core/authorize/client.test.ts @@ -86,6 +86,59 @@ describe("authorizeClient", () => { expect(result.status).toBe(401); }); + it("should authorize an unlisted origin when the bundle id matches (union)", () => { + // a webview presents both an origin the owner never listed and a valid + // bundle id; the allowlists are a union so this must be authorized + const authOptions: ClientAuthorizationPayload = { + bundleId: "com.foo.bar", + incomingServiceApiKey: null, + origin: "unauthorized.com", + secretKeyHash: null, + }; + + const responseWithBundle = { + ...validTeamAndProjectResponse, + project: { + ...validProjectResponse, + bundleIds: ["com.foo.bar"], + domains: ["example.com"], + }, + }; + + const result = authorizeClient( + authOptions, + responseWithBundle, + // biome-ignore lint/suspicious/noExplicitAny: test only + ) as any; + expect(result.authorized).toBe(true); + }); + + it("should not authorize when both origin and bundle id mismatch", () => { + const authOptions: ClientAuthorizationPayload = { + bundleId: "com.evil.app", + incomingServiceApiKey: null, + origin: "unauthorized.com", + secretKeyHash: null, + }; + + const responseWithBundle = { + ...validTeamAndProjectResponse, + project: { + ...validProjectResponse, + bundleIds: ["com.foo.bar"], + domains: ["example.com"], + }, + }; + + const result = authorizeClient( + authOptions, + responseWithBundle, + // biome-ignore lint/suspicious/noExplicitAny: test only + ) as any; + expect(result.authorized).toBe(false); + expect(result.errorCode).toBe("ORIGIN_UNAUTHORIZED"); + }); + it("should not authorize client with unauthorized origin", () => { const authOptionsWithUnauthorizedOrigin: ClientAuthorizationPayload = { bundleId: null, diff --git a/packages/service-utils/src/core/authorize/client.ts b/packages/service-utils/src/core/authorize/client.ts index 770948e9536..0e918b84606 100644 --- a/packages/service-utils/src/core/authorize/client.ts +++ b/packages/service-utils/src/core/authorize/client.ts @@ -48,17 +48,32 @@ export function authorizeClient( return authResult; } - // validate domains - if (origin) { - if ( - authorizeDomain({ - domains: project.domains, - origin, - }) - ) { - return authResult; - } + // The allowlists are a union: a request is authorized when its origin + // matches the domain allowlist OR its bundle id matches the bundle + // allowlist. A webview or in-app browser can legitimately present both an + // unlisted origin and a valid bundle id, so a failed origin check must not + // preempt the bundle check. + if ( + origin && + authorizeDomain({ + domains: project.domains, + origin, + }) + ) { + return authResult; + } + + if ( + bundleId && + authorizeBundleId({ + bundleId, + bundleIds: project.bundleIds, + }) + ) { + return authResult; + } + if (origin) { return { authorized: false, errorCode: "ORIGIN_UNAUTHORIZED", @@ -67,17 +82,7 @@ export function authorizeClient( }; } - // validate bundleId if (bundleId) { - if ( - authorizeBundleId({ - bundleId, - bundleIds: project.bundleIds, - }) - ) { - return authResult; - } - return { authorized: false, errorCode: "BUNDLE_UNAUTHORIZED",