Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/allowlist-union.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/spicy-donuts-argue.md
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions packages/service-utils/src/core/authorize/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 25 additions & 20 deletions packages/service-utils/src/core/authorize/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions packages/service-utils/src/core/authorize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ 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
// 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}`
Expand Down Expand Up @@ -80,8 +83,6 @@ export async function authorize(
if (diff < cacheTtlMs) {
teamAndProjectResponse = parsed.teamAndProjectResponse;
}
} else {
teamAndProjectResponse = parsed;
}
}
} catch {
Expand Down
Loading