Repository: badgateway/oauth2-client (ts)

Issue:
Allow to add extraParams to token request and refresh token request
I am using OAuth public client with authorozation code flow, refresh token and resource imdicators (RFC 8707).
The mentioned RFC adds the resource parameter to the authorization, token and refresh token requests. Unfortunatelly, it is not possible to pass the resource parameter to the token request and refresh token request.

The mentioned resource parameter can be a single string or an array of strings which brings additional issue. extraParams of type Record cannot hold multiple items with the same key. Moreover, serialization to query string using UrlSearchParams does not convert a Record with a property of type array os strings correctly
An example of such a Record is  { resource: [ "r1", "r2"]} which should be serialized to "resource=r1&resource=r2". 

Pull request description:
Support for Resource Indicators for OAuth 2.0.
Fixes #130.

Patch:
diff --git a/README.md b/README.md
index 9ad7153..fd4257b 100644
--- a/README.md
+++ b/README.md
@@ -10,13 +10,14 @@ since Node 18 (but it works with Polyfills on Node 14 and 16).
 
 ## Highlights
 
-* 10KB minified (3.6KB gzipped).
+* 11KB minified (3.7KB gzipped).
 * No dependencies.
 * `authorization_code` grant with optional [PKCE][1] support.
 * `password` and `client_credentials` grant.
 * a `fetch()` wrapper that automatically adds Bearer tokens and refreshes them.
 * OAuth2 endpoint discovery via the Server metadata document ([RFC8414][2]).
 * OAuth2 Token Introspection ([RFC7662][3]).
+* Resource Indicators for OAuth 2.0 ([RFC8707][5]).
 
 
 ## Installation
@@ -438,3 +439,4 @@ if (global.btoa === undefined) {
 [2]: https://datatracker.ietf.org/doc/html/rfc8414 "OAuth 2.0 Authorization Server Metadata"
 [3]: https://datatracker.ietf.org/doc/html/rfc7662 "OAuth 2.0 Token Introspection"
 [4]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API "Web Crypto API"
+[5]: https://datatracker.ietf.org/doc/html/rfc8707 "https://datatracker.ietf.org/doc/html/rfc8707"
diff --git a/changelog.md b/changelog.md
index 33a574c..13620c8 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,11 +1,12 @@
 Changelog
 =========
 
-2.2.5 (????-??-??)
+2.3.0 (????-??-??)
 ------------------
 
 * Fix for #128: If there's no secret, we should never use Basic auth to encode
   the `client_id`.
+* Support for the 'resource' parameter from RFC 8707.
 
 
 2.2.4 (2023-09-05)
diff --git a/package.json b/package.json
index d2769b1..77a9871 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,11 @@
     "pkce",
     "security",
     "bearer",
-    "RFC6749"
+    "RFC6749",
+    "RFC7636",
+    "RFC7662",
+    "RFC8414",
+    "RFC8707"
   ],
   "author": "Evert Pot (https://evertpot.com)",
   "license": "MIT",
