Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add isAuthenticated and refreshToken functions #11

Merged
merged 19 commits into from
Nov 19, 2024
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
8 changes: 7 additions & 1 deletion lib/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ describe("index exports", () => {
"generateAuthUrl",
"generateRandomString",
"mapLoginMethodParamsForUrl",
"sanatizeURL",
"sanitizeUrl",
"exchangeAuthCode",
"isAuthenticated",
"refreshToken",

// session manager
"MemoryStorage",
Expand All @@ -52,6 +54,10 @@ describe("index exports", () => {
"getActiveStorage",
"hasActiveStorage",
"clearActiveStorage",
"clearInsecureStorage",
"getInsecureStorage",
"hasInsecureStorage",
"setInsecureStorage",
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
"getClaim",
"getClaims",
"getCurrentOrganization",
Expand Down
2 changes: 1 addition & 1 deletion lib/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * from "./types";
export * from "./utils";
export * from "./sessionManager";
export * from "./utils/token/index.ts";
export * from "./utils/token";
12 changes: 12 additions & 0 deletions lib/utils/base64UrlEncode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,16 @@ describe("base64UrlEncode", () => {
const result = base64UrlEncode(input);
expect(result).toBe(expectedOutput);
});

it("should encode when passed an ArrayBuffer", () => {
const buffer = new ArrayBuffer(8);
const view = new Uint8Array(buffer);
for (let i = 0; i < view.length; i++) {
view[i] = i + 1;
}

const expectedOutput = "AQIDBAUGBwg";
const result = base64UrlEncode(buffer);
expect(result).toBe(expectedOutput);
});
});
20 changes: 13 additions & 7 deletions lib/utils/base64UrlEncode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@
* @param str String to encode
* @returns encoded string
*/
export const base64UrlEncode = (str: string): string => {
export const base64UrlEncode = (input: string | ArrayBuffer): string => {
const toBase64Url = (str: string): string =>
btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

if (input instanceof ArrayBuffer) {
const uint8Array = new Uint8Array(input);
const binaryString = String.fromCharCode(...uint8Array);
return toBase64Url(binaryString);
}

const encoder = new TextEncoder();
const uintArray = encoder.encode(str);
const charArray = Array.from(uintArray);
return btoa(String.fromCharCode.apply(null, charArray))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const uint8Array = encoder.encode(input);
const binaryString = String.fromCharCode(...uint8Array);
return toBase64Url(binaryString);
};
70 changes: 63 additions & 7 deletions lib/utils/exchangeAuthCode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ import { MemoryStorage, StorageKeys } from "../sessionManager";
import { setActiveStorage } from "./token";
import createFetchMock from "vitest-fetch-mock";
import { frameworkSettings } from "./exchangeAuthCode";
import * as refreshTokenTimer from "./refreshTimer";
import * as main from "../main";

const fetchMock = createFetchMock(vi);

describe("exchangeAuthCode", () => {
beforeEach(() => {
fetchMock.enableMocks();
vi.spyOn(refreshTokenTimer, "setRefreshTimer");
vi.spyOn(main, "refreshToken");
vi.useFakeTimers();
});

afterEach(() => {
fetchMock.resetMocks();
vi.useRealTimers();
});

it("missing state param", async () => {
Expand Down Expand Up @@ -142,10 +148,14 @@ describe("exchangeAuthCode", () => {
expect(url).toBe("http://test.kinde.com/oauth2/token");
expect(options).toMatchObject({
method: "POST",
headers: {
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
},
});
expect((options?.headers as Headers).get("Content-type")).toEqual(
"application/x-www-form-urlencoded; charset=UTF-8",
);
expect((options?.headers as Headers).get("Cache-Control")).toEqual(
"no-store",
);
expect((options?.headers as Headers).get("Pragma")).toEqual("no-cache");
});

it("set the framework and version on header", async () => {
Expand Down Expand Up @@ -173,6 +183,7 @@ describe("exchangeAuthCode", () => {
access_token: "access_token",
refresh_token: "refresh_token",
id_token: "id_token",
expires_in: 3600,
}),
);

Expand All @@ -188,11 +199,10 @@ describe("exchangeAuthCode", () => {
expect(url).toBe("http://test.kinde.com/oauth2/token");
expect(options).toMatchObject({
method: "POST",
headers: {
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"Kinde-SDK": "Framework/Version",
},
});
expect((options?.headers as Headers).get("Kinde-SDK")).toEqual(
"Framework/Version",
);
});

it("should handle token exchange failure", async () => {
Expand Down Expand Up @@ -226,4 +236,50 @@ describe("exchangeAuthCode", () => {
error: "Token exchange failed: 500 - error",
});
});

it("should set the refresh timer", async () => {
const store = new MemoryStorage();
setActiveStorage(store);

const state = "state";

await store.setItems({
[StorageKeys.state]: state,
});

frameworkSettings.framework = "Framework";
frameworkSettings.frameworkVersion = "Version";

const input = "hello";

const urlParams = new URLSearchParams();
urlParams.append("code", input);
urlParams.append("state", state);
urlParams.append("client_id", "test");

fetchMock.mockResponseOnce(
JSON.stringify({
access_token: "access_token",
refresh_token: "refresh_token",
id_token: "id_token",
expires_in: 3600,
}),
);

await exchangeAuthCode({
urlParams,
domain: "http://test.kinde.com",
clientId: "test",
redirectURL: "http://test.kinde.com",
autoRefresh: true,
});

expect(refreshTokenTimer.setRefreshTimer).toHaveBeenCalledOnce();
expect(refreshTokenTimer.setRefreshTimer).toHaveBeenCalledWith(
3600,
expect.any(Function),
);
vi.advanceTimersByTime(3600 * 1000);
expect(main.refreshToken).toHaveBeenCalledTimes(1);
});
});
33 changes: 27 additions & 6 deletions lib/utils/exchangeAuthCode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { getActiveStorage, StorageKeys } from "../main";
import {
getActiveStorage,
getInsecureStorage,
refreshToken,
StorageKeys,
} from "../main";
import { clearRefreshTimer, setRefreshTimer } from "./refreshTimer";

export const frameworkSettings: {
framework: string;
Expand All @@ -13,6 +19,7 @@ interface ExchangeAuthCodeParams {
domain: string;
clientId: string;
redirectURL: string;
autoRefresh?: boolean;
}

interface ExchangeAuthCodeResult {
Expand All @@ -28,6 +35,7 @@ export const exchangeAuthCode = async ({
domain,
clientId,
redirectURL,
autoRefresh = false,
}: ExchangeAuthCodeParams): Promise<ExchangeAuthCodeResult> => {
const state = urlParams.get("state");
const code = urlParams.get("code");
Expand All @@ -40,7 +48,7 @@ export const exchangeAuthCode = async ({
};
}

const activeStorage = getActiveStorage();
const activeStorage = getInsecureStorage();
if (!activeStorage) {
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
console.error("No active storage found");
return {
Expand Down Expand Up @@ -88,8 +96,8 @@ export const exchangeAuthCode = async ({
const response = await fetch(`${domain}/oauth2/token`, {
method: "POST",
// ...(isUseCookie && {credentials: 'include'}),
credentials: "include",
headers,
// credentials: "include",
headers: new Headers(headers),
body: new URLSearchParams({
client_id: clientId,
code,
Expand All @@ -106,20 +114,33 @@ export const exchangeAuthCode = async ({
error: `Token exchange failed: ${response.status} - ${errorText}`,
};
}
clearRefreshTimer();

const data: {
access_token: string;
id_token: string;
refresh_token: string;
expires_in: number;
} = await response.json();

activeStorage.setItems({
const secureStore = getActiveStorage();
secureStore!.setItems({
[StorageKeys.accessToken]: data.access_token,
[StorageKeys.idToken]: data.id_token,
[StorageKeys.refreshToken]: data.refresh_token,
});

await activeStorage.removeItems(StorageKeys.state, StorageKeys.codeVerifier);
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
if (autoRefresh) {
setRefreshTimer(data.expires_in, async () => {
refreshToken(domain, clientId);
});
}

DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
await activeStorage.removeItems(
StorageKeys.state,
StorageKeys.nonce,
StorageKeys.codeVerifier,
);

// Clear all url params
const cleanUrl = (url: URL): URL => {
Expand Down
6 changes: 3 additions & 3 deletions lib/utils/generateAuthUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("generateAuthUrl", () => {
expect(nonce!.length).toBe(16);
result.url.searchParams.delete("nonce");
const codeChallenge = result.url.searchParams.get("code_challenge");
expect(codeChallenge!.length).toBeGreaterThan(43);
expect(codeChallenge!.length).toBeGreaterThanOrEqual(27);
result.url.searchParams.delete("code_challenge");
expect(result.url.toString()).toBe(expectedUrl);
});
Expand Down Expand Up @@ -88,7 +88,7 @@ describe("generateAuthUrl", () => {
result.url.searchParams.delete("nonce");

const codeChallenge = result.url.searchParams.get("code_challenge");
expect(codeChallenge!.length).toBeGreaterThan(43);
expect(codeChallenge!.length).toBeGreaterThanOrEqual(27);
result.url.searchParams.delete("code_challenge");

expect(result.url.toString()).toBe(expectedUrl);
Expand Down Expand Up @@ -117,7 +117,7 @@ describe("generateAuthUrl", () => {
expect(state).not.toBeNull();
expect(state!.length).toBe(32);
const codeChallenge = result.url.searchParams.get("code_challenge");
expect(codeChallenge!.length).toBeGreaterThan(43);
expect(codeChallenge!.length).toBeGreaterThanOrEqual(27);
result.url.searchParams.delete("code_challenge");
result.url.searchParams.delete("nonce");
result.url.searchParams.delete("state");
Expand Down
20 changes: 12 additions & 8 deletions lib/utils/generateAuthUrl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { base64UrlEncode, getActiveStorage, StorageKeys } from "../main";
import { base64UrlEncode, getInsecureStorage, StorageKeys } from "../main";
import { IssuerRouteTypes, LoginOptions } from "../types";
import { generateRandomString } from "./generateRandomString";
import { mapLoginMethodParamsForUrl } from "./mapLoginMethodParamsForUrl";
Expand All @@ -13,9 +13,14 @@ export const generateAuthUrl = async (
domain: string,
type: IssuerRouteTypes = IssuerRouteTypes.login,
options: LoginOptions,
): Promise<{ url: URL; state: string; nonce: string }> => {
): Promise<{
url: URL;
state: string;
nonce: string;
codeChallenge: string;
}> => {
const authUrl = new URL(`${domain}/oauth2/auth`);
const activeStorage = getActiveStorage();
const activeStorage = getInsecureStorage();
const searchParams: Record<string, string> = {
client_id: options.clientId,
response_type: options.responseType || "code",
Expand Down Expand Up @@ -59,18 +64,17 @@ export const generateAuthUrl = async (
url: authUrl,
state: searchParams["state"],
nonce: searchParams["nonce"],
codeChallenge: searchParams["code_challenge"],
};
};

async function generatePKCEPair(): Promise<{
export async function generatePKCEPair(): Promise<{
codeVerifier: string;
codeChallenge: string;
}> {
const codeVerifier = generateRandomString(43);
const codeVerifier = generateRandomString(52);
const data = new TextEncoder().encode(codeVerifier);
const hashed = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashed));
const hashString = hashArray.map((b) => String.fromCharCode(b)).join("");
const codeChallenge = base64UrlEncode(hashString);
const codeChallenge = base64UrlEncode(hashed);
return { codeVerifier, codeChallenge };
}
4 changes: 2 additions & 2 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { base64UrlEncode } from "./base64UrlEncode";
import { generateRandomString } from "./generateRandomString";
import { extractAuthResults } from "./extractAuthResults";
import { sanatizeURL } from "./sanatizeUrl";
import { sanitizeUrl } from "./sanitizeUrl";
import { generateAuthUrl } from "./generateAuthUrl";
import { mapLoginMethodParamsForUrl } from "./mapLoginMethodParamsForUrl";
import { exchangeAuthCode, frameworkSettings } from "./exchangeAuthCode";
Expand All @@ -15,7 +15,7 @@ export {
base64UrlEncode,
generateRandomString,
extractAuthResults,
sanatizeURL,
sanitizeUrl,
generateAuthUrl,
mapLoginMethodParamsForUrl,
exchangeAuthCode,
Expand Down
4 changes: 2 additions & 2 deletions lib/utils/mapLoginMethodParamsForUrl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LoginMethodParams } from "../types";
import { sanatizeURL } from "./sanatizeUrl";
import { sanitizeUrl } from "./sanitizeUrl";

export const mapLoginMethodParamsForUrl = (
options: Partial<LoginMethodParams>,
Expand All @@ -9,7 +9,7 @@ export const mapLoginMethodParamsForUrl = (
is_create_org: options.isCreateOrg?.toString(),
connection_id: options.connectionId,
redirect_uri: options.redirectURL
? sanatizeURL(options.redirectURL)
? sanitizeUrl(options.redirectURL)
: undefined,
audience: options.audience || "",
scope: options.scope?.join(" ") || "email profile openid offline",
Expand Down
Loading
Loading