Skip to content

Use Web-compatible crypto for PKCE #783

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

Merged
merged 7 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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;
}

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