Repository: authts/oidc-client-ts
Pull request #1691 (2024-10-08 04:37:56)

Add `removeState` as an option to `processSigninResponse`
Use case:

We have a custom extension to OAuth/OIDC where the `code` is an OTP that the user is sent when the OAuth handshake starts. This code is used instead of the typical randomly generated code in exchange for an access token.

If this code is incorrect, the user cannot retry as the state (including PKCE challenge) gets removed from the store as soon as the code is used. With this change, we can pass `removeState = true` which means even if the validation fails, it can be reattempted with a different code.

Without this change, we have to mimic the behaviour of `processSigninResponse` by:
- Calling `readSigninResponseState` directly with `removeState = false`
- Editing the prototype of `OidcClient` so that `_validator.validateSigninResponse()` can be called by the application.
```ts
OidcClient.prototype.getValidator = function () {
  return this._validator;
};
```

### Checklist

- [x] This PR makes changes to the public API <!-- was the API report (docs/oidc-client-ts.api.md) updated by this PR? -->
- [ ] I have included links for closing relevant issue numbers



Patch:
diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md
index 6b4e6e4d..fb538ec2 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -346,7 +346,7 @@ export class OidcClient {
     // (undocumented)
     processResourceOwnerPasswordCredentials({ username, password, skipUserInfo, extraTokenParams, }: ProcessResourceOwnerPasswordCredentialsArgs): Promise<SigninResponse>;
     // (undocumented)
-    processSigninResponse(url: string, extraHeaders?: Record<string, ExtraHeader>): Promise<SigninResponse>;
+    processSigninResponse(url: string, extraHeaders?: Record<string, ExtraHeader>, removeState?: boolean): Promise<SigninResponse>;
     // (undocumented)
     processSignoutResponse(url: string): Promise<SignoutResponse>;
     // (undocumented)
diff --git a/package-lock.json b/package-lock.json
index 68651a02..20c31a3e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1994,9 +1994,9 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "22.7.4",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz",
-      "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==",
+      "version": "22.7.5",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
+      "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
       "dev": true,
       "dependencies": {
         "undici-types": "~6.19.2"
@@ -4422,9 +4422,9 @@
       }
     },
     "node_modules/http-proxy-middleware": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.2.tgz",
