Skip to content

Commit

Permalink
feat: adding PKCE code challenge support (#47)
Browse files Browse the repository at this point in the history
* 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
aversini authored Jun 27, 2024
1 parent 72e4486 commit 3d28250
Show file tree
Hide file tree
Showing 9 changed files with 624 additions and 436 deletions.
6 changes: 3 additions & 3 deletions .github/workflows/pull-requests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- uses: pnpm/action-setup@v4
- run: |
corepack enable
pnpm install
npx lerna run build
npx lerna run stats:pr
Expand All @@ -49,8 +49,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- uses: pnpm/action-setup@v4
- run: |
corepack enable
pnpm install
npx lerna run lint
npx lerna run build
Expand Down Expand Up @@ -84,8 +84,8 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- uses: pnpm/action-setup@v4
- run: |
corepack enable
pnpm install
npx lerna run build
npx lerna run stats:release
Expand Down
5 changes: 0 additions & 5 deletions examples/fastify-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,8 @@
"type": "module",
"node": ">=20",
"scripts": {
"build": "yarn run clean && yarn run build:types && yarn run build:js&& yarn run build:barrel",
"build:barrel": "barrelsby --delete --directory dist --pattern \"**/*.d.ts\" --name \"index.d\"",
"build:js": "swc --strip-leading-paths --source-maps --out-dir dist src --copy-files",
"build:types": "tsc",
"clean": "rimraf dist types coverage",
"dev": "nodemon dist/server.js --db tmp/auth.json",
"fix:sessions": "cross-env NODE_ENV==\"production\" node dist/doctor.js --db tmp/auth.json --sessions",
"lint": "biome lint src",
"start": "cross-env NODE_ENV==\"production\" node dist/server.js --db auth.json",
"watch": "swc --strip-leading-paths --watch --out-dir dist src"
Expand Down
11 changes: 6 additions & 5 deletions packages/auth-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"files": ["dist"],
"scripts": {
"build:check": "tsc",
"build:js": "vite build",
Expand All @@ -27,9 +25,12 @@
"dev:types": "tsup --watch src",
"dev": "npm-run-all clean --parallel dev:js dev:types",
"lint": "biome lint src",
"start": "static-server dist --port 5173"
"start": "static-server dist --port 5173",
"test:watch": "vitest",
"test": "vitest run"
},
"dependencies": {
"jose": "5.4.1"
"jose": "5.4.1",
"uuid": "10.0.0"
}
}
68 changes: 68 additions & 0 deletions packages/auth-common/src/components/__tests__/pkce.test.ts
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);
});
});
32 changes: 32 additions & 0 deletions packages/auth-common/src/components/constants.ts
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",
};
50 changes: 3 additions & 47 deletions packages/auth-common/src/components/index.ts
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";
81 changes: 81 additions & 0 deletions packages/auth-common/src/components/pkce.ts
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;
}
15 changes: 15 additions & 0 deletions packages/auth-common/src/components/verifyToken.ts
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;
}
};
Loading

0 comments on commit 3d28250

Please sign in to comment.