Repository: badgateway/oauth2-client (ts)

Issue:
Support response_mode=fragment
When passing response_mode=fragment to the autorization code flow request, most OAuth 2.0 and OIDC servers will respond with the code, state etc encoded as a URL fragment instead of query params. 

This has the security benefit of not showing up in the server logs because fragments are not sent to the server. Especially important when using a CDN or other external service to host the frontend, which usually shouldn't have access to sensitive credentials.

It would be good if GetAuthorizeUrlParams would have an additional attrubute `response_mode` which can be set to `"query"` or `"fragment"`. getTokenFromCodeRedirect would also need to be able to handle this kind of response.

Pull request description:
Support for response_mode=fragment
Fixes #143 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7bec66a..38d3352 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, 20.x]
+        node-version: [14.x, 16.x, 18.x, 20.x, 22.x]
         # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
 
     steps:
diff --git a/README.md b/README.md
index 0ea5fbe..cd5232b 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ since Node 18 (but it works with Polyfills on Node 14 and 16).
 * OAuth2 Token Introspection ([RFC7662][3]).
 * Resource Indicators for OAuth 2.0 ([RFC8707][5]).
 * OAuth2 Token Revocation ([RFC7009][6]).
+* [OAuth 2.0 Multiple Response Type Encoding Practices](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html)
 
 ## Installation
 
diff --git a/changelog.md b/changelog.md
index 8b6ca5e..07d0855 100644
--- a/changelog.md
+++ b/changelog.md
@@ -6,6 +6,7 @@ Changelog
 
 * More robust error handling. When an error is emitted, you now give you access
   to the emitted HTTP Response and response body.
+* Support for `response_mode=fragment` in the `authorization_code` flow.
 
 
 2.3.0 (2024-02-03)
diff --git a/src/client/authorization-code.ts b/src/client/authorization-code.ts
index 144db62..b86feb8 100644
--- a/src/client/authorization-code.ts
+++ b/src/client/authorization-code.ts
@@ -38,6 +38,19 @@ type GetAuthorizeUrlParams = {
    * Any parameters listed here will be added to the query string for the authorization server endpoint.
    */
   extraParams?: Record<string, string>;
+
+  /**
+   * By default response parameters for the authorization_flow will be added
+   * to the query string.
+   *
+   * Some servers let you put this in the fragment instead. This may be
+   * benefical if your client is a browser, as embedding the authorization
+   * code in the fragment part of the URI prevents it from being sent back
+   * to the server.
+   *
+   * See: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
+   */
+  responseMode?: 'query' | 'fragment';
 }
 
 type ValidateResponseResult = {
@@ -114,6 +127,11 @@ export class OAuth2AuthorizationCodeClient {
     if (params.resource) for(const resource of [].concat(params.resource as any)) {
       query.append('resource', resource);
     }
+
+    if (params.responseMode && params.responseMode!=='query') {
+      query.append('response_mode', params.responseMode);
+    }
+
     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);
@@ -125,7 +143,7 @@ export class OAuth2AuthorizationCodeClient {
 
   async getTokenFromCodeRedirect(url: string|URL, params: Omit<GetTokenParams, 'code'> ): Promise<OAuth2Token> {
 
-    const { code } = await this.validateResponse(url, {
+    const { code } = this.validateResponse(url, {
       state: params.state
     });
 
@@ -144,9 +162,14 @@ export class OAuth2AuthorizationCodeClient {
    * This function takes the url and validate the response. If the user
    * redirected back with an error, an error will be thrown.
    */
-  async validateResponse(url: string|URL, params: {state?: string}): Promise<ValidateResponseResult> {
+  validateResponse(url: string|URL, params: {state?: string}): ValidateResponseResult {
 
-    const queryParams = new URL(url).searchParams;
+    url = new URL(url);
+    let queryParams = url.searchParams;
+    if (!queryParams.has('code') && !queryParams.has('error') && url.hash.length>0) {
+      // Try the fragment
+      queryParams = new URLSearchParams(url.hash.slice(1));
+    }
 
     if (queryParams.has('error')) {
       throw new OAuth2Error(
diff --git a/src/messages.ts b/src/messages.ts
index 0aa5961..30ecd65 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -76,6 +76,7 @@ export type TokenResponse = {
 }
 
 type OAuth2ResponseType = 'code' | 'token';
+type OAuth2ResponseMode = 'query' | 'fragment';
 type OAuth2GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials' | 'refresh_token' | 'urn:ietf:params:oauth:grant-type:jwt-bearer' | 'urn:ietf:params:oauth:grant-type:saml2-bearer';
 type OAuth2AuthMethod = 'none' | 'client_secret_basic' | 'client_secret_post' | 'client_secret_jwt' | 'private_key_jwt' | 'tls_client_auth' | 'self_signed_tls_client_auth';
 type OAuth2CodeChallengeMethod = 'S256' | 'plain';
@@ -133,7 +134,7 @@ export type ServerMetadataResponse = {
    * JSON array containing a list of the OAuth 2.0 "response_mode"
    * values that this authorization server supports
    */
-  response_modes_supported?: string[];
+  response_modes_supported?: OAuth2ResponseMode[];
 
   /**
    * List of supported grant types by the server


Test patch:
diff --git a/test/authorization-code.ts b/test/authorization-code.ts
index abb21eb..755bac4 100644
--- a/test/authorization-code.ts
+++ b/test/authorization-code.ts
@@ -326,6 +326,59 @@ describe('authorization-code', () => {
 
     });
 
+  });
+
+
+  describe('validateResponse', () => {
+
+    const client = new OAuth2Client({
+      server: 'http://foo/',
+      tokenEndpoint: '/token',
+      clientId: 'test-client-id',
+    });
+
+    it('should correctly parse a valid URI from a OAUth2 server redirect', () => {
+
+      expect(
+        client.authorizationCode.validateResponse('https://example/?code=123&scope=scope1%20scope2', {})
+      ).to.deep.equal({
+        code: '123',
+        scope: ['scope1', 'scope2']
+      });
+
+    });
+    it('should work when paramaters are set into the fragment', () => {
+
+      expect(
+        client.authorizationCode.validateResponse('https://example/#code=123&scope=scope1%20scope2', {})
+      ).to.deep.equal({
+        code: '123',
+        scope: ['scope1', 'scope2']
+      });
+
+    });
+    it('should validate the state parameter', () => {
+
+      expect(
+        client.authorizationCode.validateResponse('https://example/?code=123&scope=scope1%20scope2&state=my-state', {state: 'my-state'})
+      ).to.deep.equal({
+        code: '123',
+        scope: ['scope1', 'scope2']
+      });
+
+    });
+    it('should error if the state did not match', () => {
+
+      let caught = false;
+      try {
+        client.authorizationCode.validateResponse('https://example/?code=123&scope=scope1%20scope2', {state: 'my-state'});
+      } catch (err) {
+        caught = true;
+      }
+      expect(caught).to.equal(true);
+
+    });
 
   });
+
 });