Skip to content
Merged
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
171 changes: 170 additions & 1 deletion lib/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
idTokenCustomClaims,
accessTokenCustomClaims,
Expand Down Expand Up @@ -29,6 +29,7 @@ import {
secureFetch,
denyPlanSelection,
denyPlanCancellation,
getM2MToken,
} from "./main";

global.kinde = {
Expand Down Expand Up @@ -183,6 +184,174 @@ describe("M2M Token", () => {
});
});

describe("getM2MToken", () => {
const mockOptions = {
domain: "https://test.kinde.com",
clientId: "test_client_id",
clientSecret: "test_client_secret",
audience: ["https://api.test.com"],
scopes: ["read", "write"],
headers: { "custom-header": "value" },
};

beforeEach(() => {
vi.clearAllMocks();
});

it("should fetch token with caching when skipCache is false", async () => {
global.kinde.cache.jwtToken.mockResolvedValueOnce("cached_token");

const result = await getM2MToken("test_token", mockOptions);

expect(result).toBe("cached_token");
expect(global.kinde.cache.jwtToken).toHaveBeenCalledWith("test_token", {
validation: {
key: {
type: "jwks",
jwks: {
url: "https://test.kinde.com/.well-known/jwks.json",
},
},
},
onMissingOrExpired: expect.any(Function),
});
});

it("should fetch token without caching when skipCache is true", async () => {
global.kinde.fetch.mockReturnValueOnce({
json: { access_token: "fresh_token" },
});

const result = await getM2MToken("test_token", {
...mockOptions,
skipCache: true,
});

expect(result).toBe("fresh_token");
expect(global.kinde.cache.jwtToken).not.toHaveBeenCalled();
expect(global.kinde.fetch).toHaveBeenCalledWith(
"https://test.kinde.com/oauth2/token",
{
method: "POST",
responseFormat: "json",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
"custom-header": "value",
},
body: expect.any(URLSearchParams),
},
);
});

it("should fetch token without caching when skipCache is undefined", async () => {
global.kinde.cache.jwtToken.mockResolvedValueOnce("cached_token");

const result = await getM2MToken("test_token", mockOptions);

expect(result).toBe("cached_token");
expect(global.kinde.cache.jwtToken).toHaveBeenCalled();
});

it("should include all parameters in the token request", async () => {
global.kinde.fetch.mockReturnValueOnce({
json: { access_token: "fresh_token" },
});

await getM2MToken("test_token", { ...mockOptions, skipCache: true });

const fetchCall = global.kinde.fetch.mock.calls[0];
const body = fetchCall[1].body as URLSearchParams;

expect(body.get("audience")).toBe("https://api.test.com");
expect(body.get("grant_type")).toBe("client_credentials");
expect(body.get("client_id")).toBe("test_client_id");
expect(body.get("client_secret")).toBe("test_client_secret");
expect(body.get("scope")).toBe("read write");
});

it("should handle missing optional parameters", async () => {
global.kinde.fetch.mockReturnValueOnce({
json: { access_token: "fresh_token" },
});

const minimalOptions = {
domain: "https://test.kinde.com",
clientId: "test_client_id",
clientSecret: "test_client_secret",
audience: ["https://api.test.com"],
};

await getM2MToken("test_token", { ...minimalOptions, skipCache: true });

const fetchCall = global.kinde.fetch.mock.calls[0];
const body = fetchCall[1].body as URLSearchParams;

expect(body.get("audience")).toBe("https://api.test.com");
expect(body.get("scope")).toBe("");
});

it("should throw error when required parameters are missing", async () => {
const invalidOptions = {
domain: "",
clientId: "test_client_id",
clientSecret: "test_client_secret",
audience: ["https://api.test.com"],
};

await expect(
getM2MToken("test_token", { ...invalidOptions, skipCache: true }),
).rejects.toThrow("getM2MToken: Missing required parameters");
});

it("should throw error when token response is invalid", async () => {
global.kinde.fetch.mockReturnValueOnce({
json: { invalid_field: "value" },
});

await expect(
getM2MToken("test_token", { ...mockOptions, skipCache: true }),
).rejects.toThrow("getM2MToken: No access token returned");
});

it("should throw error when fetch fails", async () => {
global.kinde.fetch.mockImplementationOnce(() => {
throw new Error("Network error");
});

await expect(
getM2MToken("test_token", { ...mockOptions, skipCache: true }),
).rejects.toThrow("getM2MToken: Failed to obtain token - Network error");
});

it("should handle onMissingOrExpired callback when using cache", async () => {
global.kinde.fetch.mockReturnValueOnce({
json: { access_token: "fresh_token" },
});
global.kinde.cache.jwtToken.mockImplementationOnce((tokenName, options) => {
// Simulate cache miss by calling onMissingOrExpired
return Promise.resolve(options.onMissingOrExpired());
});

const result = await getM2MToken("test_token", mockOptions);

expect(result).toBe("fresh_token");
expect(global.kinde.fetch).toHaveBeenCalledWith(
"https://test.kinde.com/oauth2/token",
{
method: "POST",
responseFormat: "json",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
"custom-header": "value",
},
body: expect.any(URLSearchParams),
},
);
});
});

