Skip to content

Commit 66390b6

Browse files
authored
Merge pull request #11 from kinde-oss/feat/refreshToken
feat: add isAuthenticated and refreshToken functions
2 parents 4011bb6 + 4b0efcf commit 66390b6

20 files changed

+721
-46
lines changed

lib/main.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ describe("index exports", () => {
3838
"generateAuthUrl",
3939
"generateRandomString",
4040
"mapLoginMethodParamsForUrl",
41-
"sanatizeURL",
41+
"sanitizeUrl",
4242
"exchangeAuthCode",
43+
"isAuthenticated",
44+
"refreshToken",
4345

4446
// session manager
4547
"MemoryStorage",
@@ -52,6 +54,10 @@ describe("index exports", () => {
5254
"getActiveStorage",
5355
"hasActiveStorage",
5456
"clearActiveStorage",
57+
"clearInsecureStorage",
58+
"getInsecureStorage",
59+
"hasInsecureStorage",
60+
"setInsecureStorage",
5561
"getClaim",
5662
"getClaims",
5763
"getCurrentOrganization",

lib/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export * from "./types";
22
export * from "./utils";
33
export * from "./sessionManager";
4-
export * from "./utils/token/index.ts";
4+
export * from "./utils/token";

lib/utils/base64UrlEncode.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,16 @@ describe("base64UrlEncode", () => {
3636
const result = base64UrlEncode(input);
3737
expect(result).toBe(expectedOutput);
3838
});
39+
40+
it("should encode when passed an ArrayBuffer", () => {
41+
const buffer = new ArrayBuffer(8);
42+
const view = new Uint8Array(buffer);
43+
for (let i = 0; i < view.length; i++) {
44+
view[i] = i + 1;
45+
}
46+
47+
const expectedOutput = "AQIDBAUGBwg";
48+
const result = base64UrlEncode(buffer);
49+
expect(result).toBe(expectedOutput);
50+
});
3951
});

lib/utils/base64UrlEncode.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
* @param str String to encode
44
* @returns encoded string
55
*/
6-
export const base64UrlEncode = (str: string): string => {
6+
export const base64UrlEncode = (input: string | ArrayBuffer): string => {
7+
const toBase64Url = (str: string): string =>
8+
btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9+
10+
if (input instanceof ArrayBuffer) {
11+
const uint8Array = new Uint8Array(input);
12+
const binaryString = String.fromCharCode(...uint8Array);
13+
return toBase64Url(binaryString);
14+
}
15+
716
const encoder = new TextEncoder();
8-
const uintArray = encoder.encode(str);
9-
const charArray = Array.from(uintArray);
10-
return btoa(String.fromCharCode.apply(null, charArray))
11-
.replace(/\+/g, "-")
12-
.replace(/\//g, "_")
13-
.replace(/=+$/, "");
17+
const uint8Array = encoder.encode(input);
18+
const binaryString = String.fromCharCode(...uint8Array);
19+
return toBase64Url(binaryString);
1420
};

lib/utils/exchangeAuthCode.test.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@ import { MemoryStorage, StorageKeys } from "../sessionManager";
44
import { setActiveStorage } from "./token";
55
import createFetchMock from "vitest-fetch-mock";
66
import { frameworkSettings } from "./exchangeAuthCode";
7+
import * as refreshTokenTimer from "./refreshTimer";
8+
import * as main from "../main";
79

810
const fetchMock = createFetchMock(vi);
911

1012
describe("exchangeAuthCode", () => {
1113
beforeEach(() => {
1214
fetchMock.enableMocks();
15+
vi.spyOn(refreshTokenTimer, "setRefreshTimer");
16+
vi.spyOn(main, "refreshToken");
17+
vi.useFakeTimers();
1318
});
1419

1520
afterEach(() => {
1621
fetchMock.resetMocks();
22+
vi.useRealTimers();
1723
});
1824

1925
it("missing state param", async () => {
@@ -142,10 +148,14 @@ describe("exchangeAuthCode", () => {
142148
expect(url).toBe("http://test.kinde.com/oauth2/token");
143149
expect(options).toMatchObject({
144150
method: "POST",
145-
headers: {
146-
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
147-
},
148151
});
152+
expect((options?.headers as Headers).get("Content-type")).toEqual(
153+
"application/x-www-form-urlencoded; charset=UTF-8",
154+
);
155+
expect((options?.headers as Headers).get("Cache-Control")).toEqual(
156+
"no-store",
157+
);
158+
expect((options?.headers as Headers).get("Pragma")).toEqual("no-cache");
149159
});
150160

151161
it("set the framework and version on header", async () => {
@@ -173,6 +183,7 @@ describe("exchangeAuthCode", () => {
173183
access_token: "access_token",
174184
refresh_token: "refresh_token",
175185
id_token: "id_token",
186+
expires_in: 3600,
176187
}),
177188
);
178189

@@ -188,11 +199,10 @@ describe("exchangeAuthCode", () => {
188199
expect(url).toBe("http://test.kinde.com/oauth2/token");
189200
expect(options).toMatchObject({
190201
method: "POST",
191-
headers: {
192-
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
193-
"Kinde-SDK": "Framework/Version",
194-
},
195202
});
203+
expect((options?.headers as Headers).get("Kinde-SDK")).toEqual(
204+
"Framework/Version",
205+
);
196206
});
197207

198208
it("should handle token exchange failure", async () => {
@@ -226,4 +236,50 @@ describe("exchangeAuthCode", () => {
226236
error: "Token exchange failed: 500 - error",
227237
});
228238
});
239+
240+
it("should set the refresh timer", async () => {
241+
const store = new MemoryStorage();
242+
setActiveStorage(store);
243+
244+
const state = "state";
245+
246+
await store.setItems({
247+
[StorageKeys.state]: state,
248+
});
249+
250+
frameworkSettings.framework = "Framework";
251+
frameworkSettings.frameworkVersion = "Version";
252+
253+
const input = "hello";
254+
255+
const urlParams = new URLSearchParams();
256+
urlParams.append("code", input);
257+
urlParams.append("state", state);
258+
urlParams.append("client_id", "test");
259+
260+
fetchMock.mockResponseOnce(
261+
JSON.stringify({
262+
access_token: "access_token",
263+
refresh_token: "refresh_token",
264+
id_token: "id_token",
265+
expires_in: 3600,
266+
}),
267+
);
268+
269+
await exchangeAuthCode({
270+
urlParams,
271+
domain: "http://test.kinde.com",
272+
clientId: "test",
273+
redirectURL: "http://test.kinde.com",
274+
autoRefresh: true,
275+
});
276+
277+
expect(refreshTokenTimer.setRefreshTimer).toHaveBeenCalledOnce();
278+
expect(refreshTokenTimer.setRefreshTimer).toHaveBeenCalledWith(
279+
3600,
280+
expect.any(Function),
281+
);
282+
vi.advanceTimersByTime(3600 * 1000);
283+
expect(main.refreshToken).toHaveBeenCalledTimes(1);
284+
});
229285
});

lib/utils/exchangeAuthCode.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { getActiveStorage, StorageKeys } from "../main";
1+
import {
2+
getActiveStorage,
3+
getInsecureStorage,
4+
refreshToken,
5+
StorageKeys,
6+
} from "../main";
7+
import { clearRefreshTimer, setRefreshTimer } from "./refreshTimer";
28

39
export const frameworkSettings: {
410
framework: string;
@@ -13,6 +19,7 @@ interface ExchangeAuthCodeParams {
1319
domain: string;
1420
clientId: string;
1521
redirectURL: string;
22+
autoRefresh?: boolean;
1623
}
1724

1825
interface ExchangeAuthCodeResult {
@@ -28,6 +35,7 @@ export const exchangeAuthCode = async ({
2835
domain,
2936
clientId,
3037
redirectURL,
38+
autoRefresh = false,
3139
}: ExchangeAuthCodeParams): Promise<ExchangeAuthCodeResult> => {
3240
const state = urlParams.get("state");
3341
const code = urlParams.get("code");
@@ -40,7 +48,7 @@ export const exchangeAuthCode = async ({
4048
};
4149
}
4250

43-
const activeStorage = getActiveStorage();
51+
const activeStorage = getInsecureStorage();
4452
if (!activeStorage) {
4553
console.error("No active storage found");
4654
return {
@@ -88,8 +96,8 @@ export const exchangeAuthCode = async ({
8896
const response = await fetch(`${domain}/oauth2/token`, {
8997
method: "POST",
9098
// ...(isUseCookie && {credentials: 'include'}),
91-
credentials: "include",
92-
headers,
99+
// credentials: "include",
100+
headers: new Headers(headers),
93101
body: new URLSearchParams({
94102
client_id: clientId,
95103
code,
@@ -106,20 +114,33 @@ export const exchangeAuthCode = async ({
106114
error: `Token exchange failed: ${response.status} - ${errorText}`,
107115
};
108116
}
117+
clearRefreshTimer();
109118

110119
const data: {
111120
access_token: string;
112121
id_token: string;
113122
refresh_token: string;
123+
expires_in: number;
114124
} = await response.json();
115125

116-
activeStorage.setItems({
126+
const secureStore = getActiveStorage();
127+
secureStore!.setItems({
117128
[StorageKeys.accessToken]: data.access_token,
118129
[StorageKeys.idToken]: data.id_token,
119130
[StorageKeys.refreshToken]: data.refresh_token,
120131
});
121132

122-
await activeStorage.removeItems(StorageKeys.state, StorageKeys.codeVerifier);
133+
if (autoRefresh) {
134+
setRefreshTimer(data.expires_in, async () => {
135+
refreshToken(domain, clientId);
136+
});
137+
}
138+
139+
await activeStorage.removeItems(
140+
StorageKeys.state,
141+
StorageKeys.nonce,
142+
StorageKeys.codeVerifier,
143+
);
123144

124145
// Clear all url params
125146
const cleanUrl = (url: URL): URL => {

lib/utils/generateAuthUrl.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe("generateAuthUrl", () => {
3232
expect(nonce!.length).toBe(16);
3333
result.url.searchParams.delete("nonce");
3434
const codeChallenge = result.url.searchParams.get("code_challenge");
35-
expect(codeChallenge!.length).toBeGreaterThan(43);
35+
expect(codeChallenge!.length).toBeGreaterThanOrEqual(27);
3636
result.url.searchParams.delete("code_challenge");
3737
expect(result.url.toString()).toBe(expectedUrl);
3838
});
@@ -88,7 +88,7 @@ describe("generateAuthUrl", () => {
8888
result.url.searchParams.delete("nonce");
8989

9090
const codeChallenge = result.url.searchParams.get("code_challenge");
91-
expect(codeChallenge!.length).toBeGreaterThan(43);
91+
expect(codeChallenge!.length).toBeGreaterThanOrEqual(27);
9292
result.url.searchParams.delete("code_challenge");
9393

9494
expect(result.url.toString()).toBe(expectedUrl);
@@ -117,7 +117,7 @@ describe("generateAuthUrl", () => {
117117
expect(state).not.toBeNull();
118118
expect(state!.length).toBe(32);
119119
const codeChallenge = result.url.searchParams.get("code_challenge");
120-
expect(codeChallenge!.length).toBeGreaterThan(43);
120+
expect(codeChallenge!.length).toBeGreaterThanOrEqual(27);
121121
result.url.searchParams.delete("code_challenge");
122122
result.url.searchParams.delete("nonce");
123123
result.url.searchParams.delete("state");

lib/utils/generateAuthUrl.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { base64UrlEncode, getActiveStorage, StorageKeys } from "../main";
1+
import { base64UrlEncode, getInsecureStorage, StorageKeys } from "../main";
22
import { IssuerRouteTypes, LoginOptions } from "../types";
33
import { generateRandomString } from "./generateRandomString";
44
import { mapLoginMethodParamsForUrl } from "./mapLoginMethodParamsForUrl";
@@ -13,9 +13,14 @@ export const generateAuthUrl = async (
1313
domain: string,
1414
type: IssuerRouteTypes = IssuerRouteTypes.login,
1515
options: LoginOptions,
16-
): Promise<{ url: URL; state: string; nonce: string }> => {
16+
): Promise<{
17+
url: URL;
18+
state: string;
19+
nonce: string;
20+
codeChallenge: string;
21+
}> => {
1722
const authUrl = new URL(`${domain}/oauth2/auth`);
18-
const activeStorage = getActiveStorage();
23+
const activeStorage = getInsecureStorage();
1924
const searchParams: Record<string, string> = {
2025
client_id: options.clientId,
2126
response_type: options.responseType || "code",
@@ -59,18 +64,17 @@ export const generateAuthUrl = async (
5964
url: authUrl,
6065
state: searchParams["state"],
6166
nonce: searchParams["nonce"],
67+
codeChallenge: searchParams["code_challenge"],
6268
};
6369
};
6470

65-
async function generatePKCEPair(): Promise<{
71+
export async function generatePKCEPair(): Promise<{
6672
codeVerifier: string;
6773
codeChallenge: string;
6874
}> {
69-
const codeVerifier = generateRandomString(43);
75+
const codeVerifier = generateRandomString(52);
7076
const data = new TextEncoder().encode(codeVerifier);
7177
const hashed = await crypto.subtle.digest("SHA-256", data);
72-
const hashArray = Array.from(new Uint8Array(hashed));
73-
const hashString = hashArray.map((b) => String.fromCharCode(b)).join("");
74-
const codeChallenge = base64UrlEncode(hashString);
78+
const codeChallenge = base64UrlEncode(hashed);
7579
return { codeVerifier, codeChallenge };
7680
}

lib/utils/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { base64UrlEncode } from "./base64UrlEncode";
33
import { generateRandomString } from "./generateRandomString";
44
import { extractAuthResults } from "./extractAuthResults";
5-
import { sanatizeURL } from "./sanatizeUrl";
5+
import { sanitizeUrl } from "./sanitizeUrl";
66
import { generateAuthUrl } from "./generateAuthUrl";
77
import { mapLoginMethodParamsForUrl } from "./mapLoginMethodParamsForUrl";
88
import { exchangeAuthCode, frameworkSettings } from "./exchangeAuthCode";
@@ -15,7 +15,7 @@ export {
1515
base64UrlEncode,
1616
generateRandomString,
1717
extractAuthResults,
18-
sanatizeURL,
18+
sanitizeUrl,
1919
generateAuthUrl,
2020
mapLoginMethodParamsForUrl,
2121
exchangeAuthCode,

lib/utils/mapLoginMethodParamsForUrl.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { LoginMethodParams } from "../types";
2-
import { sanatizeURL } from "./sanatizeUrl";
2+
import { sanitizeUrl } from "./sanitizeUrl";
33

44
export const mapLoginMethodParamsForUrl = (
55
options: Partial<LoginMethodParams>,
@@ -9,7 +9,7 @@ export const mapLoginMethodParamsForUrl = (
99
is_create_org: options.isCreateOrg?.toString(),
1010
connection_id: options.connectionId,
1111
redirect_uri: options.redirectURL
12-
? sanatizeURL(options.redirectURL)
12+
? sanitizeUrl(options.redirectURL)
1313
: undefined,
1414
audience: options.audience || "",
1515
scope: options.scope?.join(" ") || "email profile openid offline",

0 commit comments

Comments
 (0)