Skip to content

Commit 040ca9e

Browse files
committed
feat: option to skip cache on M2M token
1 parent 2cc4c96 commit 040ca9e

File tree

2 files changed

+215
-35
lines changed

2 files changed

+215
-35
lines changed

lib/main.test.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi } from "vitest";
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
22
import {
33
idTokenCustomClaims,
44
accessTokenCustomClaims,
@@ -29,6 +29,7 @@ import {
2929
secureFetch,
3030
denyPlanSelection,
3131
denyPlanCancellation,
32+
getM2MToken,
3233
} from "./main";
3334

3435
global.kinde = {
@@ -183,6 +184,174 @@ describe("M2M Token", () => {
183184
});
184185
});
185186

187+
describe("getM2MToken", () => {
188+
const mockOptions = {
189+
domain: "https://test.kinde.com",
190+
clientId: "test_client_id",
191+
clientSecret: "test_client_secret",
192+
audience: ["https://api.test.com"],
193+
scopes: ["read", "write"],
194+
headers: { "custom-header": "value" },
195+
};
196+
197+
beforeEach(() => {
198+
vi.clearAllMocks();
199+
});
200+
201+
it("should fetch token with caching when skipCache is false", async () => {
202+
global.kinde.cache.jwtToken.mockResolvedValueOnce("cached_token");
203+
204+
const result = await getM2MToken("test_token", mockOptions);
205+
206+
expect(result).toBe("cached_token");
207+
expect(global.kinde.cache.jwtToken).toHaveBeenCalledWith("test_token", {
208+
validation: {
209+
key: {
210+
type: "jwks",
211+
jwks: {
212+
url: "https://test.kinde.com/.well-known/jwks.json",
213+
},
214+
},
215+
},
216+
onMissingOrExpired: expect.any(Function),
217+
});
218+
});
219+
220+
it("should fetch token without caching when skipCache is true", async () => {
221+
global.kinde.fetch.mockReturnValueOnce({
222+
json: { access_token: "fresh_token" },
223+
});
224+
225+
const result = await getM2MToken("test_token", {
226+
...mockOptions,
227+
skipCache: true,
228+
});
229+
230+
expect(result).toBe("fresh_token");
231+
expect(global.kinde.cache.jwtToken).not.toHaveBeenCalled();
232+
expect(global.kinde.fetch).toHaveBeenCalledWith(
233+
"https://test.kinde.com/oauth2/token",
234+
{
235+
method: "POST",
236+
responseFormat: "json",
237+
headers: {
238+
"content-type": "application/x-www-form-urlencoded",
239+
accept: "application/json",
240+
"custom-header": "value",
241+
},
242+
body: expect.any(URLSearchParams),
243+
},
244+
);
245+
});
246+
247+
it("should fetch token without caching when skipCache is undefined", async () => {
248+
global.kinde.cache.jwtToken.mockResolvedValueOnce("cached_token");
249+
250+
const result = await getM2MToken("test_token", mockOptions);
251+
252+
expect(result).toBe("cached_token");
253+
expect(global.kinde.cache.jwtToken).toHaveBeenCalled();
254+
});
255+
256+
it("should include all parameters in the token request", async () => {
257+
global.kinde.fetch.mockReturnValueOnce({
258+
json: { access_token: "fresh_token" },
259+
});
260+
261+
await getM2MToken("test_token", { ...mockOptions, skipCache: true });
262+
263+
const fetchCall = global.kinde.fetch.mock.calls[0];
264+
const body = fetchCall[1].body as URLSearchParams;
265+
266+
expect(body.get("audience")).toBe("https://api.test.com");
267+
expect(body.get("grant_type")).toBe("client_credentials");
268+
expect(body.get("client_id")).toBe("test_client_id");
269+
expect(body.get("client_secret")).toBe("test_client_secret");
270+
expect(body.get("scope")).toBe("read write");
271+
});
272+
273+
it("should handle missing optional parameters", async () => {
274+
global.kinde.fetch.mockReturnValueOnce({
275+
json: { access_token: "fresh_token" },
276+
});
277+
278+
const minimalOptions = {
279+
domain: "https://test.kinde.com",
280+
clientId: "test_client_id",
281+
clientSecret: "test_client_secret",
282+
audience: ["https://api.test.com"],
283+
};
284+
285+
await getM2MToken("test_token", { ...minimalOptions, skipCache: true });
286+
287+
const fetchCall = global.kinde.fetch.mock.calls[0];
288+
const body = fetchCall[1].body as URLSearchParams;
289+
290+
expect(body.get("audience")).toBe("https://api.test.com");
291+
expect(body.get("scope")).toBe("");
292+
});
293+
294+
it("should throw error when required parameters are missing", async () => {
295+
const invalidOptions = {
296+
domain: "",
297+
clientId: "test_client_id",
298+
clientSecret: "test_client_secret",
299+
audience: ["https://api.test.com"],
300+
};
301+
302+
await expect(
303+
getM2MToken("test_token", { ...invalidOptions, skipCache: true }),
304+
).rejects.toThrow("getM2MToken: Missing required parameters");
305+
});
306+
307+
it("should throw error when token response is invalid", async () => {
308+
global.kinde.fetch.mockReturnValueOnce({
309+
json: { invalid_field: "value" },
310+
});
311+
312+
await expect(
313+
getM2MToken("test_token", { ...mockOptions, skipCache: true }),
314+
).rejects.toThrow("getM2MToken: No access token returned");
315+
});
316+
317+
it("should throw error when fetch fails", async () => {
318+
global.kinde.fetch.mockImplementationOnce(() => {
319+
throw new Error("Network error");
320+
});
321+
322+
await expect(
323+
getM2MToken("test_token", { ...mockOptions, skipCache: true }),
324+
).rejects.toThrow("getM2MToken: Failed to obtain token - Network error");
325+
});
326+
327+
it("should handle onMissingOrExpired callback when using cache", async () => {
328+
global.kinde.fetch.mockReturnValueOnce({
329+
json: { access_token: "fresh_token" },
330+
});
331+
global.kinde.cache.jwtToken.mockImplementationOnce((tokenName, options) => {
332+
// Simulate cache miss by calling onMissingOrExpired
333+
return Promise.resolve(options.onMissingOrExpired());
334+
});
335+
336+
const result = await getM2MToken("test_token", mockOptions);
337+
338+
expect(result).toBe("fresh_token");
339+
expect(global.kinde.fetch).toHaveBeenCalledWith(
340+
"https://test.kinde.com/oauth2/token",
341+
{
342+
method: "POST",
343+
responseFormat: "json",
344+
headers: {
345+
"content-type": "application/x-www-form-urlencoded",
346+
accept: "application/json",
347+
"custom-header": "value",
348+
},
349+
body: expect.any(URLSearchParams),
350+
},
351+
);
352+
});
353+
});
354+
186355
describe("createKindeAPI", () => {
187356
it("should return the value of the environment variable", async () => {
188357
const env = await createKindeAPI(mockEvent);

lib/main.ts

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -367,12 +367,55 @@ export type getM2MTokenOptions = {
367367
audience: string[];
368368
scopes?: string[];
369369
headers?: Record<string, string>;
370+
skipCache?: boolean;
370371
};
371372

372373
export async function getM2MToken<T = string>(
373374
tokenName: T,
374375
options: getM2MTokenOptions,
375376
) {
377+
const fetchToken = () => {
378+
if (!options.domain || !options.clientId || !options.clientSecret) {
379+
throw new Error("getM2MToken: Missing required parameters");
380+
}
381+
382+
try {
383+
const result = kinde.fetch(`${options.domain}/oauth2/token`, {
384+
method: "POST",
385+
responseFormat: "json",
386+
headers: {
387+
"content-type": "application/x-www-form-urlencoded",
388+
accept: "application/json",
389+
...options.headers,
390+
},
391+
body: new URLSearchParams({
392+
audience: options.audience?.join(" ") ?? "",
393+
grant_type: "client_credentials",
394+
client_id: options.clientId,
395+
client_secret: options.clientSecret,
396+
scope: options.scopes?.join(" ") ?? "",
397+
}),
398+
}) as { json: { access_token: string } };
399+
400+
if (!result.json?.access_token) {
401+
throw new Error("getM2MToken: No access token returned");
402+
}
403+
404+
console.log("returning token: ", result.json.access_token);
405+
return result.json.access_token;
406+
} catch (error) {
407+
throw new Error(
408+
`getM2MToken: Failed to obtain token - ${(error as Error).message}`,
409+
);
410+
}
411+
};
412+
413+
// If skipCache is true, directly fetch the token without using cache
414+
if (options.skipCache) {
415+
return fetchToken();
416+
}
417+
418+
// Otherwise, use the cache with onMissingOrExpired callback
376419
return await kinde.cache.jwtToken(tokenName as string, {
377420
validation: {
378421
key: {
@@ -382,39 +425,7 @@ export async function getM2MToken<T = string>(
382425
},
383426
},
384427
},
385-
onMissingOrExpired: () => {
386-
if (!options.domain || !options.clientId || !options.clientSecret) {
387-
throw new Error("getM2MToken: Missing required parameters");
388-
}
389-
390-
try {
391-
const result = kinde.fetch(`${options.domain}/oauth2/token`, {
392-
method: "POST",
393-
responseFormat: "json",
394-
headers: {
395-
"content-type": "application/x-www-form-urlencoded",
396-
accept: "application/json",
397-
...options.headers,
398-
},
399-
body: new URLSearchParams({
400-
audience: options.audience?.join(" ") ?? "",
401-
grant_type: "client_credentials",
402-
client_id: options.clientId,
403-
client_secret: options.clientSecret,
404-
scope: options.scopes?.join(" ") ?? "",
405-
}),
406-
}) as { json: { access_token: string } };
407-
408-
if (!result.json?.access_token) {
409-
throw new Error("getM2MToken: No access token returned");
410-
}
411-
return result.json.access_token;
412-
} catch (error) {
413-
throw new Error(
414-
`getM2MToken: Failed to obtain token - ${(error as Error).message}`,
415-
);
416-
}
417-
},
428+
onMissingOrExpired: fetchToken,
418429
});
419430
}
420431

@@ -453,7 +464,7 @@ export async function createKindeAPI(
453464
}
454465
}
455466

456-
const token = await getM2MToken("internal_m2m_access_token", {
467+
let token = await getM2MToken("internal_m2m_access_token", {
457468
domain: event.context.domains.kindeDomain,
458469
clientId,
459470
clientSecret,

0 commit comments

Comments
 (0)