describe("createKindeAPI", () => {
it("should return the value of the environment variable", async () => {
const env = await createKindeAPI(mockEvent);
Expand Down
84 changes: 50 additions & 34 deletions lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,54 @@
audience: string[];
scopes?: string[];
headers?: Record<string, string>;
skipCache?: boolean;
};

export async function getM2MToken<T = string>(
tokenName: T,
options: getM2MTokenOptions,
) {
const fetchToken = () => {
if (!options.domain || !options.clientId || !options.clientSecret) {
throw new Error("getM2MToken: Missing required parameters");
}

try {
const result = kinde.fetch(`${options.domain}/oauth2/token`, {
method: "POST",
responseFormat: "json",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
...options.headers,
},
body: new URLSearchParams({
audience: options.audience?.join(" ") ?? "",
grant_type: "client_credentials",
client_id: options.clientId,
client_secret: options.clientSecret,
scope: options.scopes?.join(" ") ?? "",
}),
}) as { json: { access_token: string } };

if (!result.json?.access_token) {
throw new Error("getM2MToken: No access token returned");
}

return result.json.access_token;
} catch (error) {
throw new Error(
`getM2MToken: Failed to obtain token - ${(error as Error).message}`,
);
}
};

// If skipCache is true, directly fetch the token without using cache
if (options.skipCache) {
return fetchToken();
}

// Otherwise, use the cache with onMissingOrExpired callback
return await kinde.cache.jwtToken(tokenName as string, {
validation: {
key: {
Expand All @@ -382,39 +424,7 @@
},
},
},
onMissingOrExpired: () => {
if (!options.domain || !options.clientId || !options.clientSecret) {
throw new Error("getM2MToken: Missing required parameters");
}

try {
const result = kinde.fetch(`${options.domain}/oauth2/token`, {
method: "POST",
responseFormat: "json",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
...options.headers,
},
body: new URLSearchParams({
audience: options.audience?.join(" ") ?? "",
grant_type: "client_credentials",
client_id: options.clientId,
client_secret: options.clientSecret,
scope: options.scopes?.join(" ") ?? "",
}),
}) as { json: { access_token: string } };

if (!result.json?.access_token) {
throw new Error("getM2MToken: No access token returned");
}
return result.json.access_token;
} catch (error) {
throw new Error(
`getM2MToken: Failed to obtain token - ${(error as Error).message}`,
);
}
},
onMissingOrExpired: fetchToken,
});
}

Expand Down Expand Up @@ -453,13 +463,19 @@
}
}

const token = await getM2MToken("internal_m2m_access_token", {
let token = await getM2MToken("internal_m2m_access_token", {
domain: event.context.domains.kindeDomain,
clientId,
clientSecret,
audience: [`${event.context.domains.kindeDomain}/api`],
});

if (typeof token === "object") {
token = JSON.stringify(token);
token = token.replace(`"\\"`, "");
token = token.replace(`\\""`, "");

Check warning on line 476 in lib/main.ts

View check run for this annotation

Codecov / codecov/patch

lib/main.ts#L474-L476

Added lines #L474 - L476 were not covered by tests
}

const callKindeAPI = async ({
method,
endpoint,
Expand Down