Repository: badgateway/oauth2-client
Pull request #75 (2022-07-22 16:58:26)

Code challenge generator is broken on Node.
Generating the url to the authorization endpoint doesn't currently work on node, due to it depending on a global `crypto` object.


Patch:
diff --git a/changelog.md b/changelog.md
index 31921bb..c69c68a 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,6 +1,13 @@
 Changelog
 =========
 
+2.0.16 (2022-07-22)
+-------------------
+
+* It was not possible to generate the URL to the authorization endpoint with
+  PKCE using Node, due to depending on a global `crypto` object. This is fixed
+  with fallbacks all the way back to Node 14.
+
 2.0.15 (2022-07-07)
 -------------------
 
diff --git a/src/client/authorization-code.ts b/src/client/authorization-code.ts
index 1c2e7bd..6dc9c98 100644
--- a/src/client/authorization-code.ts
+++ b/src/client/authorization-code.ts
@@ -149,40 +149,61 @@ export class OAuth2AuthorizationCodeClient {
 
 export async function generateCodeVerifier(): Promise<string> {
 
-  if ((typeof window !== 'undefined' && window.crypto) || (typeof self !== 'undefined' && self.crypto)) {
-    // Built-in webcrypto
+  const webCrypto = getWebCrypto();
+  if (webCrypto) {
     const arr = new Uint8Array(32);
-    crypto.getRandomValues(arr);
+    webCrypto.getRandomValues(arr);
     return base64Url(arr);
   } else {
 
+    // Old node doesn't have 'webcrypto', so this is a fallback
+
     // eslint-disable-next-line @typescript-eslint/no-var-requires
-    const crypto = require('crypto');
-    if (crypto.webcrypto) {
-      // Webcrypto in a Node 16 or 18 module
-      const arr = new Uint8Array(32);
-      crypto.webcrypto.getRandomValues(arr);
-      return base64Url(arr);
-
-    } else {
-
-      // Old node
-      return new Promise<string>((res, rej) => {
-        crypto.randomBytes(32, (err:Error, buf: Buffer) => {
-          if (err) rej(err);
-          res(buf.toString('base64url'));
-        });
+    const nodeCrypto = require('crypto');
+    return new Promise<string>((res, rej) => {
+      nodeCrypto.randomBytes(32, (err:Error, buf: Buffer) => {
+        if (err) rej(err);
+        res(buf.toString('base64url'));
       });
+    });
 
-    }
+  }
+
+}
 
+export async function getCodeChallenge(codeVerifier: string): Promise<['plain' | 'S256', string]> {
+
+  const webCrypto = getWebCrypto();
+  if (webCrypto?.subtle) {
+    return ['S256', base64Url(await webCrypto.subtle.digest('SHA-256', stringToBuffer(codeVerifier)))];
+  } else {
+    // Node 14.x fallback
+    // eslint-disable-next-line @typescript-eslint/no-var-requires
+    const nodeCrypto = require('crypto');
+    const hash = nodeCrypto.createHash('sha256');
+    hash.update(stringToBuffer(codeVerifier));
+    return ['S256', hash.digest('base64url')];
   }
 
 }
 
-async function getCodeChallenge(codeVerifier: string): Promise<['plain' | 'S256', string]> {
+function getWebCrypto() {
 
-  return ['S256', base64Url(await crypto.subtle.digest('SHA-256', stringToBuffer(codeVerifier)))];
+  // Browsers
+  if ((typeof window !== 'undefined' && window.crypto)) {
+    return window.crypto;
+  }
+  // Web workers possibly
+  if ((typeof self !== 'undefined' && self.crypto)) {
+    return self.crypto;
+  }
+  // Node
+  // eslint-disable-next-line @typescript-eslint/no-var-requires
+  const crypto = require('crypto');
+  if (crypto.webcrypto) {
+    return crypto.webcrypto;
+  }
+  return null;
 
 }
 


Test patch:
diff --git a/test/authorization-code.ts b/test/authorization-code.ts
index 9f3e3a0..399b90c 100644
--- a/test/authorization-code.ts
+++ b/test/authorization-code.ts
@@ -2,70 +2,168 @@ import { testServer } from './test-server';
 import { OAuth2Client } from '../src';
 import { expect } from 'chai';
 
+// Example directly taken from https://datatracker.ietf.org/doc/html/rfc7636
+const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
+const codeChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
+
 describe('authorization-code', () => {
 
-  it('should send requests to the token endpoint', async() => {
+  describe('Authorization endpoint redirect', () => {
 
-    const server = testServer();
+    it('should generate correct urls for the authorization endpoint', async() => {
 
-    const client = new OAuth2Client({
-      server: server.url,
-      tokenEndpoint: '/token',
-      clientId: 'test-client-id',
-    });
+      const server = testServer();
+      const client = new OAuth2Client({
+        server: server.url,
+        authorizationEndpoint: '/authorize',
+        clientId: 'test-client-id',
+      });
 
-    const result = await client.authorizationCode.getToken({
-      code: 'code_000',
-      redirectUri: 'http://example/redirect',
-    });
+      const redirectUri = 'http://my-app.example/redirect';
 
-    expect(result.accessToken).to.equal('access_token_000');
-    expect(result.refreshToken).to.equal('refresh_token_000');
-    expect(result.expiresAt).to.be.lessThanOrEqual(Date.now() + 3600_000);
-    expect(result.expiresAt).to.be.greaterThanOrEqual(Date.now() + 3500_000);
+      const params = new URLSearchParams({
+        client_id: 'test-client-id',
+        response_type: 'code',
+        redirect_uri: redirectUri
+      });
 
-    const request = server.lastRequest();
-    expect(request.headers.get('Authorization')).to.equal(null);
+      expect(
+        await client.authorizationCode.getAuthorizeUri({
+          redirectUri,
+        })
+      ).to.equal(server.url + '/authorize?' + params.toString());
+
+    });
+    it('should support PKCE', async() => {
+
+      const server = testServer();
+      const client = new OAuth2Client({
+        server: server.url,
+        authorizationEndpoint: '/authorize',
+        clientId: 'test-client-id',
+      });
+
+      const redirectUri = 'http://my-app.example/redirect';
+
+      const params = new URLSearchParams({
+        client_id: 'test-client-id',
+        response_type: 'code',
+        redirect_uri: redirectUri,
+        code_challenge_method: 'S256',
+        code_challenge: codeChallenge,
+      });
+
+      expect(
+        await client.authorizationCode.getAuthorizeUri({
+          redirectUri,
+          codeVerifier,
+        })
+      ).to.equal(server.url + '/authorize?' + params.toString());
 
-    expect(request.body).to.eql({
-      client_id: 'test-client-id',
-      grant_type: 'authorization_code',
-      code: 'code_000',
-      redirect_uri: 'http://example/redirect',
     });
 
   });
 
-  it('should send client_id and client_secret in the Authorization header if secret was specified', async() => {
+  describe('Token endpoint calls', () => {
+
+    it('should send requests to the token endpoint', async() => {
+
+      const server = testServer();
 
-    const server = testServer();
+      const client = new OAuth2Client({
+        server: server.url,
+        tokenEndpoint: '/token',
+        clientId: 'test-client-id',
+      });
+
+      const result = await client.authorizationCode.getToken({
+        code: 'code_000',
+        redirectUri: 'http://example/redirect',
+      });
+
+      expect(result.accessToken).to.equal('access_token_000');
+      expect(result.refreshToken).to.equal('refresh_token_000');
+      expect(result.expiresAt).to.be.lessThanOrEqual(Date.now() + 3600_000);
+      expect(result.expiresAt).to.be.greaterThanOrEqual(Date.now() + 3500_000);
+
+      const request = server.lastRequest();
+      expect(request.headers.get('Authorization')).to.equal(null);
+
+      expect(request.body).to.eql({
+        client_id: 'test-client-id',
+        grant_type: 'authorization_code',
+        code: 'code_000',
+        redirect_uri: 'http://example/redirect',
+      });
 
-    const client = new OAuth2Client({
-      server: server.url,
-      tokenEndpoint: '/token',
-      clientId: 'test-client-id',
-      clientSecret: 'test-client-secret',
     });
 
-    const result = await client.authorizationCode.getToken({
-      code: 'code_000',
-      redirectUri: 'http://example/redirect',
+    it('should send client_id and client_secret in the Authorization header if secret was specified', async() => {
+
+      const server = testServer();
+
+      const client = new OAuth2Client({
+        server: server.url,
+        tokenEndpoint: '/token',
+        clientId: 'test-client-id',
+        clientSecret: 'test-client-secret',
+      });
+
+      const result = await client.authorizationCode.getToken({
+        code: 'code_000',
+        redirectUri: 'http://example/redirect',
+      });
+
+      expect(result.accessToken).to.equal('access_token_000');
+      expect(result.refreshToken).to.equal('refresh_token_000');
+      expect(result.expiresAt).to.be.lessThanOrEqual(Date.now() + 3600_000);
+      expect(result.expiresAt).to.be.greaterThanOrEqual(Date.now() + 3500_000);
+
+      const request = server.lastRequest();
+      expect(request.headers.get('Authorization')).to.equal('Basic ' + btoa('test-client-id:test-client-secret'));
+
+      expect(request.body).to.eql({
+        grant_type: 'authorization_code',
+        code: 'code_000',
+        redirect_uri: 'http://example/redirect',
+      });
+
     });
 
-    expect(result.accessToken).to.equal('access_token_000');
-    expect(result.refreshToken).to.equal('refresh_token_000');
-    expect(result.expiresAt).to.be.lessThanOrEqual(Date.now() + 3600_000);
-    expect(result.expiresAt).to.be.greaterThanOrEqual(Date.now() + 3500_000);
+    it('should should support PKCE', async() => {
+
+      const server = testServer();
 
-    const request = server.lastRequest();
-    expect(request.headers.get('Authorization')).to.equal('Basic ' + btoa('test-client-id:test-client-secret'));
+      const client = new OAuth2Client({
+        server: server.url,
+        tokenEndpoint: '/token',
+        clientId: 'test-client-id',
+      });
+
+
+      const result = await client.authorizationCode.getToken({
+        code: 'code_000',
+        redirectUri: 'http://example/redirect',
+        codeVerifier,
+      });
+
+      expect(result.accessToken).to.equal('access_token_000');
+      expect(result.refreshToken).to.equal('refresh_token_000');
+      expect(result.expiresAt).to.be.lessThanOrEqual(Date.now() + 3600_000);
+      expect(result.expiresAt).to.be.greaterThanOrEqual(Date.now() + 3500_000);
+
+      const request = server.lastRequest();
+      expect(request.headers.get('Authorization')).to.equal(null);
+
+      expect(request.body).to.eql({
+        client_id: 'test-client-id',
+        grant_type: 'authorization_code',
+        code: 'code_000',
+        code_verifier: codeVerifier,
+        redirect_uri: 'http://example/redirect',
+      });
 
-    expect(request.body).to.eql({
-      grant_type: 'authorization_code',
-      code: 'code_000',
-      redirect_uri: 'http://example/redirect',
     });
 
   });
-
 });
diff --git a/test/pkce.ts b/test/pkce.ts
index 204d3de..592251c 100644
--- a/test/pkce.ts
+++ b/test/pkce.ts
@@ -1,4 +1,5 @@
 import { generateCodeVerifier } from '../src';
+import { getCodeChallenge } from '../src/client/authorization-code';
 import { expect } from 'chai';
 
 describe('generateCodeVerifier', () => {
@@ -6,9 +7,22 @@ describe('generateCodeVerifier', () => {
   it('should generate a 32byte base4url string', async () => {
 
     const out = await generateCodeVerifier();
-    console.debug(out, out.length);
+    //console.debug(out, out.length);
     expect(out).to.match(/^[A-Za-z0-9-_]{43}$/);
 
   });
 
 });
+
+describe('getCodeChallenge', () => {
+
+  it('should generate the matching code challenge for a given code verifier', async() => {
+
+    const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
+    const codeChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
+
+    expect(await getCodeChallenge(codeVerifier)).to.eql(['S256', codeChallenge]);
+
+  });
+
+});