Skip to content

Commit 9309420

Browse files
authored
Use Web-compatible crypto for PKCE (#783)
1 parent eb7fa07 commit 9309420

File tree

5 files changed

+107
-24
lines changed

5 files changed

+107
-24
lines changed

packages/auth-core/src/core.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import jwtDecode from "jwt-decode";
22
import * as edgedb from "edgedb";
3-
import { ResolvedConnectConfig } from "edgedb/dist/conUtils";
3+
import { type ResolvedConnectConfig } from "edgedb/dist/conUtils";
44

55
import * as pkce from "./pkce";
6-
import { BuiltinOAuthProviderNames, emailPasswordProviderName } from "./consts";
6+
import {
7+
type BuiltinOAuthProviderNames,
8+
emailPasswordProviderName,
9+
} from "./consts";
710

811
export interface TokenData {
912
auth_token: string;
@@ -73,8 +76,9 @@ export class Auth {
7376
return null as any;
7477
}
7578

76-
createPKCESession() {
77-
return new AuthPCKESession(this);
79+
async createPKCESession() {
80+
const { challenge, verifier } = await pkce.createVerifierChallengePair();
81+
return new AuthPCKESession(this, challenge, verifier);
7882
}
7983

8084
getToken(code: string, verifier: string): Promise<TokenData> {
@@ -87,7 +91,7 @@ export class Auth {
8791
}
8892

8993
async signinWithEmailPassword(email: string, password: string) {
90-
const { challenge, verifier } = pkce.createVerifierChallengePair();
94+
const { challenge, verifier } = await pkce.createVerifierChallengePair();
9195
const { code } = await this._post<{ code: string }>("authenticate", {
9296
provider: emailPasswordProviderName,
9397
challenge,
@@ -105,7 +109,7 @@ export class Auth {
105109
| { status: "complete"; tokenData: TokenData }
106110
| { status: "verificationRequired"; verifier: string }
107111
> {
108-
const { challenge, verifier } = pkce.createVerifierChallengePair();
112+
const { challenge, verifier } = await pkce.createVerifierChallengePair();
109113
const result = await this._post<
110114
{ code: string } | { verification_email_sent_at: string }
111115
>("register", {
@@ -200,14 +204,11 @@ export class Auth {
200204
}
201205

202206
export class AuthPCKESession {
203-
public readonly challenge: string;
204-
public readonly verifier: string;
205-
206-
constructor(private auth: Auth) {
207-
const { challenge, verifier } = pkce.createVerifierChallengePair();
208-
this.challenge = challenge;
209-
this.verifier = verifier;
210-
}
207+
constructor(
208+
private auth: Auth,
209+
public readonly challenge: string,
210+
public readonly verifier: string
211+
) {}
211212

212213
getOAuthUrl(
213214
providerName: BuiltinOAuthProviderNames,

packages/auth-core/src/crypto.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* eslint @typescript-eslint/no-var-requires: ["off"] */
2+
3+
// TODO: Drop when Node 18 is EOL: 2025-04-30
4+
if (!globalThis.crypto) {
5+
// tslint:disable-next-line: no-var-requires
6+
globalThis.crypto = require("node:crypto").webcrypto;
7+
}
8+
9+
const BASE64_URL_CHARS =
10+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
11+
12+
export function bytesToBase64Url(bytes: Uint8Array): string {
13+
const len = bytes.length;
14+
let base64url = "";
15+
16+
for (let i = 0; i < len; i += 3) {
17+
const b1 = bytes[i] & 0xff;
18+
const b2 = i + 1 < len ? bytes[i + 1] & 0xff : 0;
19+
const b3 = i + 2 < len ? bytes[i + 2] & 0xff : 0;
20+
21+
const enc1 = b1 >> 2;
22+
const enc2 = ((b1 & 0x03) << 4) | (b2 >> 4);
23+
const enc3 = ((b2 & 0x0f) << 2) | (b3 >> 6);
24+
const enc4 = b3 & 0x3f;
25+
26+
base64url += BASE64_URL_CHARS.charAt(enc1) + BASE64_URL_CHARS.charAt(enc2);
27+
if (i + 1 < len) {
28+
base64url += BASE64_URL_CHARS.charAt(enc3);
29+
}
30+
if (i + 2 < len) {
31+
base64url += BASE64_URL_CHARS.charAt(enc4);
32+
}
33+
}
34+
35+
return base64url;
36+
}
37+
38+
export async function sha256(
39+
source: BufferSource | string
40+
): Promise<Uint8Array> {
41+
const bytes =
42+
typeof source === "string" ? new TextEncoder().encode(source) : source;
43+
return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
44+
}
45+
46+
export function randomBytes(length: number): Uint8Array {
47+
return crypto.getRandomValues(new Uint8Array(length));
48+
}

packages/auth-core/src/pkce.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import crypto from "node:crypto";
1+
import { bytesToBase64Url, sha256, randomBytes } from "./crypto";
22

3-
export function createVerifierChallengePair(): {
3+
export async function createVerifierChallengePair(): Promise<{
44
verifier: string;
55
challenge: string;
6-
} {
7-
const verifier = crypto.randomBytes(32).toString("base64url");
8-
const challenge = crypto
9-
.createHash("sha256")
10-
.update(verifier)
11-
.digest("base64url");
6+
}> {
7+
const verifier = bytesToBase64Url(randomBytes(32));
8+
const challenge = await sha256(verifier).then(bytesToBase64Url);
129

1310
return { verifier, challenge };
1411
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import crypto from "node:crypto";
2+
import { bytesToBase64Url, sha256, randomBytes } from "../src/crypto";
3+
4+
describe("crypto", () => {
5+
describe("bytesToBase64Url", () => {
6+
test("Equivalent to Buffer implementation", () => {
7+
for (let i = 0; i < 100; i++) {
8+
const buffer = crypto.randomBytes(32);
9+
expect(buffer.toString("base64url")).toEqual(bytesToBase64Url(buffer));
10+
}
11+
});
12+
});
13+
14+
describe("sha256", () => {
15+
test("Equivalent to Node crypto SHA256 implementation", async () => {
16+
for (let i = 0; i < 100; i++) {
17+
const buffer = crypto.randomBytes(32);
18+
expect(crypto.createHash("sha256").update(buffer).digest()).toEqual(
19+
Buffer.from(await sha256(buffer))
20+
);
21+
}
22+
});
23+
});
24+
25+
describe("randomBytes", () => {
26+
test("Generates Uint8Array of correct length", () => {
27+
const length = 32;
28+
const bytes = randomBytes(length);
29+
expect(bytes).toBeInstanceOf(Uint8Array);
30+
expect(bytes.length).toEqual(length);
31+
});
32+
});
33+
});

packages/auth-nextjs/src/app/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export class NextAppAuth extends NextAuth {
7676
throw new Error(`invalid provider_name: ${provider}`);
7777
}
7878
const redirectUrl = `${this._authRoute}/oauth/callback`;
79-
const pkceSession = (await this.core).createPKCESession();
79+
const pkceSession = await this.core.then((core) =>
80+
core.createPKCESession()
81+
);
8082
cookies().set({
8183
name: this.options.pkceVerifierCookieName,
8284
value: pkceSession.verifier,
@@ -238,7 +240,9 @@ export class NextAppAuth extends NextAuth {
238240
}
239241
case "builtin/signin":
240242
case "builtin/signup": {
241-
const pkceSession = (await this.core).createPKCESession();
243+
const pkceSession = await this.core.then((core) =>
244+
core.createPKCESession()
245+
);
242246
cookies().set({
243247
name: this.options.pkceVerifierCookieName,
244248
value: pkceSession.verifier,

0 commit comments

Comments
 (0)