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:
Add support for client_secret_post auth method
Adds support for `client_secret_post` authentication method for the `client_credentials` and `password` grants. This is useful for those who are unable to allow `client_secret_basic` on their auth server.

Tests have been added to assert correct behavior for:
- `client_secret_post` for `client_credentials` grant
- `client_secret_basic` for `password` grant
- `client_secret_post` for `password` grant

Additionally, I ran a formatter over the source files containing my changes, and fixed some typos.

---

Closes #85 

Patch:
diff --git a/src/client.ts b/src/client.ts
index 44fce83..c358494 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -18,7 +18,7 @@ export interface ClientSettings {
    * The hostname of the OAuth2 server.
    * If provided, we'll attempt to discover all the other related endpoints.
    *
-   * If this is not desired, just specifiy the other endpoints manually.
+   * If this is not desired, just specify the other endpoints manually.
    *
    * This url will also be used as the base URL for all other urls. This lets
    * you specify all the other urls as relative.
@@ -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;
 
@@ -56,7 +57,7 @@ export interface ClientSettings {
    * Introspection endpoint.
    *
    * Required for, well, introspecting tokens.
-   * If not provided we'll try to discover it, or othwerwise default to /introspect
+   * If not provided we'll try to discover it, or otherwise default to /introspect
    */
   introspectionEndpoint?: 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;
 }
 
 
@@ -83,7 +93,9 @@ export class OAuth2Client {
   constructor(clientSettings: ClientSettings) {
 
     this.settings = clientSettings;
-
+    if (!this.settings.authenticationMethod) {
+      this.settings.authenticationMethod = 'client_secret_basic';
+    }
   }
 
   /**
@@ -95,7 +107,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,
     };
@@ -111,9 +123,9 @@ export class OAuth2Client {
   /**
    * Retrieves an OAuth2 token using the client_credentials grant.
    */
-  async clientCredentials(params?: {scope?: string[]}): Promise<OAuth2Token> {
+  async clientCredentials(params?: { scope?: string[] }): Promise<OAuth2Token> {
 
-    const body:ClientCredentialsRequest = {
+    const body: ClientCredentialsRequest = {
       grant_type: 'client_credentials',
       scope: params?.scope?.join(' '),
     };
@@ -128,16 +140,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));
 
   }
@@ -196,14 +205,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);
     }
 
@@ -229,7 +238,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');
@@ -243,9 +252,9 @@ 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);
     }
@@ -265,7 +274,12 @@ export class OAuth2Client {
       'Content-Type': 'application/x-www-form-urlencoded',
     };
 
-    if (this.settings.clientSecret) {
+    if (this.settings.authenticationMethod === 'client_secret_post') {
+      body.client_id = this.settings.clientId;
+      if (body.grant_type === 'client_credentials') {
+        body.client_secret = this.settings.clientSecret;
+      }
+    } else if (this.settings.clientSecret) {
       const basicAuthStr = btoa(this.settings.clientId + ':' + this.settings.clientSecret);
       headers.Authorization = 'Basic ' + basicAuthStr;
     } else if (body.grant_type === 'authorization_code') {
@@ -309,7 +323,7 @@ export class OAuth2Client {
 
 }
 
-function resolve(uri: string, base?:string): string {
+function resolve(uri: string, base?: string): string {
 
   return new URL(uri, base).toString();
 
@@ -317,7 +331,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,
@@ -330,11 +344,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 c0efbe0..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();
 
@@ -30,4 +30,32 @@ describe('client-credentials', () => {
     });
   });
 
+  it('should work with client_secret_post', async () => {
+
+    const server = testServer();
+
+    const client = new OAuth2Client({
+      server: server.url,
+      tokenEndpoint: '/token',
+      clientId: 'test-client-id',
+      clientSecret: 'test-client-secret',
+      authenticationMethod: 'client_secret_post'
+    });
+
+    const result = await client.clientCredentials();
+
+    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({
+      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'
+    });
+  });
+
+});