Repository: authts/oidc-client-ts
Pull request #1569 (2024-07-02 01:36:23)

Add dpop nonce support
This is a first cut aimed at adding `DPoP` nonce support. I initially removed this feature in the original [PR](https://github.com/authts/oidc-client-ts/pull/1461) and this is a follow up.

This is very experimental and open to change. @pamapa I would really appreciate your thoughts as to how this could be done more cleanly 🙏🏼

The main functionality is supporting [Authorization Server Provided Nonces](https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid).

According to the spec, if/when an AS requires the use of a nonce and a token request is made without a nonce claim within the DPoP proof JWT, the AS responds with a specific 400 bad request that includes a response header `dpop-nonce` which contains the nonce value to be used. The client is then expected to regenerate its DPoP proof again with the included nonce, and then retry the token request.

>Upon receiving the nonce, the client is expected to retry its token request using a DPoP proof including the supplied nonce value in the nonce claim of the DPoP proof. An example unencoded JWT payload of such a DPoP proof including a nonce is shown below.

e.g.

```
HTTP/1.1 400 Bad Request
 DPoP-Nonce: eyJ7S_zG.eyJH0-Z.HX4w-7v

 {
  "error": "use_dpop_nonce",
  "error_description":
    "Authorization server requires nonce in DPoP proof"
 }
[Figure 20](https://datatracker.ietf.org/doc/html/rfc9449#figure-20): [HTTP 400 Response to a Token Request without a Nonce](https://datatracker.ietf.org/doc/html/rfc9449#name-http-400-response-to-a-toke)
Other HTTP headers and JSON fields MAY also be included in the error response, but there MUST NOT be more than one DPoP-Nonce header.

Upon receiving the nonce, the client is expected to retry its token request using a DPoP proof including the supplied nonce value in the nonce claim of the DPoP proof. An example unencoded JWT payload of such a DPoP proof including a nonce is shown below.

 {
  "jti": "-BwC3ESc6acc2lTc",
  "htm": "POST",
  "htu": "https://server.example.com/token",
  "iat": 1562262616,
  "nonce": "eyJ7S_zG.eyJH0-Z.HX4w-7v"
 }

```


In order to accomodate this I have added a new abstraction `DPoPState`, which is an object that contains both the CryptoKeyPair and the optional nonce value. I have altered the `DPoPStore` interface to accomodate this as the nonce can be used repeatedly for token requests until the AS decides to invalidate.

Which brings me to my next point, the spec is quite open as to how an AS may [provide a new nonce](https://datatracker.ietf.org/doc/html/rfc9449#name-providing-a-new-nonce-value).

>The authorization server MAY supply the new nonce in the same way that the initial one was supplied: by using a DPoP-Nonce HTTP header in the response. The DPoP-Nonce HTTP header field uses the nonce syntax defined in [Section 8.1](https://datatracker.ietf.org/doc/html/rfc9449#NonceSyntax). Each time this happens, it requires an extra protocol round trip.

>A more efficient manner of supplying a new nonce value is also defined by including a DPoP-Nonce HTTP header in the HTTP 200 (OK) response from the previous request. The client MUST use the new nonce value supplied for the next token request and for all subsequent token requests until the authorization server supplies a new nonce.

I attempted to try and add a way to handle a new nonce upon a 200 response but it was going to be messy and potentially involved altering the `SigninResponse`  class, which didn't feel right. I looked at how IdentityServer issues new nonces and they just [re-use the 400 bad request method](https://github.com/DuendeSoftware/IdentityServer/blob/c1e1508c0749265dae983601e7ed7b88eac8fe8b/src/IdentityServer/Validation/Default/DefaultDPoPProofValidator.cs#L414-L422). I think we should leave this out for the time being.

In regards to supporting [Resource Server provided nonces](https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no), that is a lot simpler as the client should manage storing and renewing the resource provided nonce as it will be returned on a client initiated fetch request to the RS - it has nothing to do with `oidc-client-ts`. For now I have opted to provide a way to supply the nonce as an [optional parameter when creating the DPoPProof on the user manager class](https://github.com/authts/oidc-client-ts/compare/main...dbfr3qs:oidc-client-ts:add-dpop-nonce-support?expand=1#diff-be27343df6f02daa157014989cbc33093c0d605f2103d55d569a6dd1ab3fdf84R820). We can revisit if we want to offer a way for clients to store the RS provided nonce values and/or provide helpers to manage them, but this interaction falls outside the relationship between the client and the AS. 


### Checklist

- [ ] 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 02c583e6..838553e6 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -63,6 +63,15 @@ export type CreateSignoutRequestArgs = Omit<SignoutRequestArgs, "url" | "state_d
     state?: unknown;
 };
 
+// @public (undocumented)
+export class DPoPState {
+    constructor(keys: CryptoKeyPair, nonce?: string | undefined);
+    // (undocumented)
+    readonly keys: CryptoKeyPair;
+    // (undocumented)
+    nonce?: string | undefined;
+}
+
 // @public
 export class ErrorResponse extends Error {
     constructor(args: {
@@ -150,15 +159,15 @@ export class IndexedDbDPoPStore implements DPoPStore {
     // (undocumented)
     readonly _dbName: string;
     // (undocumented)
-    get(key: string): Promise<CryptoKeyPair>;
+    get(key: string): Promise<DPoPState>;
     // (undocumented)
     getAllKeys(): Promise<string[]>;
     // (undocumented)
     promisifyRequest<T = undefined>(request: IDBRequest<T> | IDBTransaction): Promise<T>;
     // (undocumented)
-    remove(key: string): Promise<CryptoKeyPair>;
+    remove(key: string): Promise<DPoPState>;
     // (undocumented)
-    set(key: string, value: CryptoKeyPair): Promise<void>;
+    set(key: string, value: DPoPState): Promise<void>;
     // (undocumented)
     readonly _storeName: string;
 }
@@ -329,7 +338,7 @@ export class OidcClient {
     // (undocumented)
     createSignoutRequest({ state, id_token_hint, client_id, request_type, post_logout_redirect_uri, extraQueryParams, }?: CreateSignoutRequestArgs): Promise<SignoutRequest>;
     // (undocumented)
-    getDpopProof(dpopStore: DPoPStore): Promise<string>;
+    getDpopProof(dpopStore: DPoPStore, nonce?: string): Promise<string>;
     // (undocumented)
     protected readonly _logger: Logger;
     // (undocumented)
@@ -989,7 +998,7 @@ export class UserManager {
     clearStaleState(): Promise<void>;
     // (undocumented)
     protected readonly _client: OidcClient;
-    dpopProof(url: string, user: User, httpMethod?: string): Promise<string | undefined>;
+    dpopProof(url: string, user: User, httpMethod?: string, nonce?: string): Promise<string | undefined>;
     get events(): UserManagerEvents;
     // (undocumented)
     protected readonly _events: UserManagerEvents;
diff --git a/package-lock.json b/package-lock.json
index dfad705a..2c6e3694 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
         "@types/node": "^20.8.2",
         "@typescript-eslint/eslint-plugin": "^6.4.1",
         "@typescript-eslint/parser": "^6.4.1",
-        "esbuild": "^0.21.1",
+        "esbuild": "^0.23.0",
         "eslint": "^8.5.0",
         "eslint-plugin-testing-library": "^6.0.0",
         "fake-indexeddb": "^6.0.0",
@@ -632,9 +632,9 @@
       "dev": true
     },
     "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",
+      "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==",
       "cpu": [
         "ppc64"
       ],
@@ -644,13 +644,13 @@
         "aix"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/android-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
-      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz",
+      "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==",
       "cpu": [
         "arm"
       ],
@@ -660,13 +660,13 @@
         "android"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/android-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
-      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz",
+      "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==",
       "cpu": [
         "arm64"
       ],
@@ -676,13 +676,13 @@
         "android"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/android-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
-      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz",
+      "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==",
       "cpu": [
         "x64"
       ],
@@ -692,13 +692,13 @@
         "android"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
-      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz",
+      "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==",
       "cpu": [
         "arm64"
       ],
@@ -708,13 +708,13 @@
         "darwin"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/darwin-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
-      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz",
+      "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==",
       "cpu": [
         "x64"
       ],
@@ -724,13 +724,13 @@
         "darwin"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
-      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==",
       "cpu": [
         "arm64"
       ],
@@ -740,13 +740,13 @@
         "freebsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
-      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz",
+      "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==",
       "cpu": [
         "x64"
       ],
@@ -756,13 +756,13 @@
         "freebsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
-      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz",
+      "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==",
       "cpu": [
         "arm"
       ],
@@ -772,13 +772,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
-      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz",
+      "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==",
       "cpu": [
         "arm64"
       ],
@@ -788,13 +788,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
-      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz",
+      "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==",
       "cpu": [
         "ia32"
       ],
@@ -804,13 +804,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-loong64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
-      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz",
+      "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==",
       "cpu": [
         "loong64"
       ],
@@ -820,13 +820,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
-      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz",
+      "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==",
       "cpu": [
         "mips64el"
       ],
@@ -836,13 +836,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz",
+      "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==",
       "cpu": [
         "ppc64"
       ],
@@ -852,13 +852,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
-      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz",
+      "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==",
       "cpu": [
         "riscv64"
       ],
@@ -868,13 +868,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-s390x": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
-      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz",
+      "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==",
       "cpu": [
         "s390x"
       ],
@@ -884,13 +884,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
-      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz",
+      "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==",
       "cpu": [
         "x64"
       ],
@@ -900,13 +900,13 @@
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==",
       "cpu": [
         "x64"
       ],
@@ -916,13 +916,29 @@
         "netbsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==",
       "cpu": [
         "x64"
       ],
@@ -932,13 +948,13 @@
         "openbsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/sunos-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
-      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz",
+      "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==",
       "cpu": [
         "x64"
       ],
@@ -948,13 +964,13 @@
         "sunos"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/win32-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
-      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz",
+      "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==",
       "cpu": [
         "arm64"
       ],
@@ -964,13 +980,13 @@
         "win32"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/win32-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
-      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz",
+      "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==",
       "cpu": [
         "ia32"
       ],
@@ -980,13 +996,13 @@
         "win32"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/win32-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
-      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz",
+      "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==",
       "cpu": [
         "x64"
       ],
@@ -996,7 +1012,7 @@
         "win32"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@eslint-community/eslint-utils": {
@@ -3359,41 +3375,42 @@
       }
     },
     "node_modules/esbuild": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
-      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz",
+      "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==",
       "dev": true,
       "hasInstallScript": true,
       "bin": {
         "esbuild": "bin/esbuild"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       },
       "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.21.5",
-        "@esbuild/android-arm": "0.21.5",
-        "@esbuild/android-arm64": "0.21.5",
-        "@esbuild/android-x64": "0.21.5",
-        "@esbuild/darwin-arm64": "0.21.5",
-        "@esbuild/darwin-x64": "0.21.5",
-        "@esbuild/freebsd-arm64": "0.21.5",
-        "@esbuild/freebsd-x64": "0.21.5",
-        "@esbuild/linux-arm": "0.21.5",
-        "@esbuild/linux-arm64": "0.21.5",
-        "@esbuild/linux-ia32": "0.21.5",
-        "@esbuild/linux-loong64": "0.21.5",
-        "@esbuild/linux-mips64el": "0.21.5",
-        "@esbuild/linux-ppc64": "0.21.5",
-        "@esbuild/linux-riscv64": "0.21.5",
-        "@esbuild/linux-s390x": "0.21.5",
-        "@esbuild/linux-x64": "0.21.5",
-        "@esbuild/netbsd-x64": "0.21.5",
-        "@esbuild/openbsd-x64": "0.21.5",
-        "@esbuild/sunos-x64": "0.21.5",
-        "@esbuild/win32-arm64": "0.21.5",
-        "@esbuild/win32-ia32": "0.21.5",
-        "@esbuild/win32-x64": "0.21.5"
+        "@esbuild/aix-ppc64": "0.23.0",
+        "@esbuild/android-arm": "0.23.0",
+        "@esbuild/android-arm64": "0.23.0",
+        "@esbuild/android-x64": "0.23.0",
+        "@esbuild/darwin-arm64": "0.23.0",
+        "@esbuild/darwin-x64": "0.23.0",
+        "@esbuild/freebsd-arm64": "0.23.0",
+        "@esbuild/freebsd-x64": "0.23.0",
+        "@esbuild/linux-arm": "0.23.0",
+        "@esbuild/linux-arm64": "0.23.0",
+        "@esbuild/linux-ia32": "0.23.0",
+        "@esbuild/linux-loong64": "0.23.0",
+        "@esbuild/linux-mips64el": "0.23.0",
+        "@esbuild/linux-ppc64": "0.23.0",
+        "@esbuild/linux-riscv64": "0.23.0",
+        "@esbuild/linux-s390x": "0.23.0",
+        "@esbuild/linux-x64": "0.23.0",
+        "@esbuild/netbsd-x64": "0.23.0",
+        "@esbuild/openbsd-arm64": "0.23.0",
+        "@esbuild/openbsd-x64": "0.23.0",
+        "@esbuild/sunos-x64": "0.23.0",
+        "@esbuild/win32-arm64": "0.23.0",
+        "@esbuild/win32-ia32": "0.23.0",
+        "@esbuild/win32-x64": "0.23.0"
       }
     },
     "node_modules/escalade": {
@@ -5191,9 +5208,9 @@
       "dev": true
     },
     "node_modules/jose": {
-      "version": "5.6.2",
-      "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.2.tgz",
-      "integrity": "sha512-F1t1/WZJ4JdmCE/XoMYw1dPOW5g8JF0xGm6Ox2fwaCAPlCzt+4Bh0EWP59iQuZNHHauDkCdjx+kCZSh5z/PGow==",
+      "version": "5.6.3",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-5.6.3.tgz",
+      "integrity": "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==",
       "dev": true,
       "funding": {
         "url": "https://github.com/sponsors/panva"
diff --git a/package.json b/package.json
index 39e9d0b0..c3b2367f 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
     "@types/node": "^20.8.2",
     "@typescript-eslint/eslint-plugin": "^6.4.1",
     "@typescript-eslint/parser": "^6.4.1",
-    "esbuild": "^0.21.1",
+    "esbuild": "^0.23.0",
     "eslint": "^8.5.0",
     "eslint-plugin-testing-library": "^6.0.0",
     "http-proxy-middleware": "^3.0.0",
diff --git a/src/DPoPStore.ts b/src/DPoPStore.ts
index 3dd06f6f..31607a38 100644
--- a/src/DPoPStore.ts
+++ b/src/DPoPStore.ts
@@ -2,8 +2,18 @@
  * @public
  */
 export interface DPoPStore {
-    set(key: string, value: CryptoKeyPair): Promise<void>;
-    get(key: string): Promise<CryptoKeyPair>;
-    remove(key: string): Promise<CryptoKeyPair>;
+    set(key: string, value: DPoPState): Promise<void>;
+    get(key: string): Promise<DPoPState>;
+    remove(key: string): Promise<DPoPState>;
     getAllKeys(): Promise<string[]>;
 }
+
+/**
+ * @public
+ */
+export class DPoPState {
+    public constructor(
+        public readonly keys: CryptoKeyPair,
+        public nonce?: string,
+    ) { }
+}
diff --git a/src/IndexedDbDPoPStore.ts b/src/IndexedDbDPoPStore.ts
index f90ef2e6..396cf8f9 100644
--- a/src/IndexedDbDPoPStore.ts
+++ b/src/IndexedDbDPoPStore.ts
@@ -1,4 +1,4 @@
-import type { DPoPStore } from "./DPoPStore";
+import { DPoPState, type DPoPStore } from "./DPoPStore";
 
 /**
  * Provides a default implementation of the DPoP store using IndexedDB.
@@ -9,7 +9,7 @@ export class IndexedDbDPoPStore implements DPoPStore {
     readonly _dbName: string = "oidc";
     readonly _storeName: string = "dpop";
 
-    public async set(key: string, value: CryptoKeyPair): Promise<void> {
+    public async set(key: string, value: DPoPState): Promise<void> {
         const store = await this.createStore(this._dbName, this._storeName);
         await store("readwrite", (str: IDBObjectStore) => {
             str.put(value, key);
@@ -17,14 +17,14 @@ export class IndexedDbDPoPStore implements DPoPStore {
         });
     }
 
-    public async get(key: string): Promise<CryptoKeyPair> {
+    public async get(key: string): Promise<DPoPState> {
         const store = await this.createStore(this._dbName, this._storeName);
         return await store("readonly", (str) => {
             return this.promisifyRequest(str.get(key));
-        }) as CryptoKeyPair;
+        }) as DPoPState;
     }
 
-    public async remove(key: string): Promise<CryptoKeyPair> {
+    public async remove(key: string): Promise<DPoPState> {
         const item = await this.get(key);
         const store = await this.createStore(this._dbName, this._storeName);
         await store("readwrite", (str) => {
diff --git a/src/JsonService.ts b/src/JsonService.ts
index d7561178..55eaa5cc 100644
--- a/src/JsonService.ts
+++ b/src/JsonService.ts
@@ -4,6 +4,7 @@
 import { ErrorResponse, ErrorTimeout } from "./errors";
 import type { ExtraHeader } from "./OidcClientSettings";
 import { Logger } from "./utils";
+import { ErrorDPoPNonce } from "./errors/ErrorDPoPNonce";
 
 /**
  * @internal
@@ -180,6 +181,10 @@ export class JsonService {
 
         if (!response.ok) {
             logger.error("Error from server:", json);
+            if (response.headers.has("dpop-nonce")) {
+                const nonce = response.headers.get("dpop-nonce") as string;
+                throw new ErrorDPoPNonce(nonce, `${JSON.stringify(json)}`);
+            }
             if (json.error) {
                 throw new ErrorResponse(json, body);
             }
diff --git a/src/OidcClient.ts b/src/OidcClient.ts
index 57500795..efab56b8 100644
--- a/src/OidcClient.ts
+++ b/src/OidcClient.ts
@@ -15,7 +15,8 @@ import { SigninState } from "./SigninState";
 import { State } from "./State";
 import { TokenClient } from "./TokenClient";
 import { ClaimsService } from "./ClaimsService";
-import type { DPoPStore } from "./DPoPStore";
+import { DPoPState, type DPoPStore } from "./DPoPStore";
+import { ErrorDPoPNonce } from "./errors/ErrorDPoPNonce";
 
 /**
  * @public
@@ -182,24 +183,53 @@ export class OidcClient {
             extraHeaders = { ...extraHeaders, "DPoP": dpopProof };
         }
 
-        await this._validator.validateSigninResponse(response, state, extraHeaders);
+        /**
+         * The DPoP spec describes a method for Authorization Servers to supply a nonce value
+         * in order to limit the lifetime of a given DPoP proof.
+         * See https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
+         * This involves the AS returning a 400 bad request with a DPoP-Nonce header containing
+         * the nonce value. The client must then retry the request with a recomputed DPoP proof
+         * containing the supplied nonce value.
+         */
+        try {
+            await this._validator.validateSigninResponse(response, state, extraHeaders);
+        }
+        catch (err) {
+            if (err instanceof ErrorDPoPNonce && this.settings.dpop) {
+                const dpopProof = await this.getDpopProof(this.settings.dpop.store, err.nonce);
+                extraHeaders!["DPoP"] = dpopProof;
+                await this._validator.validateSigninResponse(response, state, extraHeaders);
+            } else {
+                throw err;
+            }
+        }
+
         return response;
     }
 
-    async getDpopProof(dpopStore: DPoPStore): Promise<string> {
+    async getDpopProof(dpopStore: DPoPStore, nonce?: string): Promise<string> {
         let keyPair: CryptoKeyPair;
+        let dpopState: DPoPState;
 
         if (!(await dpopStore.getAllKeys()).includes(this.settings.client_id)) {
             keyPair = await CryptoUtils.generateDPoPKeys();
-            await dpopStore.set(this.settings.client_id, keyPair);
+            dpopState = new DPoPState(keyPair, nonce);
+            await dpopStore.set(this.settings.client_id, dpopState);
         } else {
-            keyPair = await dpopStore.get(this.settings.client_id);
+            dpopState = await dpopStore.get(this.settings.client_id);
+
+            // if the server supplied nonce has changed since the last request, update the nonce
+            if (dpopState.nonce !== nonce && nonce) {
+                dpopState.nonce = nonce;
+                await dpopStore.set(this.settings.client_id, dpopState);
+            }
         }
 
         return await CryptoUtils.generateDPoPProof({
             url: await this.metadataService.getTokenEndpoint(false),
             httpMethod: "POST",
-            keyPair: keyPair,
+            keyPair: dpopState.keys,
+            nonce: dpopState.nonce,
         });
     }
 
@@ -244,16 +274,44 @@ export class OidcClient {
             extraHeaders = { ...extraHeaders, "DPoP": dpopProof };
         }
 
-        const result = await this._tokenClient.exchangeRefreshToken({
-            refresh_token: state.refresh_token,
-            // provide the (possible filtered) scope list
-            scope,
-            redirect_uri,
-            resource,
-            timeoutInSeconds,
-            extraHeaders,
-            ...extraTokenParams,
-        });
+        /**
+         * The DPoP spec describes a method for Authorization Servers to supply a nonce value
+         * in order to limit the lifetime of a given DPoP proof.
+         * See https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
+         * This involves the AS returning a 400 bad request with a DPoP-Nonce header containing
+         * the nonce value. The client must then retry the request with a recomputed DPoP proof
+         * containing the supplied nonce value.
+         */
+        let result;
+        try {
+            result = await this._tokenClient.exchangeRefreshToken({
+                refresh_token: state.refresh_token,
+                // provide the (possible filtered) scope list
+                scope,
+                redirect_uri,
+                resource,
+                timeoutInSeconds,
+                extraHeaders,
+                ...extraTokenParams,
+            });
+        } catch (err) {
+            if (err instanceof ErrorDPoPNonce && this.settings.dpop) {
+                extraHeaders!["DPoP"] = await this.getDpopProof(this.settings.dpop.store, err.nonce);
+                result = await this._tokenClient.exchangeRefreshToken({
+                    refresh_token: state.refresh_token,
+                    // provide the (possible filtered) scope list
+                    scope,
+                    redirect_uri,
+                    resource,
+                    timeoutInSeconds,
+                    extraHeaders,
+                    ...extraTokenParams,
+                });
+            } else {
+                throw err;
+            }
+        }
+
         const response = new SigninResponse(new URLSearchParams());
         Object.assign(response, result);
         logger.debug("validating response", response);
diff --git a/src/UserManager.ts b/src/UserManager.ts
index ccc6471b..aafe47c5 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -16,6 +16,7 @@ import type { MetadataService } from "./MetadataService";
 import { RefreshState } from "./RefreshState";
 import type { SigninResponse } from "./SigninResponse";
 import type { ExtraHeader, DPoPSettings } from "./OidcClientSettings";
+import { DPoPState } from "./DPoPStore";
 
 /**
  * @public
@@ -812,28 +813,31 @@ export class UserManager {
      * @param url - The URL to generate the DPoP proof for
      * @param user - The user to generate the DPoP proof for
      * @param httpMethod - Optional, defaults to "GET"
+     * @param nonce - Optional nonce provided by the resource server
      *
      * @returns A promise containing the DPoP proof or undefined if DPoP is not enabled/no user is found.
      */
-    public async dpopProof(url: string, user: User, httpMethod?: string): Promise<string | undefined> {
-        const dpopKey = await this.settings.dpop?.store?.get(this.settings.client_id);
-        if (dpopKey) {
+    public async dpopProof(url: string, user: User, httpMethod?: string, nonce?: string): Promise<string | undefined> {
+        const dpopState = await this.settings.dpop?.store?.get(this.settings.client_id);
+        if (dpopState) {
             return await CryptoUtils.generateDPoPProof({
                 url,
                 accessToken: user?.access_token,
                 httpMethod: httpMethod,
-                keyPair: dpopKey,
+                keyPair: dpopState.keys,
+                nonce,
             });
         }
         return undefined;
     }
 
     async generateDPoPJkt(dpopSettings: DPoPSettings): Promise<string | undefined> {
-        let dpopKeys = await dpopSettings.store.get(this.settings.client_id);
-        if (!dpopKeys) {
-            dpopKeys = await CryptoUtils.generateDPoPKeys();
-            await dpopSettings.store.set(this.settings.client_id, dpopKeys);
+        let dpopState = await dpopSettings.store.get(this.settings.client_id);
+        if (!dpopState) {
+            const dpopKeys = await CryptoUtils.generateDPoPKeys();
+            dpopState = new DPoPState(dpopKeys);
+            await dpopSettings.store.set(this.settings.client_id, dpopState);
         }
-        return await CryptoUtils.generateDPoPJkt(dpopKeys);
+        return await CryptoUtils.generateDPoPJkt(dpopState.keys);
     }
 }
diff --git a/src/errors/ErrorDPoPNonce.ts b/src/errors/ErrorDPoPNonce.ts
new file mode 100644
index 00000000..b5e5a450
--- /dev/null
+++ b/src/errors/ErrorDPoPNonce.ts
@@ -0,0 +1,10 @@
+export class ErrorDPoPNonce extends Error {
+    /** Marker to detect class: "ErrorDPoPNonce" */
+    public readonly name: string = "ErrorDPoPNonce";
+    public readonly nonce: string;
+
+    public constructor(nonce: string, message?: string) {
+        super(message);
+        this.nonce = nonce;
+    }
+}
diff --git a/src/index.ts b/src/index.ts
index 88acca6e..0f92dc40 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -45,3 +45,4 @@ export type { UserManagerSettings } from "./UserManagerSettings";
 export { Version } from "./Version";
 export { WebStorageStateStore } from "./WebStorageStateStore";
 export { IndexedDbDPoPStore } from "./IndexedDbDPoPStore";
+export { DPoPState } from "./DPoPStore";
diff --git a/src/utils/CryptoUtils.ts b/src/utils/CryptoUtils.ts
index 39af3f54..d973d0d1 100644
--- a/src/utils/CryptoUtils.ts
+++ b/src/utils/CryptoUtils.ts
@@ -6,6 +6,7 @@ export interface GenerateDPoPProofOpts {
     accessToken?: string;
     httpMethod?: string;
     keyPair: CryptoKeyPair;
+    nonce?: string;
 }
 
 const UUID_V4_TEMPLATE = "10000000-1000-4000-8000-100000000000";
@@ -136,6 +137,7 @@ export class CryptoUtils {
         accessToken,
         httpMethod,
         keyPair,
+        nonce,
     }: GenerateDPoPProofOpts): Promise<string> {
         let hashedToken: Uint8Array;
         let encodedHash: string;
@@ -153,6 +155,10 @@ export class CryptoUtils {
             payload.ath = encodedHash;
         }
 
+        if (nonce) {
+            payload.nonce = nonce;
+        }
+
         try {
             const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
             const header = {


Test patch:
diff --git a/src/IndexedDbDPoPStore.test.ts b/src/IndexedDbDPoPStore.test.ts
index e9d23ff3..15caf493 100644
--- a/src/IndexedDbDPoPStore.test.ts
+++ b/src/IndexedDbDPoPStore.test.ts
@@ -1,9 +1,10 @@
 import { IndexedDbDPoPStore } from "./IndexedDbDPoPStore";
+import { DPoPState } from "./DPoPStore";
 
 describe("DPoPStore", () => {
     const subject = new IndexedDbDPoPStore();
 
-    let data: CryptoKeyPair;
+    let data: DPoPState;
 
     const createCryptoKeyPair = async () => {
         return await window.crypto.subtle.generateKey(
@@ -17,7 +18,8 @@ describe("DPoPStore", () => {
     };
 
     beforeEach(async () => {
-        data = await createCryptoKeyPair();
+        const crytoKeys = await createCryptoKeyPair();
+        data = new DPoPState(crytoKeys);
     });
 
     describe("set", () => {
@@ -37,6 +39,16 @@ describe("DPoPStore", () => {
 
             expect(result).toEqual(data);
         });
+
+        it("should store a nonce if provided in IndexedDB", async () => {
+            data = new DPoPState(data.keys, "some-nonce");
+            await subject.set("foo", data);
+
+            const result = await subject.get("foo");
+
+            expect(result).toEqual(data);
+            expect(result.nonce).toEqual("some-nonce");
+        });
     });
 
     describe("remove", () => {
@@ -82,7 +94,8 @@ describe("DPoPStore", () => {
 
         it("should get all keys in IndexedDB", async () => {
             await subject.set("foo", data);
-            const dataTwo = await createCryptoKeyPair();
+            const crytoKeys = await createCryptoKeyPair();
+            const dataTwo = new DPoPState(crytoKeys);
             await subject.set("boo", dataTwo);
 
             const result = await subject.getAllKeys();
diff --git a/src/JsonService.test.ts b/src/JsonService.test.ts
index 8030bd3f..d380c1d9 100644
--- a/src/JsonService.test.ts
+++ b/src/JsonService.test.ts
@@ -6,6 +6,7 @@ import { JsonService } from "./JsonService";
 
 import { mocked } from "jest-mock";
 import type { ExtraHeader } from "./OidcClientSettings";
+import { ErrorDPoPNonce } from "./errors/ErrorDPoPNonce";
 
 describe("JsonService", () => {
     let subject: JsonService;
@@ -530,6 +531,24 @@ describe("JsonService", () => {
                 .rejects.toThrow("Invalid response Content-Type: text/html");
         });
 
+        it("should reject promise and throw ErrorDPoPNonce error when http response is 400 and response headers include dpop-nonce", async () => {
+            // arrange
+            const json = { foo: 1, bar: "test" };
+            mocked(fetch).mockResolvedValue({
+                status: 400,
+                ok: false,
+                headers: new Headers({
+                    "dpop-nonce": "some-nonce",
+                }),
+                text: () => Promise.resolve(JSON.stringify(json)),
+            } as Response);
+
+            // act
+            await expect(subject.postForm("http://test", { body: new URLSearchParams("payload=dummy") }))
+                // assert
+                .rejects.toThrow(ErrorDPoPNonce);
+        });
+
         it("should reject promise when http response is not 200", async () => {
             // arrange
             const json = {};
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index 97e3cadb..8ee7b7b6 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -1,7 +1,7 @@
 // Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 
-import { JwtUtils } from "./utils";
+import { CryptoUtils, JwtUtils } from "./utils";
 import type { ErrorResponse } from "./errors";
 import type { JwtClaims } from "./Claims";
 import { OidcClient } from "./OidcClient";
@@ -15,6 +15,8 @@ import { RefreshState } from "./RefreshState";
 import { SigninResponse } from "./SigninResponse";
 import type { UserProfile } from "./User";
 import { IndexedDbDPoPStore } from "./IndexedDbDPoPStore";
+import { ErrorDPoPNonce } from "./errors/ErrorDPoPNonce";
+import { DPoPState } from "./DPoPStore";
 
 describe("OidcClient", () => {
     let subject: OidcClient;
@@ -426,6 +428,43 @@ describe("OidcClient", () => {
             expect(metadataServiceMock).toHaveBeenCalled();
             expect(validateSigninResponseMock).toHaveBeenCalledWith(response, item, { "DPoP": expect.any(String) });
         });
+
+        it("should retry code request if original fails with ErrorDPoPNonce exception", async () => {
+            subject = new OidcClient({
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "redirect",
+                post_logout_redirect_uri: "http://app",
+                dpop: {
+                    bind_authorization_code: false,
+                    store: new IndexedDbDPoPStore(),
+                },
+            });
+
+            // arrange
+            const item = await SigninState.create({
+                id: "1",
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "http://app/cb",
+                scope: "scope",
+                request_type: "type",
+            });
+            jest.spyOn(subject.settings.stateStore, "remove")
+                .mockImplementation(async () => item.toStorageString());
+            const validateSigninResponseMock = jest.spyOn(subject["_validator"], "validateSigninResponse")
+                .mockRejectedValueOnce(new ErrorDPoPNonce("some-nonce"));
+            const getDpopProofMock = jest.spyOn(subject, "getDpopProof");
+            const metadataServiceMock = jest.spyOn(subject["metadataService"], "getTokenEndpoint").mockResolvedValue("http://sts/token");
+            // act
+            await subject.processSigninResponse("http://app/cb?state=1");
+
+            // assert
+            expect(metadataServiceMock).toHaveBeenCalled();
+            expect(validateSigninResponseMock).toHaveBeenCalledTimes(2);
+            expect(getDpopProofMock).toHaveBeenCalledTimes(2);
+            expect(getDpopProofMock).toHaveBeenNthCalledWith(2, { "_dbName": "oidc", "_storeName": "dpop" }, "some-nonce");
+        });
     });
 
     describe("processResourceOwnerPasswordCredentials", () => {
@@ -654,6 +693,50 @@ describe("OidcClient", () => {
             expect(response).toHaveProperty("scope", state.scope);
         });
 
+        it("should retry token exchange if tokenClient exchange throws ErrorDPoPNonce exception", async () => {
+            // arrange
+            const settings: OidcClientSettings = {
+                authority: "authority",
+                client_id: "client",
+                redirect_uri: "redirect",
+                post_logout_redirect_uri: "http://app",
+                dpop: {
+                    bind_authorization_code: false,
+                    store: new IndexedDbDPoPStore(),
+                },
+            };
+
+            subject = new OidcClient(settings);
+
+            const tokenResponse = {
+                access_token: "new_access_token",
+            };
+            const exchangeRefreshTokenMock =
+                jest.spyOn(subject["_tokenClient"], "exchangeRefreshToken")
+                    .mockRejectedValueOnce(new ErrorDPoPNonce("some-nonce"))
+                    .mockResolvedValueOnce(tokenResponse);
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "sub" });
+            jest.spyOn(subject["metadataService"], "getTokenEndpoint").mockResolvedValue("http://sts/token");
+            const getDpopProofSpy = jest.spyOn(subject, "getDpopProof");
+
+            const state = new RefreshState({
+                refresh_token: "refresh_token",
+                id_token: "id_token",
+                session_state: "session_state",
+                scope: "openid",
+                profile: {} as UserProfile,
+            });
+
+            // act
+            await subject.useRefreshToken({ state, resource: "resource" });
+
+            // assert
+
+            expect(exchangeRefreshTokenMock).toHaveBeenCalledTimes(2);
+            expect(getDpopProofSpy).toHaveBeenCalledTimes(2);
+            expect(getDpopProofSpy).toHaveBeenNthCalledWith(2, { "_dbName": "oidc", "_storeName": "dpop" }, "some-nonce");
+        });
+
         it("should pass extraHeaders to tokenClient.exchangeRefreshToken if supplied", async () => {
             // arrange
             const tokenResponse = {
@@ -1032,4 +1115,69 @@ describe("OidcClient", () => {
             });
         });
     });
+
+    describe("getDpopProof", () => {
+        it("stores a nonce if one is provided", async () => {
+            // arrange
+            const store = new IndexedDbDPoPStore();
+            const keyPair = await CryptoUtils.generateDPoPKeys();
+            jest.spyOn(CryptoUtils, "generateDPoPKeys").mockResolvedValue(keyPair);
+
+            const settings: OidcClientSettings = {
+                authority: "authority",
+                client_id: "dpop_client",
+                redirect_uri: "redirect",
+                post_logout_redirect_uri: "http://app",
+                dpop: {
+                    bind_authorization_code: false,
+                    store: store,
+                },
+            };
+
+            subject = new OidcClient(settings);
+
+            jest.spyOn(subject["metadataService"], "getTokenEndpoint").mockResolvedValue("http://sts/token");
+
+            // act
+            await subject.getDpopProof(store, "some-nonce");
+            const storedDPoPState = await subject.settings.dpop?.store.get("dpop_client");
+            // assert
+
+            expect(storedDPoPState?.nonce).toEqual("some-nonce");
+        });
+
+        it("updates the stored nonce if a new one is provided", async () => {
+            // arrange
+            const store = new IndexedDbDPoPStore();
+            const keyPair = await CryptoUtils.generateDPoPKeys();
+            jest.spyOn(CryptoUtils, "generateDPoPKeys").mockResolvedValue(keyPair);
+            const dpopState = new DPoPState(keyPair, "some-nonce");
+
+            const settings: OidcClientSettings = {
+                authority: "authority",
+                client_id: "dpop_client",
+                redirect_uri: "redirect",
+                post_logout_redirect_uri: "http://app",
+                dpop: {
+                    bind_authorization_code: false,
+                    store: store,
+                },
+            };
+
+            subject = new OidcClient(settings);
+
+            jest.spyOn(subject["metadataService"], "getTokenEndpoint").mockResolvedValue("http://sts/token");
+            await subject.getDpopProof(store, "some-nonce");
+            let storedDPoPState = await subject.settings.dpop?.store.get("dpop_client");
+            expect(storedDPoPState?.nonce).toEqual("some-nonce");
+            dpopState.nonce = "some-other-nonce";
+
+            // act
+            await subject.getDpopProof(store, "some-other-nonce");
+            storedDPoPState = await subject.settings.dpop?.store.get("dpop_client");
+
+            // assert
+            expect(storedDPoPState?.nonce).toEqual("some-other-nonce");
+        });
+    });
 });
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index 59fe4ab4..c78745c1 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -22,6 +22,7 @@ import type { State } from "./State";
 import { mocked } from "jest-mock";
 import { CryptoUtils } from "./utils";
 import { IndexedDbDPoPStore } from "./IndexedDbDPoPStore";
+import { DPoPState } from "./DPoPStore";
 
 describe("UserManager", () => {
     let userStoreMock: WebStorageStateStore;
@@ -361,7 +362,8 @@ describe("UserManager", () => {
             const generateDPoPJktSpy = jest.spyOn(subject, "generateDPoPJkt");
 
             const keyPair = await CryptoUtils.generateDPoPKeys();
-            await subject.settings.dpop?.store?.set("client", keyPair);
+
+            await subject.settings.dpop?.store?.set("client", new DPoPState(keyPair));
 
             // act
             await subject.signinRedirect();
@@ -516,7 +518,7 @@ describe("UserManager", () => {
             const generateDPoPJktSpy = jest.spyOn(subject, "generateDPoPJkt");
 
             const keyPair = await CryptoUtils.generateDPoPKeys();
-            await subject.settings.dpop?.store?.set("client", keyPair);
+            await subject.settings.dpop?.store?.set("client", new DPoPState(keyPair));
 
             const user = new User({
                 access_token: "access_token",
@@ -744,7 +746,7 @@ describe("UserManager", () => {
             });
 
             const keyPair = await CryptoUtils.generateDPoPKeys();
-            await subject.settings.dpop?.store?.set("client", keyPair);
+            await subject.settings.dpop?.store?.set("client", new DPoPState(keyPair));
 
             const user = new User({
                 access_token: "access_token",
@@ -1219,7 +1221,7 @@ describe("UserManager", () => {
             });
             await subject.storeUser(user);
             const dpopKeyPair = await CryptoUtils.generateDPoPKeys();
-            await subject.settings.dpop?.store.set("client", dpopKeyPair);
+            await subject.settings.dpop?.store.set("client", new DPoPState(dpopKeyPair));
 
             // act
             await subject.storeUser(null);
@@ -1255,7 +1257,7 @@ describe("UserManager", () => {
 
             const user = await subject.getUser() as User;
             const keyPair = await CryptoUtils.generateDPoPKeys();
-            const mockDpopStore = jest.spyOn(subject.settings.dpop!.store, "get").mockResolvedValue(keyPair);
+            const mockDpopStore = jest.spyOn(subject.settings.dpop!.store, "get").mockResolvedValue(new DPoPState(keyPair));
 
             // act
             const dpopProof = await subject.dpopProof("http://some.url", user, "POST");
diff --git a/src/utils/CryptoUtils.test.ts b/src/utils/CryptoUtils.test.ts
index ba0e9b8e..778b8d2a 100644
--- a/src/utils/CryptoUtils.test.ts
+++ b/src/utils/CryptoUtils.test.ts
@@ -109,5 +109,27 @@ describe("CryptoUtils", () => {
             exportKeyMock.mockRestore();
             generateSignedJwtMock.mockRestore();
         });
+
+        it("should generate a valid proof with a nonce", async () => {
+            // arrange
+            const url = "https://localhost:5005/identity";
+            const httpMethod = "GET";
+            const accessToken = "access_token";
+            const keyPair = await CryptoUtils.generateDPoPKeys();
+            const nonce = "some-nonce";
+
+            // act
+            const dpopProof = await CryptoUtils.generateDPoPProof({ url, accessToken, httpMethod, keyPair, nonce });
+            const protectedHeader = decodeProtectedHeader(dpopProof);
+            const publicKey = await importJWK(<JWK>protectedHeader.jwk);
+            const verifiedResult = await jwtVerify(dpopProof, publicKey);
+
+            // assert
+            expect(verifiedResult.payload).toHaveProperty("htu");
+            expect(verifiedResult.payload).toHaveProperty("htm");
+            expect(verifiedResult.payload).toHaveProperty("ath");
+            expect(verifiedResult.payload).toHaveProperty("nonce");
+            expect(verifiedResult.payload.nonce).toEqual(nonce);
+        });
     });
 });