Skip to content

Commit 72875f9

Browse files
authored
Add support for service-to-service API key authentication (#6764)
1 parent 97ce74f commit 72875f9

File tree

8 files changed

+82
-10
lines changed

8 files changed

+82
-10
lines changed

.changeset/poor-donkeys-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@thirdweb-dev/service-utils": patch
3+
---
4+
5+
add support for service-to-service api key authentication

packages/service-utils/src/cf-worker/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,18 @@ export async function extractAuthorizationData(
147147
}
148148
}
149149

150+
let incomingServiceApiKey: string | null = null;
151+
let incomingServiceApiKeyHash: string | null = null;
152+
if (headers.has("x-service-api-key")) {
153+
incomingServiceApiKey = headers.get("x-service-api-key");
154+
if (incomingServiceApiKey) {
155+
incomingServiceApiKeyHash = await hashSecretKey(incomingServiceApiKey);
156+
}
157+
}
158+
150159
return {
160+
incomingServiceApiKey,
161+
incomingServiceApiKeyHash,
151162
jwt,
152163
hashedJWT: jwt ? await hashSecretKey(jwt) : null,
153164
secretKey,

packages/service-utils/src/core/api.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export async function fetchTeamAndProject(
228228
config: CoreServiceConfig,
229229
): Promise<ApiResponse> {
230230
const { apiUrl, serviceApiKey } = config;
231-
const { teamId, clientId } = authData;
231+
const { teamId, clientId, incomingServiceApiKey } = authData;
232232

233233
const url = new URL("/v2/keys/use", apiUrl);
234234
if (clientId) {
@@ -247,11 +247,17 @@ export async function fetchTeamAndProject(
247247
headers: {
248248
...(authData.secretKey ? { "x-secret-key": authData.secretKey } : {}),
249249
...(authData.jwt ? { Authorization: `Bearer ${authData.jwt}` } : {}),
250-
"x-service-api-key": serviceApiKey,
250+
// use the incoming service api key if it exists, otherwise use the service api key
251+
// this is done to ensure that the incoming service API key is VALID in the first place
252+
"x-service-api-key": incomingServiceApiKey
253+
? incomingServiceApiKey
254+
: serviceApiKey,
251255
"content-type": "application/json",
252256
},
253257
});
254258

259+
// TODO: if the response is a well understood status code (401, 402, etc), we should skip retry logic
260+
255261
let text = "";
256262
try {
257263
text = await response.text();

packages/service-utils/src/core/authorize/authorize.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ describe("authorizeClient", () => {
1919
secretKeyHash: null,
2020
hashedJWT: null,
2121
jwt: null,
22+
incomingServiceApiKey: null,
23+
incomingServiceApiKeyHash: null,
2224
ecosystemId: null,
2325
ecosystemPartnerId: null,
2426
},

packages/service-utils/src/core/authorize/client.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe("authorizeClient", () => {
1010
secretKeyHash: "secret-hash",
1111
bundleId: null,
1212
origin: "example.com",
13+
incomingServiceApiKey: null,
1314
};
1415

1516
it("should authorize client with valid secret key", () => {
@@ -27,6 +28,7 @@ describe("authorizeClient", () => {
2728
secretKeyHash: null,
2829
bundleId: null,
2930
origin: "sub.example.com",
31+
incomingServiceApiKey: null,
3032
};
3133

3234
const result = authorizeClient(
@@ -43,6 +45,7 @@ describe("authorizeClient", () => {
4345
secretKeyHash: null,
4446
bundleId: null,
4547
origin: null,
48+
incomingServiceApiKey: null,
4649
};
4750

4851
const validProjectResponseAnyDomain = {
@@ -67,6 +70,7 @@ describe("authorizeClient", () => {
6770
secretKeyHash: null,
6871
bundleId: "com.foo.bar",
6972
origin: null,
73+
incomingServiceApiKey: null,
7074
};
7175

7276
const result = authorizeClient(
@@ -87,6 +91,7 @@ describe("authorizeClient", () => {
8791
secretKeyHash: null,
8892
bundleId: null,
8993
origin: "unauthorized.com",
94+
incomingServiceApiKey: null,
9095
};
9196

9297
const result = authorizeClient(
@@ -101,4 +106,21 @@ describe("authorizeClient", () => {
101106
expect(result.errorCode).toBe("ORIGIN_UNAUTHORIZED");
102107
expect(result.status).toBe(401);
103108
});
109+
110+
it("should authorize client with incoming service api key", () => {
111+
const authOptionsWithServiceKey: ClientAuthorizationPayload = {
112+
secretKeyHash: null,
113+
bundleId: null,
114+
origin: "unauthorized.com", // Even unauthorized origin should work with service key
115+
incomingServiceApiKey: "test-service-key",
116+
};
117+
118+
const result = authorizeClient(
119+
authOptionsWithServiceKey,
120+
validTeamAndProjectResponse,
121+
// biome-ignore lint/suspicious/noExplicitAny: test only
122+
) as any;
123+
expect(result.authorized).toBe(true);
124+
expect(result.project).toEqual(validTeamAndProjectResponse.project);
125+
});
104126
});

packages/service-utils/src/core/authorize/client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ export type ClientAuthorizationPayload = {
55
secretKeyHash: string | null;
66
bundleId: string | null;
77
origin: string | null;
8+
incomingServiceApiKey: string | null;
89
};
910

1011
export function authorizeClient(
1112
authOptions: ClientAuthorizationPayload,
1213
teamAndProjectResponse: TeamAndProjectResponse,
1314
): AuthorizationResult {
14-
const { origin, bundleId } = authOptions;
15+
const { origin, bundleId, incomingServiceApiKey } = authOptions;
1516
const { team, project, authMethod } = teamAndProjectResponse;
1617

1718
const authResult: AuthorizationResult = {
@@ -31,6 +32,12 @@ export function authorizeClient(
3132
return authResult;
3233
}
3334

35+
if (authMethod === "publishableKey" && incomingServiceApiKey) {
36+
// if the auth was done using a combination of publishableKey and incomingServiceKey,
37+
// we will treat this the same as a secret key auth (relying on the upstream service to have already validated the publishableKey)
38+
return authResult;
39+
}
40+
3441
// check for public restrictions
3542
if (project.domains.includes("*")) {
3643
return authResult;

packages/service-utils/src/core/authorize/index.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { authorizeService } from "./service.js";
99
import type { AuthorizationResult } from "./types.js";
1010

1111
export type AuthorizationInput = {
12+
incomingServiceApiKey: string | null;
13+
incomingServiceApiKeyHash: string | null;
1214
secretKey: string | null;
1315
clientId: string | null;
1416
ecosystemId: string | null;
@@ -45,13 +47,19 @@ export async function authorize(
4547
let teamAndProjectResponse: TeamAndProjectResponse | null = null;
4648

4749
// Use a separate cache key per auth method.
48-
const cacheKey = authData.secretKeyHash
49-
? `key-v2:secret-key:${authData.secretKeyHash}`
50-
: authData.hashedJWT
51-
? `key-v2:dashboard-jwt:${authData.hashedJWT}:${authData.teamId ?? "default"}`
52-
: authData.clientId
53-
? `key-v2:client-id:${authData.clientId}`
54-
: null;
50+
const cacheKey = authData.incomingServiceApiKey
51+
? // incoming service key + clientId case
52+
`key-v2:service-key:${authData.incomingServiceApiKeyHash}:${authData.clientId ?? "default"}`
53+
: authData.secretKeyHash
54+
? // secret key case
55+
`key-v2:secret-key:${authData.secretKeyHash}`
56+
: authData.hashedJWT
57+
? // dashboard jwt case
58+
`key-v2:dashboard-jwt:${authData.hashedJWT}:${authData.teamId ?? "default"}`
59+
: authData.clientId
60+
? // clientId case
61+
`key-v2:client-id:${authData.clientId}`
62+
: null;
5563

5664
// TODO if we have cache options we want to check the cache first
5765
if (cacheOptions && cacheKey) {

packages/service-utils/src/node/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,18 @@ export function extractAuthorizationData(
151151
}
152152
}
153153

154+
let incomingServiceApiKey: string | null = null;
155+
let incomingServiceApiKeyHash: string | null = null;
156+
if (getHeader(headers, "x-service-api-key")) {
157+
incomingServiceApiKey = getHeader(headers, "x-service-api-key");
158+
if (incomingServiceApiKey) {
159+
incomingServiceApiKeyHash = hashSecretKey(incomingServiceApiKey);
160+
}
161+
}
162+
154163
return {
164+
incomingServiceApiKey,
165+
incomingServiceApiKeyHash,
155166
jwt,
156167
hashedJWT: jwt ? hashSecretKey(jwt) : null,
157168
secretKeyHash,

0 commit comments

Comments
 (0)