Skip to content

Commit

Permalink
Use Web-compatible crypto for PKCE (#783)
Browse files Browse the repository at this point in the history
  • Loading branch information
scotttrinh authored Nov 17, 2023
1 parent eb7fa07 commit 9309420
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 24 deletions.
29 changes: 15 additions & 14 deletions packages/auth-core/src/core.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -73,8 +76,9 @@ export class Auth {
return null as any;

Check warning on line 76 in packages/auth-core/src/core.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 18, 2)

Unexpected any. Specify a different type

Check warning on line 76 in packages/auth-core/src/core.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 18, 3)

Unexpected any. Specify a different type

Check warning on line 76 in packages/auth-core/src/core.ts

View workflow job for this annotation

GitHub Actions / test (18, ubuntu-latest, stable)

Unexpected any. Specify a different type

Check warning on line 76 in packages/auth-core/src/core.ts

View workflow job for this annotation

GitHub Actions / test (20, ubuntu-latest, stable)

Unexpected any. Specify a different type

Check warning on line 76 in packages/auth-core/src/core.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 18, nightly)

Unexpected any. Specify a different type

Check warning on line 76 in packages/auth-core/src/core.ts

View workflow job for this annotation

GitHub Actions / test (macos-latest, 18, stable)

Unexpected any. Specify a different type
}

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<TokenData> {
Expand All @@ -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,
Expand All @@ -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", {
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions packages/auth-core/src/crypto.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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));
}
13 changes: 5 additions & 8 deletions packages/auth-core/src/pkce.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
33 changes: 33 additions & 0 deletions packages/auth-core/test/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
8 changes: 6 additions & 2 deletions packages/auth-nextjs/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 9309420

Please sign in to comment.