diff --git a/.changeset/poor-donkeys-lay.md b/.changeset/poor-donkeys-lay.md new file mode 100644 index 00000000000..75c2c786d26 --- /dev/null +++ b/.changeset/poor-donkeys-lay.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/service-utils": patch +--- + +add support for service-to-service api key authentication diff --git a/packages/service-utils/src/cf-worker/index.ts b/packages/service-utils/src/cf-worker/index.ts index 99dad1715be..e5bca55728c 100644 --- a/packages/service-utils/src/cf-worker/index.ts +++ b/packages/service-utils/src/cf-worker/index.ts @@ -147,7 +147,18 @@ export async function extractAuthorizationData( } } + let incomingServiceApiKey: string | null = null; + let incomingServiceApiKeyHash: string | null = null; + if (headers.has("x-service-api-key")) { + incomingServiceApiKey = headers.get("x-service-api-key"); + if (incomingServiceApiKey) { + incomingServiceApiKeyHash = await hashSecretKey(incomingServiceApiKey); + } + } + return { + incomingServiceApiKey, + incomingServiceApiKeyHash, jwt, hashedJWT: jwt ? await hashSecretKey(jwt) : null, secretKey, diff --git a/packages/service-utils/src/core/api.ts b/packages/service-utils/src/core/api.ts index 0218636826f..d79c749ce8e 100644 --- a/packages/service-utils/src/core/api.ts +++ b/packages/service-utils/src/core/api.ts @@ -228,7 +228,7 @@ export async function fetchTeamAndProject( config: CoreServiceConfig, ): Promise { const { apiUrl, serviceApiKey } = config; - const { teamId, clientId } = authData; + const { teamId, clientId, incomingServiceApiKey } = authData; const url = new URL("/v2/keys/use", apiUrl); if (clientId) { @@ -247,11 +247,17 @@ export async function fetchTeamAndProject( headers: { ...(authData.secretKey ? { "x-secret-key": authData.secretKey } : {}), ...(authData.jwt ? { Authorization: `Bearer ${authData.jwt}` } : {}), - "x-service-api-key": serviceApiKey, + // use the incoming service api key if it exists, otherwise use the service api key + // this is done to ensure that the incoming service API key is VALID in the first place + "x-service-api-key": incomingServiceApiKey + ? incomingServiceApiKey + : serviceApiKey, "content-type": "application/json", }, }); + // TODO: if the response is a well understood status code (401, 402, etc), we should skip retry logic + let text = ""; try { text = await response.text(); diff --git a/packages/service-utils/src/core/authorize/authorize.test.ts b/packages/service-utils/src/core/authorize/authorize.test.ts index 46f559cb842..e92651285bf 100644 --- a/packages/service-utils/src/core/authorize/authorize.test.ts +++ b/packages/service-utils/src/core/authorize/authorize.test.ts @@ -19,6 +19,8 @@ describe("authorizeClient", () => { secretKeyHash: null, hashedJWT: null, jwt: null, + incomingServiceApiKey: null, + incomingServiceApiKeyHash: null, ecosystemId: null, ecosystemPartnerId: null, }, diff --git a/packages/service-utils/src/core/authorize/client.test.ts b/packages/service-utils/src/core/authorize/client.test.ts index ae4a3ce38fe..6039300817b 100644 --- a/packages/service-utils/src/core/authorize/client.test.ts +++ b/packages/service-utils/src/core/authorize/client.test.ts @@ -10,6 +10,7 @@ describe("authorizeClient", () => { secretKeyHash: "secret-hash", bundleId: null, origin: "example.com", + incomingServiceApiKey: null, }; it("should authorize client with valid secret key", () => { @@ -27,6 +28,7 @@ describe("authorizeClient", () => { secretKeyHash: null, bundleId: null, origin: "sub.example.com", + incomingServiceApiKey: null, }; const result = authorizeClient( @@ -43,6 +45,7 @@ describe("authorizeClient", () => { secretKeyHash: null, bundleId: null, origin: null, + incomingServiceApiKey: null, }; const validProjectResponseAnyDomain = { @@ -67,6 +70,7 @@ describe("authorizeClient", () => { secretKeyHash: null, bundleId: "com.foo.bar", origin: null, + incomingServiceApiKey: null, }; const result = authorizeClient( @@ -87,6 +91,7 @@ describe("authorizeClient", () => { secretKeyHash: null, bundleId: null, origin: "unauthorized.com", + incomingServiceApiKey: null, }; const result = authorizeClient( @@ -101,4 +106,21 @@ describe("authorizeClient", () => { expect(result.errorCode).toBe("ORIGIN_UNAUTHORIZED"); expect(result.status).toBe(401); }); + + it("should authorize client with incoming service api key", () => { + const authOptionsWithServiceKey: ClientAuthorizationPayload = { + secretKeyHash: null, + bundleId: null, + origin: "unauthorized.com", // Even unauthorized origin should work with service key + incomingServiceApiKey: "test-service-key", + }; + + const result = authorizeClient( + authOptionsWithServiceKey, + validTeamAndProjectResponse, + // biome-ignore lint/suspicious/noExplicitAny: test only + ) as any; + expect(result.authorized).toBe(true); + expect(result.project).toEqual(validTeamAndProjectResponse.project); + }); }); diff --git a/packages/service-utils/src/core/authorize/client.ts b/packages/service-utils/src/core/authorize/client.ts index 0419a0aba42..be2f8fe555b 100644 --- a/packages/service-utils/src/core/authorize/client.ts +++ b/packages/service-utils/src/core/authorize/client.ts @@ -5,13 +5,14 @@ export type ClientAuthorizationPayload = { secretKeyHash: string | null; bundleId: string | null; origin: string | null; + incomingServiceApiKey: string | null; }; export function authorizeClient( authOptions: ClientAuthorizationPayload, teamAndProjectResponse: TeamAndProjectResponse, ): AuthorizationResult { - const { origin, bundleId } = authOptions; + const { origin, bundleId, incomingServiceApiKey } = authOptions; const { team, project, authMethod } = teamAndProjectResponse; const authResult: AuthorizationResult = { @@ -31,6 +32,12 @@ export function authorizeClient( return authResult; } + if (authMethod === "publishableKey" && incomingServiceApiKey) { + // if the auth was done using a combination of publishableKey and incomingServiceKey, + // we will treat this the same as a secret key auth (relying on the upstream service to have already validated the publishableKey) + return authResult; + } + // check for public restrictions if (project.domains.includes("*")) { return authResult; diff --git a/packages/service-utils/src/core/authorize/index.ts b/packages/service-utils/src/core/authorize/index.ts index 98f509b8491..b54c427abce 100644 --- a/packages/service-utils/src/core/authorize/index.ts +++ b/packages/service-utils/src/core/authorize/index.ts @@ -9,6 +9,8 @@ import { authorizeService } from "./service.js"; import type { AuthorizationResult } from "./types.js"; export type AuthorizationInput = { + incomingServiceApiKey: string | null; + incomingServiceApiKeyHash: string | null; secretKey: string | null; clientId: string | null; ecosystemId: string | null; @@ -45,13 +47,19 @@ export async function authorize( let teamAndProjectResponse: TeamAndProjectResponse | null = null; // Use a separate cache key per auth method. - const cacheKey = authData.secretKeyHash - ? `key-v2:secret-key:${authData.secretKeyHash}` - : authData.hashedJWT - ? `key-v2:dashboard-jwt:${authData.hashedJWT}:${authData.teamId ?? "default"}` - : authData.clientId - ? `key-v2:client-id:${authData.clientId}` - : null; + const cacheKey = authData.incomingServiceApiKey + ? // incoming service key + clientId case + `key-v2:service-key:${authData.incomingServiceApiKeyHash}:${authData.clientId ?? "default"}` + : authData.secretKeyHash + ? // secret key case + `key-v2:secret-key:${authData.secretKeyHash}` + : authData.hashedJWT + ? // dashboard jwt case + `key-v2:dashboard-jwt:${authData.hashedJWT}:${authData.teamId ?? "default"}` + : authData.clientId + ? // clientId case + `key-v2:client-id:${authData.clientId}` + : null; // TODO if we have cache options we want to check the cache first if (cacheOptions && cacheKey) { diff --git a/packages/service-utils/src/node/index.ts b/packages/service-utils/src/node/index.ts index 750e7fb00c9..edb1695b098 100644 --- a/packages/service-utils/src/node/index.ts +++ b/packages/service-utils/src/node/index.ts @@ -151,7 +151,18 @@ export function extractAuthorizationData( } } + let incomingServiceApiKey: string | null = null; + let incomingServiceApiKeyHash: string | null = null; + if (getHeader(headers, "x-service-api-key")) { + incomingServiceApiKey = getHeader(headers, "x-service-api-key"); + if (incomingServiceApiKey) { + incomingServiceApiKeyHash = hashSecretKey(incomingServiceApiKey); + } + } + return { + incomingServiceApiKey, + incomingServiceApiKeyHash, jwt, hashedJWT: jwt ? hashSecretKey(jwt) : null, secretKeyHash,