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

fix: codeExchange, storage updates, generateAuthUrl fixes #15

Merged
merged 30 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e0e2422
fix: couple of small updates to generateAuthUrl, including auto setti…
DanielRivers Oct 24, 2024
3c00453
fix: remove exception when no state
DanielRivers Oct 25, 2024
c110df8
tests: fix tests
DanielRivers Oct 25, 2024
d8313d7
test: fix tests
DanielRivers Oct 25, 2024
b4fde7a
Merge branch 'main' into fix/generateAuthUrl
DanielRivers Oct 25, 2024
19ccb41
test: fix tests
DanielRivers Oct 25, 2024
8daa59d
feat: added hasActiveStorage and JSDocs
DanielRivers Oct 25, 2024
78a96b6
BREAKING CHANGE: Make generateAuthUrl async
DanielRivers Oct 25, 2024
3658bc4
fix: PR updates
DanielRivers Oct 25, 2024
05e7203
fix: add missing StorageKey
DanielRivers Oct 25, 2024
6e2399f
feat: add clearActiveStorage and tests
DanielRivers Oct 28, 2024
bd82a91
chore: lint
DanielRivers Oct 28, 2024
f8f605a
chore: clean up tests
DanielRivers Oct 28, 2024
7c649b4
fix: extend code verifier
DanielRivers Oct 28, 2024
8c9474c
feat: add remove multiple items support from store
DanielRivers Oct 28, 2024
eb514ce
feat: add exchangeAuthCode method
DanielRivers Oct 28, 2024
912a7cb
chore: lint and remove only from tests
DanielRivers Oct 28, 2024
b33533e
chore: update lock
DanielRivers Oct 28, 2024
5ba4864
chore: remove incomplete store.
DanielRivers Oct 28, 2024
62ebed6
feat: added framework settings config and expanded tests
DanielRivers Oct 29, 2024
3243c4e
test: update tests
DanielRivers Oct 29, 2024
eff28e6
chore: lint
DanielRivers Oct 29, 2024
7c866c1
chore: update test config
DanielRivers Oct 29, 2024
c78f029
feat: improve splitString
DanielRivers Oct 30, 2024
4f0e74b
fix: await removing storage items
DanielRivers Oct 31, 2024
d033a0d
feat: add handling bad responses from token endpoint
DanielRivers Oct 31, 2024
00a554f
fix: error handling improvements and improved security
DanielRivers Oct 31, 2024
b6877c0
test: minor updates
DanielRivers Oct 31, 2024
e0d39fd
fix: preseve browser state
DanielRivers Oct 31, 2024
034021b
chore: remove redundant import
DanielRivers Oct 31, 2024
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
1 change: 1 addition & 0 deletions lib/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe("index exports", () => {

// token utils
"getActiveStorage",
"hasActiveStorage",
"getClaim",
"getClaims",
"getCurrentOrganization",
Expand Down
52 changes: 40 additions & 12 deletions lib/utils/generateAuthUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { IssuerRouteTypes, LoginOptions, Scopes } from "../types";
import { generateAuthUrl } from "./generateAuthUrl";

describe("generateAuthUrl", () => {
it("should generate the correct auth URL with required parameters", () => {
it("should generate the correct auth URL with required parameters", async () => {
const domain = "https://auth.example.com";
const options: LoginOptions = {
clientId: "client123",
Expand All @@ -18,17 +18,24 @@ describe("generateAuthUrl", () => {
state: "state123",
};
const expectedUrl =
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&login_hint=user%40example.com&is_create_org=true&connection_id=conn123&redirect_uri=https%3A%2F%2Fexample.com&audience=audience123&scope=openid+profile&prompt=login&state=state123";
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&login_hint=user%40example.com&is_create_org=true&connection_id=conn123&redirect_uri=https%3A%2F%2Fexample.com&audience=audience123&scope=openid+profile&prompt=login&state=state123&code_challenge_method=S256";

const result = generateAuthUrl(domain, IssuerRouteTypes.login, options);
const result = await generateAuthUrl(
domain,
IssuerRouteTypes.login,
options,
);
const nonce = result.url.searchParams.get("nonce");
expect(nonce).not.toBeNull();
expect(nonce!.length).toBe(16);
result.url.searchParams.delete("nonce");
const codeChallenge = result.url.searchParams.get("code_challenge");
expect(codeChallenge!.length).toBeGreaterThan(32);
result.url.searchParams.delete("code_challenge");
expect(result.url.toString()).toBe(expectedUrl);
});

it("should include optional parameters if provided", () => {
it("should include optional parameters if provided", async () => {
const domain = "https://auth.example.com";
const options: LoginOptions = {
clientId: "client123",
Expand All @@ -41,17 +48,22 @@ describe("generateAuthUrl", () => {
prompt: "create",
};
const expectedUrl =
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&scope=openid+profile&prompt=create&state=state123&code_challenge=challenge123&code_challenge_method=S256";
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&audience=&scope=openid+profile&prompt=create&state=state123&code_challenge=challenge123&code_challenge_method=S256";

const result = generateAuthUrl(domain, IssuerRouteTypes.login, options);
const result = await generateAuthUrl(
domain,
IssuerRouteTypes.login,
options,
);
const nonce = result.url.searchParams.get("nonce");
expect(nonce).not.toBeNull();
expect(nonce!.length).toBe(16);
result.url.searchParams.delete("nonce");

expect(result.url.toString()).toBe(expectedUrl);
});

it("should handle default responseType if not provided", () => {
it("should handle default responseType if not provided", async () => {
const domain = "https://auth.example.com";
const options: LoginOptions = {
clientId: "client123",
Expand All @@ -61,17 +73,26 @@ describe("generateAuthUrl", () => {
state: "state123",
};
const expectedUrl =
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&scope=openid+profile+offline&prompt=create&state=state123";
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&audience=&scope=openid+profile+offline&prompt=create&state=state123&code_challenge_method=S256";

const result = generateAuthUrl(domain, IssuerRouteTypes.login, options);
const result = await generateAuthUrl(
domain,
IssuerRouteTypes.login,
options,
);
const nonce = result.url.searchParams.get("nonce");
expect(nonce).not.toBeNull();
expect(nonce!.length).toBe(16);
result.url.searchParams.delete("nonce");

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

expect(result.url.toString()).toBe(expectedUrl);
});

it("should handle default responseType if not provided", () => {
it("should handle default responseType if not provided", async () => {
const domain = "https://auth.example.com";
const options: LoginOptions = {
clientId: "client123",
Expand All @@ -80,15 +101,22 @@ describe("generateAuthUrl", () => {
prompt: "create",
};
const expectedUrl =
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&scope=openid+profile+offline&prompt=create";
"https://auth.example.com/oauth2/auth?client_id=client123&response_type=code&start_page=login&redirect_uri=https%3A%2F%2Fexample2.com&audience=&scope=openid+profile+offline&prompt=create&code_challenge_method=S256";
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved

const result = generateAuthUrl(domain, IssuerRouteTypes.login, options);
const result = await generateAuthUrl(
domain,
IssuerRouteTypes.login,
options,
);
const nonce = result.url.searchParams.get("nonce");
expect(nonce).not.toBeNull();
expect(nonce!.length).toBe(16);
const state = result.url.searchParams.get("state");
expect(state).not.toBeNull();
expect(state!.length).toBe(32);
const codeChallenge = result.url.searchParams.get("code_challenge");
expect(codeChallenge!.length).toBeGreaterThan(32);
result.url.searchParams.delete("code_challenge");
result.url.searchParams.delete("nonce");
result.url.searchParams.delete("state");
expect(result.url.toString()).toBe(expectedUrl);
Expand Down
32 changes: 28 additions & 4 deletions lib/utils/generateAuthUrl.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { base64UrlEncode, getActiveStorage, StorageKeys } from "../main";
import { IssuerRouteTypes, LoginOptions } from "../types";
import { generateRandomString } from "./generateRandomString";
import { mapLoginMethodParamsForUrl } from "./mapLoginMethodParamsForUrl";
Expand All @@ -8,13 +9,13 @@
* @param type
* @returns URL to redirect to
*/
export const generateAuthUrl = (
export const generateAuthUrl = async (
domain: string,
type: IssuerRouteTypes = IssuerRouteTypes.login,
options: LoginOptions,
): { url: URL; state: string; nonce: string } => {
): Promise<{ url: URL; state: string; nonce: string }> => {
const authUrl = new URL(`${domain}/oauth2/auth`);

const activeStorage = getActiveStorage();
const searchParams: Record<string, string> = {
client_id: options.clientId,
response_type: options.responseType || "code",
Expand All @@ -24,18 +25,30 @@

if (!options.state) {
options.state = generateRandomString(32);
if (activeStorage) {
activeStorage.setSessionItem(StorageKeys.state, options.state);
}
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
}
searchParams["state"] = options.state;

if (!options.nonce) {
options.nonce = generateRandomString(16);
}
searchParams["nonce"] = options.nonce;
if (activeStorage) {
activeStorage.setSessionItem(StorageKeys.nonce, options.nonce);
}

if (options.codeChallenge) {
searchParams["code_challenge"] = options.codeChallenge;
searchParams["code_challenge_method"] = "S256";
} else {
const { codeVerifier, codeChallenge } = await generatePKCEPair();
if (activeStorage) {
activeStorage.setSessionItem(StorageKeys.codeVerifier, codeVerifier);

Check failure on line 47 in lib/utils/generateAuthUrl.ts

View workflow job for this annotation

GitHub Actions / main (20.x)

Property 'codeVerifier' does not exist on type 'typeof StorageKeys'.
}
searchParams["code_challenge"] = codeChallenge;
}
searchParams["code_challenge_method"] = "S256";

if (options.codeChallengeMethod) {
searchParams["code_challenge_method"] = options.codeChallengeMethod;
Expand All @@ -48,3 +61,14 @@
nonce: searchParams["nonce"],
};
};

async function generatePKCEPair(): Promise<{
codeVerifier: string;
codeChallenge: string;
}> {
const codeVerifier = generateRandomString(32);
const data = new TextEncoder().encode(codeVerifier);
const hashed = await crypto.subtle.digest("SHA-256", data);
const codeChallenge = base64UrlEncode(new TextDecoder().decode(hashed));
return { codeVerifier, codeChallenge };
}
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 5 additions & 0 deletions lib/utils/mapLoginMethodParamsForUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe("mapLoginMethodParamsForUrl", () => {
const expectedOutput = {
login_hint: "[email protected]",
scope: "openid",
audience: "",
};

const result = mapLoginMethodParamsForUrl(options);
Expand All @@ -60,6 +61,7 @@ describe("mapLoginMethodParamsForUrl", () => {
const expectedOutput = {
login_hint: "[email protected]",
scope: "email profile openid offline",
audience: "",
};

const result = mapLoginMethodParamsForUrl(options);
Expand All @@ -74,6 +76,7 @@ describe("mapLoginMethodParamsForUrl", () => {
const expectedOutput = {
redirect_uri: "https://example.com",
scope: "email profile openid offline",
audience: "",
};

const result = mapLoginMethodParamsForUrl(options);
Expand All @@ -90,6 +93,7 @@ describe("mapLoginMethodParamsForUrl", () => {
is_create_org: "false",
has_success_page: "false",
scope: "email profile openid offline",
audience: "",
};

const result = mapLoginMethodParamsForUrl(options);
Expand All @@ -113,6 +117,7 @@ describe("mapLoginMethodParamsForUrl", () => {

const expectedOutput = {
scope: "email profile openid offline",
audience: "",
};

const result = mapLoginMethodParamsForUrl(options);
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/mapLoginMethodParamsForUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const mapLoginMethodParamsForUrl = (
redirect_uri: options.redirectURL
? sanatizeURL(options.redirectURL)
: undefined,
audience: options.audience,
audience: options.audience || "",
scope: options.scope?.join(" ") || "email profile openid offline",
prompt: options.prompt,
lang: options.lang,
Expand Down
6 changes: 2 additions & 4 deletions lib/utils/token/getDecodedToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ import { setActiveStorage } from ".";
import { createMockAccessToken } from "./testUtils";

describe("getDecodedToken", () => {
it("error when no active storage is set", () => {
expect(() => getDecodedToken("idToken")).rejects.toThrowError(
"Session manager is not initialized",
);
it("return null when no active storage is defined", async () => {
expect(await getDecodedToken("idToken")).toBe(null);
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
});
});

Expand Down
4 changes: 4 additions & 0 deletions lib/utils/token/getDecodedToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const getDecodedToken = async <
): Promise<T | null> => {
const activeStorage = getActiveStorage();

if (!activeStorage) {
return null;
}

const token = (await activeStorage.getSessionItem(
tokenType === "accessToken" ? StorageKeys.accessToken : StorageKeys.idToken,
)) as string;
Expand Down
24 changes: 19 additions & 5 deletions lib/utils/token/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,34 @@ const storage = {
value: null as SessionManager | null,
};

/**
* Sets the active storage
* @param store Session manager instance
*/
const setActiveStorage = (store: SessionManager) => {
storage.value = store;
};

const getActiveStorage = () => {
if (!storage.value) {
throw new Error("Session manager is not initialized");
}
return storage.value;
/**
* Gets the current active storage
* @returns Session manager instance or null
*/
const getActiveStorage = (): SessionManager | null => {
return storage.value || null;
DanielRivers marked this conversation as resolved.
Show resolved Hide resolved
};

/**
* Checks if there is an active storage
* @returns boolean
*/
const hasActiveStorage = (): boolean => {
return storage.value !== null;
};

export {
setActiveStorage,
getActiveStorage,
hasActiveStorage,
getClaim,
getClaims,
getCurrentOrganization,
Expand Down
Loading