diff --git a/src/client.ts b/src/client.ts
index 74b1917..509bfd4 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -12,6 +12,34 @@ import {
 import { OAuth2Error } from './error';
 import { OAuth2AuthorizationCodeClient } from './client/authorization-code';
 
+
+type ClientCredentialsParams = {
+  scope?: string[];
+  extraParams?: Record<string, string>;
+
+  /**
+   * The resource  the client intends to access.
+   *
+   * @see https://datatracker.ietf.org/doc/html/rfc8707
+   */
+  resource?: string | string[];
+}
+
+type PasswordParams = {
+  username: string;
+  password: string;
+
+  scope?: string[];
+
+  /**
+   * The resource  the client intends to access.
+   *
+   * @see https://datatracker.ietf.org/doc/html/rfc8707
+   */
+  resource?: string | string[];
+
+}
+
 export interface ClientSettings {
 
   /**
@@ -132,7 +160,7 @@ export class OAuth2Client {
   /**
    * Retrieves an OAuth2 token using the client_credentials grant.
    */
-  async clientCredentials(params?: { scope?: string[]; extraParams?: Record<string, string> }): Promise<OAuth2Token> {
+  async clientCredentials(params?: ClientCredentialsParams): Promise<OAuth2Token> {
 
     const disallowed = ['client_id', 'client_secret', 'grant_type', 'scope'];
 
@@ -143,6 +171,7 @@ export class OAuth2Client {
     const body: ClientCredentialsRequest = {
       grant_type: 'client_credentials',
       scope: params?.scope?.join(' '),
+      resource: params?.resource,
       ...params?.extraParams
     };
 
@@ -157,7 +186,7 @@ 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: PasswordParams): Promise<OAuth2Token> {
 
     const body: PasswordRequest = {
       grant_type: 'password',
@@ -386,12 +415,14 @@ function resolve(uri: string, base?: string): string {
  *
  * 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[]>): string {
 
-  return new URLSearchParams(
-    Object.fromEntries(
-      Object.entries(params).filter(([k, v]) => v !== undefined)
-    ) as Record<string, string>
-  ).toString();
+  const query = new URLSearchParams();
+  for (const [k, v] of Object.entries(params)) {
+    if (Array.isArray(v)) {
+      for(const vItem of v) query.append(k, vItem);
+    } else if (v !== undefined) query.set(k, v.toString());
+  }
+  return query.toString();
 
 }
diff --git a/src/client/authorization-code.ts b/src/client/authorization-code.ts
index 7b71f4e..18d86c7 100644
--- a/src/client/authorization-code.ts
+++ b/src/client/authorization-code.ts
@@ -1,6 +1,6 @@
-import { OAuth2Client, generateQueryString } from '../client';
+import { OAuth2Client } from '../client';
 import { OAuth2Token } from '../token';
-import { AuthorizationCodeRequest, AuthorizationQueryParams } from '../messages';
+import { AuthorizationCodeRequest } from '../messages';
 import { OAuth2Error } from '../error';
 
 type GetAuthorizeUrlParams = {
@@ -27,6 +27,13 @@ type GetAuthorizeUrlParams = {
    */
   scope?: string[];
 
+  /**
+   * The resource the client intends to access.
+   *
+   * This is defined in RFC 8707.
+   */
+  resource?: string[] | string;
+
   /**
    * Any parameters listed here will be added to the query string for the authorization server endpoint.
    */
@@ -47,6 +54,23 @@ type ValidateResponseResult = {
 
 }
 
+type GetTokenParams = {
+
+  code: string;
+
+  redirectUri: string;
+  state?: string;
+  codeVerifier?:string;
+
+  /**
+   * The resource the client intends to access.
+   *
+   * @see https://datatracker.ietf.org/doc/html/rfc8707
+   */
+  resource?: string[] | string;
+
+}
+
 export class OAuth2AuthorizationCodeClient {
 
   client: OAuth2Client;
@@ -71,34 +95,35 @@ export class OAuth2AuthorizationCodeClient {
       this.client.getEndpoint('authorizationEndpoint')
     ]);
 
-    let query: AuthorizationQueryParams = {
+    const query = new URLSearchParams({
       client_id: this.client.settings.clientId,
       response_type: 'code',
       redirect_uri: params.redirectUri,
-      code_challenge_method: codeChallenge?.[0],
-      code_challenge: codeChallenge?.[1],
-    };
+    });
+    if (codeChallenge) {
+      query.set('code_challenge_method', codeChallenge[0]);
+      query.set('code_challenge', codeChallenge[1]);
+    }
     if (params.state) {
-      query.state = params.state;
+      query.set('state', params.state);
     }
     if (params.scope) {
-      query.scope = params.scope.join(' ');
+      query.set('scope', params.scope.join(' '));
     }
 
-    const disallowed = Object.keys(query);
-
-    if (params?.extraParams && Object.keys(params.extraParams).filter((key) => disallowed.includes(key)).length > 0) {
-      throw new Error(`The following extraParams are disallowed: '${disallowed.join("', '")}'`);
+    if (params.resource) for(const resource of [].concat(params.resource as any)) {
+      query.append('resource', resource);
+    }
+    if (params.extraParams) for(const [k,v] of Object.entries(params.extraParams)) {
+      if (query.has(k)) throw new Error(`Property in extraParams would overwrite standard property: ${k}`);
+      query.set(k, v);
     }
 
-    query = {...query, ...params?.extraParams};
-
-
-    return authorizationEndpoint + '?' + generateQueryString(query);
+    return authorizationEndpoint + '?' + query.toString();
 
   }
 
-  async getTokenFromCodeRedirect(url: string|URL, params: {redirectUri: string; state?: string; codeVerifier?:string} ): Promise<OAuth2Token> {
+  async getTokenFromCodeRedirect(url: string|URL, params: Omit<GetTokenParams, 'code'> ): Promise<OAuth2Token> {
 
     const { code } = await this.validateResponse(url, {
       state: params.state
@@ -126,7 +151,7 @@ export class OAuth2AuthorizationCodeClient {
     if (queryParams.has('error')) {
       throw new OAuth2Error(
         queryParams.get('error_description') ?? 'OAuth2 error',
-        queryParams.get('error')!,
+        queryParams.get('error') as any,
         0,
       );
     }
@@ -148,13 +173,14 @@ export class OAuth2AuthorizationCodeClient {
   /**
    * Receives an OAuth2 token using 'authorization_code' grant
    */
-  async getToken(params: { code: string; redirectUri: string; codeVerifier?: string }): Promise<OAuth2Token> {
+  async getToken(params: GetTokenParams): Promise<OAuth2Token> {
 
     const body:AuthorizationCodeRequest = {
       grant_type: 'authorization_code',
       code: params.code,
       redirect_uri: params.redirectUri,
       code_verifier: params.codeVerifier,
+      resource: params.resource,
     };
     return this.client.tokenResponseToOAuth2Token(this.client.request('tokenEndpoint', body));
 
diff --git a/src/error.ts b/src/error.ts
index 250d393..69e8234 100644
--- a/src/error.ts
+++ b/src/error.ts
@@ -1,3 +1,5 @@
+import { OAuth2ErrorCode } from './messages';
+
 /**
  * An error class for any error the server emits.
  *
@@ -12,10 +14,10 @@
  */
 export class OAuth2Error extends Error {
 
-  oauth2Code: string;
+  oauth2Code: OAuth2ErrorCode;
   httpCode: number;
 
-  constructor(message: string, oauth2Code: string, httpCode: number) {
+  constructor(message: string, oauth2Code: OAuth2ErrorCode, httpCode: number) {
 
     super(message);
 
diff --git a/src/messages.ts b/src/messages.ts
index 3d4636c..72adff3 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -7,6 +7,13 @@ export type RefreshRequest = {
 
   client_id?: string;
   scope?: string;
+
+  /**
+   * The resource  the client intends to access.
+   *
+   * @see https://datatracker.ietf.org/doc/html/rfc8707
+   */
+  resource?: string | string[];
 }
 
 /**
@@ -15,7 +22,15 @@ export type RefreshRequest = {
 export type ClientCredentialsRequest = {
   grant_type: 'client_credentials';
   scope?: string;
-  [key: string]: string | undefined;
+
+  /**
+   * The resource  the client intends to access.
+   *
+   * @see https://datatracker.ietf.org/doc/html/rfc8707
+   */
+  resource?: string | string[];
+
+  [key: string]: string | undefined | string[];
 }
 
 /**
@@ -26,6 +41,13 @@ export type PasswordRequest = {
   username: string;
   password: string;
   scope?: string;
+
+  /**
+   * The resource  the client intends to access.
+   *
+   * @see https://datatracker.ietf.org/doc/html/rfc8707
+   */
+  resource?: string | string[];
 }
 
 export type AuthorizationCodeRequest = {
@@ -33,21 +55,13 @@ export type AuthorizationCodeRequest = {
   code: string;
   redirect_uri: string;
   code_verifier: string|undefined;
-}
 
-/**
- * The query parameters that will be sent to the /authorization endpoint
- * for the authorization_code request.
- */
-export type AuthorizationQueryParams = {
-  response_type: 'code';
-  client_id: string;
-  redirect_uri: string;
-  state?: string;
-  scope?: string;
-  code_challenge_method?: 'plain' | 'S256';
-  code_challenge?: string;
-  [key: string]: string | undefined;
+  /**
+   * The resource  the client intends to access.
+   *
+   * @see https://datatracker.ietf.org/doc/html/rfc8707
+   */
+  resource?: string | string[];
 }
 
 /**
@@ -266,3 +280,17 @@ export type IntrospectionResponse = {
   jti?: string;
 
 }
+
+export type OAuth2ErrorCode =
+  | 'invalid_request'
+  | 'invalid_client'
+  | 'invalid_grant'
+  | 'unauthorized_client'
+  | 'unsupported_grant_type'
+  | 'invalid_scope'
+
+  /**
+   * RFC 8707
+   */
+  | 'invalid_target';
+


Test patch:
diff --git a/test/authorization-code.ts b/test/authorization-code.ts
index e81c0e9..abb21eb 100644
--- a/test/authorization-code.ts
+++ b/test/authorization-code.ts
@@ -95,10 +95,7 @@ describe('authorization-code', () => {
           extraParams
         });
       } catch (error: any) {
-        expect(error.message).to.equal(
-          'The following extraParams are disallowed: \'client_id\', \'response_type\', \'redirect_uri\', ' +
-             '\'code_challenge_method\', \'code_challenge\', \'state\', \'scope\''
-        );
+        expect(error.message).to.include('Property in extraParams');
         return;
       }
 
@@ -132,6 +129,32 @@ describe('authorization-code', () => {
       ).to.equal(server.url + '/authorize?' + params.toString());
 
     });
+    it('should support the resource parameter', 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 resource = ['https://example/foo1', 'https://example/foo2'];
+      const params = new URLSearchParams({
+        client_id: 'test-client-id',
+        response_type: 'code',
+        redirect_uri: redirectUri,
+      });
+      for(const r of resource) params.append('resource', r);
+
+      expect(
+        await client.authorizationCode.getAuthorizeUri({
+          redirectUri,
+          resource,
+        })
+      ).to.equal(server.url + '/authorize?' + params.toString());
+
+    });
 
   });
 
@@ -268,5 +291,41 @@ describe('authorization-code', () => {
 
     });
 
+    it('should support the resource parameter', async() => {
+
+      const server = testServer();
+
+      const client = new OAuth2Client({
+        server: server.url,
+        tokenEndpoint: '/token',
+        clientId: 'test-client-id',
+      });
+      const resource = ['https://example/foo1', 'https://example/foo2'];
+
+      const result = await client.authorizationCode.getToken({
+        code: 'code_000',
+        redirectUri: 'http://example/redirect',
+        resource,
+      });
+
+      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',
+        resource,
+      });
+
+    });
+
+
   });
 });
diff --git a/test/client-credentials.ts b/test/client-credentials.ts
index 8d1cf9f..afba0e6 100644
--- a/test/client-credentials.ts
+++ b/test/client-credentials.ts
@@ -87,5 +87,39 @@ describe('client-credentials', () => {
       grant_type: 'client_credentials'
     });
   });
+  it('should support the resource parameter', async() => {
+
+    const server = testServer();
+
+    const client = new OAuth2Client({
+      server: server.url,
+      tokenEndpoint: '/token',
+      clientId: 'test-client-id',
+      clientSecret: 'test-client-secret',
+    });
+
+    const resource = [
+      'https://example/resource1',
+      'https://example/resource2',
+    ];
+
+    const result = await client.clientCredentials({
+      resource,
+    });
+
+    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: 'client_credentials',
+      resource,
+    });
+  });
+
 
 });
diff --git a/test/password.ts b/test/password.ts
index b6495ce..e8388d7 100644
--- a/test/password.ts
+++ b/test/password.ts
@@ -66,4 +66,41 @@ describe('password', () => {
     });
   });
 
+  it('should support the resource parameter', async () => {
+
+    const server = testServer();
+
+    const client = new OAuth2Client({
+      server: server.url,
+      tokenEndpoint: '/token',
+      clientId: 'test-client-id',
+      authenticationMethod: 'client_secret_post'
+    });
+    const resource = [
+      'https://example/resource1',
+      'https://example/resource2',
+    ];
+
+    const result = await client.password({
+      username: 'user123',
+      password: 'password',
+      resource,
+    });
+
+    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',
+      resource,
+    });
+  });
+
 });