-      "integrity": "sha512-fBLFpmvDzlxdckwZRjM0wWtwDZ4KBtQ8NFqhrFKoEtK4myzuiumBuNTxD+F4cVbXfOZljIbrynmvByofDzT7Ag==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.3.tgz",
+      "integrity": "sha512-usY0HG5nyDUwtqpiZdETNbmKtw3QQ1jwYFZ9wi5iHzX2BcILwQKtYDJPo7XHTsu5Z0B2Hj3W9NNnbd+AjFWjqg==",
       "dev": true,
       "dependencies": {
         "@types/http-proxy": "^1.17.15",
@@ -5388,9 +5388,9 @@
       "dev": true
     },
     "node_modules/jose": {
-      "version": "5.9.3",
-      "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.3.tgz",
-      "integrity": "sha512-egLIoYSpcd+QUF+UHgobt5YzI2Pkw/H39ou9suW687MY6PmCwPmkNV/4TNjn1p2tX5xO3j0d0sq5hiYE24bSlg==",
+      "version": "5.9.4",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.4.tgz",
+      "integrity": "sha512-WBBl6au1qg6OHj67yCffCgFR3BADJBXN8MdRvCgJDuMv3driV2nHr7jdGvaKX9IolosAsn+M0XRArqLXUhyJHQ==",
       "dev": true,
       "funding": {
         "url": "https://github.com/sponsors/panva"
@@ -7254,9 +7254,9 @@
       }
     },
     "node_modules/typedoc": {
-      "version": "0.26.7",
-      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.7.tgz",
-      "integrity": "sha512-gUeI/Wk99vjXXMi8kanwzyhmeFEGv1LTdTQsiyIsmSYsBebvFxhbcyAx7Zjo4cMbpLGxM4Uz3jVIjksu/I2v6Q==",
+      "version": "0.26.8",
+      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.8.tgz",
+      "integrity": "sha512-QBF0BMbnNeUc6U7pRHY7Jb8pjhmiNWZNQT8LU6uk9qP9t3goP9bJptdlNqMC0wBB2w9sQrxjZt835bpRSSq1LA==",
       "dev": true,
       "dependencies": {
         "lunr": "^2.3.9",
diff --git a/samples/Parcel/package-lock.json b/samples/Parcel/package-lock.json
index d0f7c5e1..0079a3ae 100644
--- a/samples/Parcel/package-lock.json
+++ b/samples/Parcel/package-lock.json
@@ -6,7 +6,7 @@
     "": {
       "name": "parcel-sample",
       "dependencies": {
-        "express": "^4.21.0",
+        "express": "^4.21.1",
         "jsrsasign": "^11.1.0"
       },
       "devDependencies": {
@@ -2356,9 +2356,9 @@
       }
     },
     "node_modules/cookie": {
-      "version": "0.6.0",
-      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
-      "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+      "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
       "engines": {
         "node": ">= 0.6"
       }
@@ -2792,16 +2792,16 @@
       }
     },
     "node_modules/express": {
-      "version": "4.21.0",
-      "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
-      "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
+      "version": "4.21.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
+      "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
       "dependencies": {
         "accepts": "~1.3.8",
         "array-flatten": "1.1.1",
         "body-parser": "1.20.3",
         "content-disposition": "0.5.4",
         "content-type": "~1.0.4",
-        "cookie": "0.6.0",
+        "cookie": "0.7.1",
         "cookie-signature": "1.0.6",
         "debug": "2.6.9",
         "depd": "2.0.0",
diff --git a/samples/Parcel/package.json b/samples/Parcel/package.json
index a3e6d775..09af77e3 100644
--- a/samples/Parcel/package.json
+++ b/samples/Parcel/package.json
@@ -8,7 +8,7 @@
     "server": "node ./server.js"
   },
   "dependencies": {
-    "express": "^4.21.0",
+    "express": "^4.21.1",
     "jsrsasign": "^11.1.0"
   },
   "devDependencies": {
diff --git a/src/OidcClient.ts b/src/OidcClient.ts
index efab56b8..29f3a7cb 100644
--- a/src/OidcClient.ts
+++ b/src/OidcClient.ts
@@ -172,10 +172,10 @@ export class OidcClient {
         return { state, response };
     }
 
-    public async processSigninResponse(url: string, extraHeaders?: Record<string, ExtraHeader>): Promise<SigninResponse> {
+    public async processSigninResponse(url: string, extraHeaders?: Record<string, ExtraHeader>, removeState = true): Promise<SigninResponse> {
         const logger = this._logger.create("processSigninResponse");
 
-        const { state, response } = await this.readSigninResponseState(url, true);
+        const { state, response } = await this.readSigninResponseState(url, removeState);
         logger.debug("received state from storage; validating response");
 
         if (this.settings.dpop && this.settings.dpop.store) {


Test patch:
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index 8ee7b7b6..6af0e15a 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -465,6 +465,30 @@ describe("OidcClient", () => {
             expect(getDpopProofMock).toHaveBeenCalledTimes(2);
             expect(getDpopProofMock).toHaveBeenNthCalledWith(2, { "_dbName": "oidc", "_storeName": "dpop" }, "some-nonce");
         });
+
+        it("should not delete state when removeState = false", async () => {
+            // arrange
+            const item = await SigninState.create({
+                id: "1",
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "http://app/cb",
+                scope: "scope",
+                request_type: "type",
+            });
+            const getStateMock = jest.spyOn(subject.settings.stateStore, "get")
+                .mockImplementation(async () => item.toStorageString());
+            const removeStateMock = jest.spyOn(subject.settings.stateStore, "remove")
+                .mockImplementation(async () => item.toStorageString());
+            jest.spyOn(subject["_validator"], "validateSigninResponse").mockResolvedValue();
+
+            // act
+            await subject.processSigninResponse("http://app/cb?state=1", undefined, false);
+
+            // assert
+            expect(getStateMock).toHaveBeenCalled();
+            expect(removeStateMock).not.toHaveBeenCalled();
+        });
     });
 
     describe("processResourceOwnerPasswordCredentials", () => {