-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: adding PKCE code challenge support (#47)
* wip update * update * feat: adding PKCE code challenge support * trying to fix the build on GitHub actions * Update pull-requests.yml * Update package.json * Update pnpm-lock.yaml
- Loading branch information
Showing
9 changed files
with
624 additions
and
436 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
68 changes: 68 additions & 0 deletions
68
packages/auth-common/src/components/__tests__/pkce.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { | ||
generateCodeChallenge, | ||
pkceChallengePair, | ||
verifyChallenge, | ||
} from "../pkce"; | ||
|
||
describe("pkceChallengePair tests", () => { | ||
it("should have a default verifier length of 43", async () => { | ||
expect((await pkceChallengePair()).code_verifier.length).toBe(43); | ||
}); | ||
|
||
it("should make sure that code_verifier pattern matches", async () => { | ||
const pattern = /^[A-Za-z\d\-._~]{43,128}$/; | ||
const challengePair = await pkceChallengePair(128); | ||
expect(challengePair.code_verifier).toMatch(pattern); | ||
}); | ||
|
||
it("should make sure that code_challenge pattern doesn't have [=+/]", async () => { | ||
const challengePair = await pkceChallengePair(128); | ||
expect(challengePair.code_challenge).not.toMatch("="); | ||
expect(challengePair.code_challenge).not.toMatch("+"); | ||
expect(challengePair.code_challenge).not.toMatch("/"); | ||
}); | ||
|
||
it("should throw an error if verifier length < 43", async () => { | ||
await expect(pkceChallengePair(42)).rejects.toStrictEqual( | ||
"Expected a length between 43 and 128. Received 42.", | ||
); | ||
}); | ||
|
||
it("should throw an error if verifier length > 128", async () => { | ||
await expect(pkceChallengePair(129)).rejects.toStrictEqual( | ||
"Expected a length between 43 and 128. Received 129.", | ||
); | ||
}); | ||
}); | ||
|
||
describe("verifyChallenge tests", () => { | ||
it("should make sure that verifyChallenge returns true", async () => { | ||
const challengePair = await pkceChallengePair(); | ||
expect( | ||
await verifyChallenge( | ||
challengePair.code_verifier, | ||
challengePair.code_challenge, | ||
), | ||
).toBe(true); | ||
}); | ||
|
||
it("should make sure that verifyChallenge returns false", async () => { | ||
const challengePair = await pkceChallengePair(); | ||
expect( | ||
await verifyChallenge( | ||
challengePair.code_verifier, | ||
challengePair.code_challenge + "blah", | ||
), | ||
).toBe(false); | ||
}); | ||
}); | ||
|
||
describe("generateCodeChallenge tests", () => { | ||
test("generateChallenge should create a consistent challenge from a code_verifier", async () => { | ||
const challengePair = await pkceChallengePair(); | ||
const code_challenge = await generateCodeChallenge( | ||
challengePair.code_verifier, | ||
); | ||
expect(code_challenge).toBe(challengePair.code_challenge); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
export const AUTH_TYPES = { | ||
ID_TOKEN: "id_token", | ||
ACCESS_TOKEN: "token", | ||
ID_AND_ACCESS_TOKEN: "id_token token", | ||
}; | ||
|
||
export const HEADERS = { | ||
CLIENT_ID: "X-Auth-ClientId", | ||
}; | ||
|
||
export const JWT = { | ||
ALG: "RS256", | ||
USER_ID_KEY: "_id", | ||
TOKEN_ID_KEY: "__raw", | ||
NONCE_KEY: "_nonce", | ||
ISSUER: "gizmette.com", | ||
}; | ||
|
||
export const JWT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- | ||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsF6i3Jd9fY/3COqCw/m7 | ||
w5PKyTYLGAI2I6SIIdpe6i6DOCbEkmDz7LdVsBqwNtVi8gvWYIj+8ol6rU3qu1v5 | ||
i1Jd45GSK4kzkVdgCmQZbM5ak0KI99q5wsrAIzUd+LRJ2HRvWtr5IYdsIiXaQjle | ||
aMwPFOIcJH+rKfFgNcHLcaS5syp7zU1ANwZ+trgR+DifBr8TLVkBynmNeTyhDm2+ | ||
l0haqjMk0UoNPPE8iYBWUHQJJE1Dqstj65d6Eh5g64Pao25y4cmYJbKjiblIGEkE | ||
sjqybA9mARAqh9k/eiIopecWSiffNQTwVQVd2I9ZH3BalhEXHlqFgrjz51kFqg81 | ||
awIDAQAB | ||
-----END PUBLIC KEY-----`; | ||
|
||
export const TOKEN_EXPIRATION = { | ||
ACCESS: "5m", | ||
ID: "90d", | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,47 +1,3 @@ | ||
import * as jose from "jose"; | ||
|
||
export const AUTH_TYPES = { | ||
ID_TOKEN: "id_token", | ||
ACCESS_TOKEN: "token", | ||
ID_AND_ACCESS_TOKEN: "id_token token", | ||
}; | ||
|
||
export const HEADERS = { | ||
CLIENT_ID: "X-Auth-ClientId", | ||
}; | ||
|
||
export const JWT = { | ||
ALG: "RS256", | ||
USER_ID_KEY: "_id", | ||
TOKEN_ID_KEY: "__raw", | ||
NONCE_KEY: "_nonce", | ||
ISSUER: "gizmette.com", | ||
}; | ||
|
||
export const JWT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- | ||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsF6i3Jd9fY/3COqCw/m7 | ||
w5PKyTYLGAI2I6SIIdpe6i6DOCbEkmDz7LdVsBqwNtVi8gvWYIj+8ol6rU3qu1v5 | ||
i1Jd45GSK4kzkVdgCmQZbM5ak0KI99q5wsrAIzUd+LRJ2HRvWtr5IYdsIiXaQjle | ||
aMwPFOIcJH+rKfFgNcHLcaS5syp7zU1ANwZ+trgR+DifBr8TLVkBynmNeTyhDm2+ | ||
l0haqjMk0UoNPPE8iYBWUHQJJE1Dqstj65d6Eh5g64Pao25y4cmYJbKjiblIGEkE | ||
sjqybA9mARAqh9k/eiIopecWSiffNQTwVQVd2I9ZH3BalhEXHlqFgrjz51kFqg81 | ||
awIDAQAB | ||
-----END PUBLIC KEY-----`; | ||
|
||
export const TOKEN_EXPIRATION = { | ||
ACCESS: "5m", | ||
ID: "90d", | ||
}; | ||
|
||
export const verifyAndExtractToken = async (token: string) => { | ||
try { | ||
const alg = JWT.ALG; | ||
const spki = JWT_PUBLIC_KEY; | ||
const publicKey = await jose.importSPKI(spki, alg); | ||
return await jose.jwtVerify(token, publicKey, { | ||
issuer: JWT.ISSUER, | ||
}); | ||
} catch (_error) { | ||
return undefined; | ||
} | ||
}; | ||
export * from "./constants"; | ||
export * from "./verifyToken"; | ||
export * from "./pkce"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import { v4 as uuidv4 } from "uuid"; | ||
|
||
const crypto = globalThis.crypto; | ||
|
||
/** | ||
* Generate a PKCE challenge verifier. | ||
* | ||
* @param length Length of the verifier. | ||
* @returns A random verifier `length` characters long. | ||
*/ | ||
const generateCodeVerifier = (length: number): string => { | ||
return `${uuidv4()}${uuidv4()}`.slice(0, length); | ||
}; | ||
|
||
/** | ||
* Converts an ArrayBuffer to base64 string. | ||
* | ||
* @param val ArrayBuffer to convert. | ||
* @returns Base64 string. | ||
*/ | ||
const toBase64 = (val: ArrayBuffer): string => | ||
btoa( | ||
[...new Uint8Array(val)].map((chr) => String.fromCharCode(chr)).join(""), | ||
); | ||
|
||
/** | ||
* Generate a PKCE code challenge from a code verifier. | ||
* | ||
* @param code_verifier | ||
* @returns The base64 url encoded code challenge. | ||
*/ | ||
export async function generateCodeChallenge(code_verifier: string) { | ||
if (!crypto.subtle) { | ||
throw new Error( | ||
"crypto.subtle is available only in secure contexts (HTTPS).", | ||
); | ||
} | ||
const data = new TextEncoder().encode(code_verifier); | ||
const hashed = await crypto.subtle.digest("SHA-256", data); | ||
return toBase64(hashed) | ||
.replace(/\+/g, "-") | ||
.replace(/\//g, "_") | ||
.replace(/=+$/, ""); | ||
} | ||
|
||
/** | ||
* Generate a PKCE challenge pair. | ||
* | ||
* @param length Length of the verifier (between 43-128). Defaults to 43. | ||
* @returns PKCE challenge pair. | ||
*/ | ||
export async function pkceChallengePair(length?: number): Promise<{ | ||
code_verifier: string; | ||
code_challenge: string; | ||
}> { | ||
const actualLength = length || 43; | ||
if (actualLength < 43 || actualLength > 128) { | ||
throw `Expected a length between 43 and 128. Received ${length}.`; | ||
} | ||
const verifier = generateCodeVerifier(actualLength); | ||
const challenge = await generateCodeChallenge(verifier); | ||
return { | ||
code_verifier: verifier, | ||
code_challenge: challenge, | ||
}; | ||
} | ||
|
||
/** | ||
* Verify that a code_verifier produces the expected code challenge. | ||
* | ||
* @param code_verifier | ||
* @param expectedChallenge The code challenge to verify. | ||
* @returns True if challenges are equal. False otherwise. | ||
*/ | ||
export async function verifyChallenge( | ||
code_verifier: string, | ||
expectedChallenge: string, | ||
) { | ||
const actualChallenge = await generateCodeChallenge(code_verifier); | ||
return actualChallenge === expectedChallenge; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { importSPKI, jwtVerify } from "jose"; | ||
import { JWT, JWT_PUBLIC_KEY } from "./constants"; | ||
|
||
export const verifyAndExtractToken = async (token: string) => { | ||
try { | ||
const alg = JWT.ALG; | ||
const spki = JWT_PUBLIC_KEY; | ||
const publicKey = await importSPKI(spki, alg); | ||
return await jwtVerify(token, publicKey, { | ||
issuer: JWT.ISSUER, | ||
}); | ||
} catch (_error) { | ||
return undefined; | ||
} | ||
}; |
Oops, something went wrong.