From 9309420c59b7d617f639efbb3edcd98377b57f13 Mon Sep 17 00:00:00 2001 From: Scott Trinh Date: Fri, 17 Nov 2023 09:16:46 -0500 Subject: [PATCH] Use Web-compatible crypto for PKCE (#783) --- packages/auth-core/src/core.ts | 29 ++++++++-------- packages/auth-core/src/crypto.ts | 48 ++++++++++++++++++++++++++ packages/auth-core/src/pkce.ts | 13 +++---- packages/auth-core/test/crypto.test.ts | 33 ++++++++++++++++++ packages/auth-nextjs/src/app/index.ts | 8 +++-- 5 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 packages/auth-core/src/crypto.ts create mode 100644 packages/auth-core/test/crypto.test.ts diff --git a/packages/auth-core/src/core.ts b/packages/auth-core/src/core.ts index af28bdf15..30a6d275d 100644 --- a/packages/auth-core/src/core.ts +++ b/packages/auth-core/src/core.ts @@ -1,9 +1,12 @@ import jwtDecode from "jwt-decode"; import * as edgedb from "edgedb"; -import { ResolvedConnectConfig } from "edgedb/dist/conUtils"; +import { type ResolvedConnectConfig } from "edgedb/dist/conUtils"; import * as pkce from "./pkce"; -import { BuiltinOAuthProviderNames, emailPasswordProviderName } from "./consts"; +import { + type BuiltinOAuthProviderNames, + emailPasswordProviderName, +} from "./consts"; export interface TokenData { auth_token: string; @@ -73,8 +76,9 @@ export class Auth { return null as any; } - createPKCESession() { - return new AuthPCKESession(this); + async createPKCESession() { + const { challenge, verifier } = await pkce.createVerifierChallengePair(); + return new AuthPCKESession(this, challenge, verifier); } getToken(code: string, verifier: string): Promise { @@ -87,7 +91,7 @@ export class Auth { } async signinWithEmailPassword(email: string, password: string) { - const { challenge, verifier } = pkce.createVerifierChallengePair(); + const { challenge, verifier } = await pkce.createVerifierChallengePair(); const { code } = await this._post<{ code: string }>("authenticate", { provider: emailPasswordProviderName, challenge, @@ -105,7 +109,7 @@ export class Auth { | { status: "complete"; tokenData: TokenData } | { status: "verificationRequired"; verifier: string } > { - const { challenge, verifier } = pkce.createVerifierChallengePair(); + const { challenge, verifier } = await pkce.createVerifierChallengePair(); const result = await this._post< { code: string } | { verification_email_sent_at: string } >("register", { @@ -200,14 +204,11 @@ export class Auth { } export class AuthPCKESession { - public readonly challenge: string; - public readonly verifier: string; - - constructor(private auth: Auth) { - const { challenge, verifier } = pkce.createVerifierChallengePair(); - this.challenge = challenge; - this.verifier = verifier; - } + constructor( + private auth: Auth, + public readonly challenge: string, + public readonly verifier: string + ) {} getOAuthUrl( providerName: BuiltinOAuthProviderNames, diff --git a/packages/auth-core/src/crypto.ts b/packages/auth-core/src/crypto.ts new file mode 100644 index 000000000..d18a7c88d --- /dev/null +++ b/packages/auth-core/src/crypto.ts @@ -0,0 +1,48 @@ +/* eslint @typescript-eslint/no-var-requires: ["off"] */ + +// TODO: Drop when Node 18 is EOL: 2025-04-30 +if (!globalThis.crypto) { + // tslint:disable-next-line: no-var-requires + globalThis.crypto = require("node:crypto").webcrypto; +} + +const BASE64_URL_CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +export function bytesToBase64Url(bytes: Uint8Array): string { + const len = bytes.length; + let base64url = ""; + + for (let i = 0; i < len; i += 3) { + const b1 = bytes[i] & 0xff; + const b2 = i + 1 < len ? bytes[i + 1] & 0xff : 0; + const b3 = i + 2 < len ? bytes[i + 2] & 0xff : 0; + + const enc1 = b1 >> 2; + const enc2 = ((b1 & 0x03) << 4) | (b2 >> 4); + const enc3 = ((b2 & 0x0f) << 2) | (b3 >> 6); + const enc4 = b3 & 0x3f; + + base64url += BASE64_URL_CHARS.charAt(enc1) + BASE64_URL_CHARS.charAt(enc2); + if (i + 1 < len) { + base64url += BASE64_URL_CHARS.charAt(enc3); + } + if (i + 2 < len) { + base64url += BASE64_URL_CHARS.charAt(enc4); + } + } + + return base64url; +} + +export async function sha256( + source: BufferSource | string +): Promise { + const bytes = + typeof source === "string" ? new TextEncoder().encode(source) : source; + return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); +} + +export function randomBytes(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)); +} diff --git a/packages/auth-core/src/pkce.ts b/packages/auth-core/src/pkce.ts index 31e0bb61c..656b05fb4 100644 --- a/packages/auth-core/src/pkce.ts +++ b/packages/auth-core/src/pkce.ts @@ -1,14 +1,11 @@ -import crypto from "node:crypto"; +import { bytesToBase64Url, sha256, randomBytes } from "./crypto"; -export function createVerifierChallengePair(): { +export async function createVerifierChallengePair(): Promise<{ verifier: string; challenge: string; -} { - const verifier = crypto.randomBytes(32).toString("base64url"); - const challenge = crypto - .createHash("sha256") - .update(verifier) - .digest("base64url"); +}> { + const verifier = bytesToBase64Url(randomBytes(32)); + const challenge = await sha256(verifier).then(bytesToBase64Url); return { verifier, challenge }; } diff --git a/packages/auth-core/test/crypto.test.ts b/packages/auth-core/test/crypto.test.ts new file mode 100644 index 000000000..8f38a1547 --- /dev/null +++ b/packages/auth-core/test/crypto.test.ts @@ -0,0 +1,33 @@ +import crypto from "node:crypto"; +import { bytesToBase64Url, sha256, randomBytes } from "../src/crypto"; + +describe("crypto", () => { + describe("bytesToBase64Url", () => { + test("Equivalent to Buffer implementation", () => { + for (let i = 0; i < 100; i++) { + const buffer = crypto.randomBytes(32); + expect(buffer.toString("base64url")).toEqual(bytesToBase64Url(buffer)); + } + }); + }); + + describe("sha256", () => { + test("Equivalent to Node crypto SHA256 implementation", async () => { + for (let i = 0; i < 100; i++) { + const buffer = crypto.randomBytes(32); + expect(crypto.createHash("sha256").update(buffer).digest()).toEqual( + Buffer.from(await sha256(buffer)) + ); + } + }); + }); + + describe("randomBytes", () => { + test("Generates Uint8Array of correct length", () => { + const length = 32; + const bytes = randomBytes(length); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toEqual(length); + }); + }); +}); diff --git a/packages/auth-nextjs/src/app/index.ts b/packages/auth-nextjs/src/app/index.ts index 8f46141c9..a7a62ea16 100644 --- a/packages/auth-nextjs/src/app/index.ts +++ b/packages/auth-nextjs/src/app/index.ts @@ -76,7 +76,9 @@ export class NextAppAuth extends NextAuth { throw new Error(`invalid provider_name: ${provider}`); } const redirectUrl = `${this._authRoute}/oauth/callback`; - const pkceSession = (await this.core).createPKCESession(); + const pkceSession = await this.core.then((core) => + core.createPKCESession() + ); cookies().set({ name: this.options.pkceVerifierCookieName, value: pkceSession.verifier, @@ -238,7 +240,9 @@ export class NextAppAuth extends NextAuth { } case "builtin/signin": case "builtin/signup": { - const pkceSession = (await this.core).createPKCESession(); + const pkceSession = await this.core.then((core) => + core.createPKCESession() + ); cookies().set({ name: this.options.pkceVerifierCookieName, value: pkceSession.verifier,