Repository: badgateway/oauth2-client (ts)

Issue:
Support for 'client_secret_post'
Hello! First of all, thanks for your hard word on this project :)

While working with this library to replace an existing OIDC auth client, I noticed that there is currently only support for `client_secret_basic` authentication method. This is a blocker for my team, because we are only allowed to enable `client_secret_post`.

I went ahead and opened PR #84 which contains the changes need to support `client_secret_post` for both the `client_credentials` and `password` grant types.

Pull request description:
Support client_id and client_secret to be sent in POST request body
Fixes #85 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0ab544d..7bec66a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [14.x, 16.x, 18.x]
+        node-version: [14.x, 16.x, 18.x, 20.x]
         # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
 
     steps:
diff --git a/changelog.md b/changelog.md
index 9eece01..f971117 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,14 +1,6 @@
 Changelog
 =========
 
-2.1.0 (????-??-??)
-------------------
-
-* Allow users to provide non-standard properties to `client_credentials` token
-  requests via an `extraParams` property. This is necessary to support vendors
-  like Auth0 and Kinde which both require an `audience` parameter. (@South-Paw)
-
-
 2.0.18 (2023-04-13)
 -------------------
 
diff --git a/src/client.ts b/src/client.ts
index a0a6f36..135f9a8 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -33,8 +33,9 @@ export interface ClientSettings {
   /**
    * OAuth2 clientSecret
    *
-   * This is required for the client_credentials and password flows, but
-   * not authorization_code or implicit.
+   * This is required when using the 'client_secret_basic' authenticationMethod
+   * for the client_credentials and password flows, but not authorization_code
+   * or implicit.
    */
   clientSecret?: string;
 
@@ -71,6 +72,15 @@ export interface ClientSettings {
    */
   discoveryEndpoint?: string;
 
+  /**
+   * Client authentication method that is used to authenticate
+   * when using the token endpoint.
+   *
+   * Can be one of 'client_secret_basic' | 'client_secret_post'.
+   *
+   * The default value is 'client_secret_basic' if not provided.
+   */
+  authenticationMethod?: string;
 }
 
 
@@ -95,7 +105,7 @@ export class OAuth2Client {
       throw new Error('This token didn\'t have a refreshToken. It\'s not possible to refresh this');
     }
 
-    const body:RefreshRequest = {
+    const body: RefreshRequest = {
       grant_type: 'refresh_token',
       refresh_token: token.refreshToken,
     };
@@ -136,16 +146,13 @@ export class OAuth2Client {
   /**
    * Retrieves an OAuth2 token using the 'password' grant'.
    */
-  async password(params: {username: string; password: string; scope?: string[]}): Promise<OAuth2Token> {
+  async password(params: { username: string; password: string; scope?: string[] }): Promise<OAuth2Token> {
 
-    const body:PasswordRequest = {
+    const body: PasswordRequest = {
       grant_type: 'password',
       ...params,
       scope: params.scope?.join(' '),
     };
-    if (!this.settings.clientSecret) {
-      throw new Error('A clientSecret must be provided to use client_credentials');
-    }
     return tokenResponseToOAuth2Token(this.request('tokenEndpoint', body));
 
   }
@@ -203,14 +210,14 @@ export class OAuth2Client {
       throw new Error(`Could not determine the location of ${endpoint}. Either specify ${endpoint} in the settings, or the "server" endpoint to let the client discover it.`);
     }
 
-    switch(endpoint) {
+    switch (endpoint) {
       case 'authorizationEndpoint':
         return resolve('/authorize', this.settings.server);
-      case 'tokenEndpoint' :
+      case 'tokenEndpoint':
         return resolve('/token', this.settings.server);
       case 'discoveryEndpoint':
         return resolve('/.well-known/oauth-authorization-server', this.settings.server);
-      case 'introspectionEndpoint' :
+      case 'introspectionEndpoint':
         return resolve('/introspect', this.settings.server);
     }
 
@@ -236,7 +243,7 @@ export class OAuth2Client {
       console.warn('[oauth2] OAuth2 discovery endpoint could not be determined. Either specify the "server" or "discoveryEndpoint');
       return;
     }
-    const resp = await fetch(discoverUrl, { headers: { Accept: 'application/json' }});
+    const resp = await fetch(discoverUrl, { headers: { Accept: 'application/json' } });
     if (!resp.ok) return;
     if (!resp.headers.get('Content-Type')?.startsWith('application/json')) {
       console.warn('[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored');
@@ -250,13 +257,17 @@ export class OAuth2Client {
       ['introspection_endpoint', 'introspectionEndpoint'],
     ] as const;
 
-    if (this.serverMetadata===null) return;
+    if (this.serverMetadata === null) return;
 
-    for(const [property, setting] of urlMap) {
+    for (const [property, setting] of urlMap) {
       if (!this.serverMetadata[property]) continue;
       this.settings[setting] = resolve(this.serverMetadata[property]!, discoverUrl);
     }
 
+    if (this.serverMetadata.token_endpoint_auth_methods_supported && !this.settings.authenticationMethod) {
+      this.settings.authenticationMethod = this.serverMetadata.token_endpoint_auth_methods_supported[0];
+    }
+
   }
 
   /**
@@ -272,11 +283,24 @@ export class OAuth2Client {
       'Content-Type': 'application/x-www-form-urlencoded',
     };
 
-    if (this.settings.clientSecret) {
-      const basicAuthStr = btoa(this.settings.clientId + ':' + this.settings.clientSecret);
-      headers.Authorization = 'Basic ' + basicAuthStr;
-    } else if (body.grant_type === 'authorization_code') {
-      body.client_id = this.settings.clientId;
+    let authMethod = this.settings.authenticationMethod;
+    if (!authMethod) {
+      authMethod = this.settings.clientSecret ? 'client_secret_basic' : 'client_secret_post';
+    }
+
+    switch(authMethod) {
+      case 'client_secret_basic' :
+        headers.Authorization = 'Basic ' +
+          btoa(this.settings.clientId + ':' + this.settings.clientSecret);
+        break;
+      case 'client_secret_post' :
+        body.client_id = this.settings.clientId;
+        if (this.settings.clientSecret) {
+          body.client_secret = this.settings.clientSecret;
+        }
+        break;
+      default:
+        throw new Error('Authentication method not yet supported:' + authMethod + '. Open a feature request if you want this!');
     }
 
     const resp = await fetch(uri, {
@@ -316,7 +340,7 @@ export class OAuth2Client {
 
 }
 
-function resolve(uri: string, base?:string): string {
+function resolve(uri: string, base?: string): string {
 
   return new URL(uri, base).toString();
 
@@ -324,7 +348,7 @@ function resolve(uri: string, base?:string): string {
 
 export function tokenResponseToOAuth2Token(resp: Promise<TokenResponse>): Promise<OAuth2Token> {
 
-  return resp.then( body => ({
+  return resp.then(body => ({
     accessToken: body.access_token,
     expiresAt: body.expires_in ? Date.now() + (body.expires_in * 1000) : null,
     refreshToken: body.refresh_token ?? null,
@@ -337,11 +361,11 @@ export function tokenResponseToOAuth2Token(resp: Promise<TokenResponse>): Promis
  *
  * This function filters out any undefined values.
  */
-export function generateQueryString(params: Record<string, undefined|number|string>): string {
+export function generateQueryString(params: Record<string, undefined | number | string>): string {
 
   return new URLSearchParams(
     Object.fromEntries(
-      Object.entries(params).filter( ([k, v]) => v!==undefined)
+      Object.entries(params).filter(([k, v]) => v !== undefined)
     ) as Record<string, string>
   ).toString();
 


Test patch:
diff --git a/test/client-credentials.ts b/test/client-credentials.ts
index f3e0ba0..6f59ee7 100644
--- a/test/client-credentials.ts
+++ b/test/client-credentials.ts
@@ -4,7 +4,7 @@ import { expect } from 'chai';
 
 describe('client-credentials', () => {
 
-  it('should work', async() => {
+  it('should work with client_secret_basic', async () => {
 
     const server = testServer();
 
@@ -29,7 +29,8 @@ describe('client-credentials', () => {
       grant_type: 'client_credentials',
     });
   });
-  it('should support extra parameters', async() => {
+
+  it('should work with client_secret_post', async () => {
 
     const server = testServer();
 
@@ -38,13 +39,10 @@ describe('client-credentials', () => {
       tokenEndpoint: '/token',
       clientId: 'test-client-id',
       clientSecret: 'test-client-secret',
+      authenticationMethod: 'client_secret_post'
     });
 
-    const result = await client.clientCredentials({
-      extraParams: {
-        foo: 'bar',
-      }
-    });
+    const result = await client.clientCredentials();
 
     expect(result.accessToken).to.equal('access_token_000');
     expect(result.refreshToken).to.equal('refresh_token_000');
@@ -52,11 +50,11 @@ describe('client-credentials', () => {
     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: 'client_credentials',
-      foo: 'bar',
+      client_id: 'test-client-id',
+      client_secret: 'test-client-secret',
+      grant_type: 'client_credentials'
     });
   });
 
diff --git a/test/password.ts b/test/password.ts
new file mode 100644
index 0000000..b6495ce
--- /dev/null
+++ b/test/password.ts
@@ -0,0 +1,69 @@
+import { testServer } from './test-server';
+import { OAuth2Client } from '../src';
+import { expect } from 'chai';
+
+describe('password', () => {
+
+  it('should work with client_secret_basic', 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.password({
+      username: 'user123',
+      password: 'password'
+    });
+
+    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: 'password',
+      password: 'password',
+      username: 'user123'
+    });
+  });
+
+  it('should work with client_secret_post', async () => {
+
+    const server = testServer();
+
+    const client = new OAuth2Client({
+      server: server.url,
+      tokenEndpoint: '/token',
+      clientId: 'test-client-id',
+      authenticationMethod: 'client_secret_post'
+    });
+
+    const result = await client.password({
+      username: 'user123',
+      password: 'password'
+    });
+
+    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.body).to.eql({
+      grant_type: 'password',
+      password: 'password',
+      username: 'user123',
+      client_id: 'test-client-id'
+    });
+  });
+
+});