Repository: authts/oidc-client-ts (ts)

Issue:
Missing state on Login Redirect
I'm not sure whether this is a bug or I do something wrong, however, I would like to read the state that is preserved between the initial load, redirect to IS and redirect back. 

From the client and IS point of view, everything seems to be fine, the state is being sent all the way through and comes back as expected:

<i>https://<span></span>localhost:44302/connect/authorize?client_id=MyClient&redirect_uri=http://<span></span>localhost:3000/authentication/login-callback&response_type=code&scope=openid profile offline_access&state=**b5924d628ef74c9b8d599a76d7127cd4**&code_challenge=asX_clrphzIXLga3cj7z3gZofVFz-FXxcer25rVsz4k&code_challenge_method=S256&response_mode=query

https://<span></span>localhost:44302/Account/Login?ReturnUrl=/connect/authorize/callback?client_id=MyClient&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauthentication%2Flogin-callback&response_type=code&scope=openid%20profile%20offline_access&state=**b5924d628ef74c9b8d599a76d7127cd4**&code_challenge=asX_clrphzIXLga3cj7z3gZofVFz-FXxcer25rVsz4k&code_challenge_method=S256&response_mode=query

https://<span></span>localhost:44302/connect/authorize/callback?client_id=MyClient&redirect_uri=http://<span></span>localhost:3000/authentication/login-callback&response_type=code&scope=openid profile offline_access&state=**b5924d628ef74c9b8d599a76d7127cd4**&code_challenge=asX_clrphzIXLga3cj7z3gZofVFz-FXxcer25rVsz4k&code_challenge_method=S256&response_mode=query

http://<span></span>localhost:3000/authentication/login-callback?code=30B4D78B1A42018F8AD3C0579B72A876CFD60936D02998F029BBDBEE3570BED1&scope=openid profile offline_access&state=**b5924d628ef74c9b8d599a76d7127cd4**&session_state=L_rI2q6PV9a_Kljbx5mHL29ftlwEdNQn6k9ky8wak98.3DF5BF2643218A919507CC884F1F1A92</i>

However, if you then look into the storage (I'm using Session storage, but should not make any difference in this case), there is no state with the round-tripped id. I'm using reference tokens and thus I can't read values from tokens as an alternative solution.

Interesting enough, all this works well on sign out redirect. The state id is sent there and back and I can pick up the state from the storage after the final redirect. The issue is not new in `oidc-client-ts`, I've had the same problem with the `oidc-client-js`. 

I'm just wondering whether I do something wrong or whether this is an issue of the library.

Pull request:
Add support for custom URL state values
<!-- Please link relevant issue numbers or provide context for this change -->
Closes/fixes #293

@pamapa @Badisi 

### Checklist

- [X] This PR makes changes to the public API <!-- was the API report (docs/oidc-client-ts.api.md) updated by this PR? -->
- [X] I have included links for closing relevant issue numbers


Patch:
diff --git a/docs/index.md b/docs/index.md
index 7960e3f7..6934ef5a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -117,6 +117,16 @@ After successful sign-in the custom state is part of the [User](classes/User.htm
 
 This custom state should not be confused with the URL state parameter. The latter is internally used to match against the authentication state object to finish the authentication process.
 
+## Custom state in request url
+If you would like to encode a custom state string in the sign in request url, you can do so with the `url_state` parameter. You may want to do this in order to pass user state to the authentication server and/or a proxy and return that state as part of the response.
+
+```javascript
+const mgr = new UserManager();
+mgr.signinRedirect({ url_state: 'custom url state' })
+```
+
+The `url_state` will be appended to the opaque, unique value created by the library when sending the request. It should survive the round trip to your authentication server and will be part of the [User](classes/User.html#url_state) object as `url_state`.
+
 
 ## Projects using oidc-client
 
diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md
index 65372e72..29833fd2 100644
--- a/docs/oidc-client-ts.api.md
+++ b/docs/oidc-client-ts.api.md
@@ -69,6 +69,7 @@ export class ErrorResponse extends Error {
         error_uri?: string | null;
         userState?: unknown;
         session_state?: string | null;
+        url_state?: string;
     },
     form?: URLSearchParams | undefined);
     readonly error: string | null;
@@ -79,6 +80,8 @@ export class ErrorResponse extends Error {
     // (undocumented)
     readonly session_state: string | null;
     state?: unknown;
+    // (undocumented)
+    url_state?: string;
 }
 
 // @public
@@ -91,7 +94,7 @@ export class ErrorTimeout extends Error {
 export type ExtraHeader = string | (() => string);
 
 // @public (undocumented)
-export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales" | "resource">;
+export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales" | "resource" | "url_state">;
 
 // @public (undocumented)
 export type ExtraSignoutRequestArgs = Pick<CreateSignoutRequestArgs, "extraQueryParams" | "state" | "id_token_hint" | "post_logout_redirect_uri">;
@@ -298,7 +301,7 @@ export class OidcClient {
     // (undocumented)
     clearStaleState(): Promise<void>;
     // (undocumented)
-    createSigninRequest({ state, request, request_uri, request_type, id_token_hint, login_hint, skipUserInfo, nonce, response_type, scope, redirect_uri, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, extraQueryParams, extraTokenParams, }: CreateSigninRequestArgs): Promise<SigninRequest>;
+    createSigninRequest({ state, request, request_uri, request_type, id_token_hint, login_hint, skipUserInfo, nonce, url_state, response_type, scope, redirect_uri, prompt, display, max_age, ui_locales, acr_values, resource, response_mode, extraQueryParams, extraTokenParams, }: CreateSigninRequestArgs): Promise<SigninRequest>;
     // (undocumented)
     createSignoutRequest({ state, id_token_hint, client_id, request_type, post_logout_redirect_uri, extraQueryParams, }?: CreateSignoutRequestArgs): Promise<SignoutRequest>;
     // (undocumented)
@@ -629,7 +632,7 @@ export type SigninRedirectArgs = RedirectParams & ExtraSigninRequestArgs;
 
 // @public (undocumented)
 export class SigninRequest {
-    constructor({ url, authority, client_id, redirect_uri, response_type, scope, state_data, response_mode, request_type, client_secret, nonce, resource, skipUserInfo, extraQueryParams, extraTokenParams, disablePKCE, ...optionalParams }: SigninRequestArgs);
+    constructor({ url, authority, client_id, redirect_uri, response_type, scope, state_data, response_mode, request_type, client_secret, nonce, url_state, resource, skipUserInfo, extraQueryParams, extraTokenParams, disablePKCE, ...optionalParams }: SigninRequestArgs);
     // (undocumented)
     readonly state: SigninState;
     // (undocumented)
@@ -687,6 +690,8 @@ export interface SigninRequestArgs {
     ui_locales?: string;
     // (undocumented)
     url: string;
+    // (undocumented)
+    url_state?: string;
 }
 
 // @public (undocumented)
@@ -726,6 +731,8 @@ export class SigninResponse {
     readonly state: string | null;
     // (undocumented)
     token_type: string;
+    // (undocumented)
+    url_state?: string;
     userState: unknown;
 }
 
@@ -739,6 +746,7 @@ export class SigninState extends State {
         data?: unknown;
         created?: number;
         request_type?: string;
+        url_state?: string;
         code_verifier?: string | boolean;
         authority: string;
         client_id: string;
@@ -832,6 +840,7 @@ export class State {
         data?: unknown;
         created?: number;
         request_type?: string;
+        url_state?: string;
     });
     // (undocumented)
     static clearStaleState(storage: StateStore, age: number): Promise<void>;
@@ -846,6 +855,8 @@ export class State {
     readonly request_type: string | undefined;
     // (undocumented)
     toStorageString(): string;
+    // (undocumented)
+    readonly url_state: string | undefined;
 }
 
 // @public (undocumented)
@@ -872,6 +883,7 @@ export class User {
         profile: UserProfile;
         expires_at?: number;
         userState?: unknown;
+        url_state?: string;
     });
     access_token: string;
     get expired(): boolean | undefined;
@@ -890,6 +902,8 @@ export class User {
     token_type: string;
     // (undocumented)
     toStorageString(): string;
+    // (undocumented)
+    readonly url_state?: string;
 }
 
 // @public (undocumented)
diff --git a/package-lock.json b/package-lock.json
index 529ce3fb..df57ba74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1828,9 +1828,9 @@
       }
     },
     "node_modules/@types/crypto-js": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.2.tgz",
-      "integrity": "sha512-t33RNmTu5ufG/sorROIafiCVJMx3jz95bXUMoPAZcUD14fxMXnuTzqzXZoxpR0tNx2xpw11Dlmem9vGCsrSOfA==",
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.3.tgz",
+      "integrity": "sha512-YP1sYYayLe7Eg5oXyLLvOLfxBfZ5Fgpz6sVWkpB18wDMywCLPWmqzRz+9gyuOoLF0fzDTTFwlyNbx7koONUwqA==",
       "dev": true
     },
     "node_modules/@types/express": {
@@ -1900,9 +1900,9 @@
       }
     },
     "node_modules/@types/jest": {
-      "version": "29.5.5",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz",
-      "integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==",
+      "version": "29.5.6",
+      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.6.tgz",
+      "integrity": "sha512-/t9NnzkOpXb4Nfvg17ieHE6EeSjDS2SGSpNYfoLbUAeL/EOueU/RSdOWFpfQTXBEM7BguYW1XQ0EbM+6RlIh6w==",
       "dev": true,
       "dependencies": {
         "expect": "^29.0.0",
@@ -3105,8 +3105,9 @@
       }
     },
     "node_modules/crypto-js": {
-      "version": "4.1.1",
-      "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw=="
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
     },
     "node_modules/css.escape": {
       "version": "1.5.1",
@@ -5290,9 +5291,9 @@
       "dev": true
     },
     "node_modules/lint-staged": {
-      "version": "15.0.1",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.0.1.tgz",
-      "integrity": "sha512-2IU5OWmCaxch0X0+IBF4/v7sutpB+F3qoXbro43pYjQTOo5wumckjxoxn47pQBqqBsCWrD5HnI2uG/zJA7isew==",
+      "version": "15.0.2",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.0.2.tgz",
+      "integrity": "sha512-vnEy7pFTHyVuDmCAIFKR5QDO8XLVlPFQQyujQ/STOxe40ICWqJ6knS2wSJ/ffX/Lw0rz83luRDh+ET7toN+rOw==",
       "dev": true,
       "dependencies": {
         "chalk": "5.3.0",
@@ -5300,11 +5301,11 @@
         "debug": "4.3.4",
         "execa": "8.0.1",
         "lilconfig": "2.1.0",
-        "listr2": "7.0.1",
+        "listr2": "7.0.2",
         "micromatch": "4.0.5",
         "pidtree": "0.6.0",
         "string-argv": "0.3.2",
-        "yaml": "2.3.2"
+        "yaml": "2.3.3"
       },
       "bin": {
         "lint-staged": "bin/lint-staged.js"
@@ -5472,9 +5473,9 @@
       }
     },
     "node_modules/listr2": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.1.tgz",
-      "integrity": "sha512-nz+7hwgbDp8eWNoDgzdl4hA/xDSLrNRzPu1TLgOYs6l5Y+Ma6zVWWy9Oyt9TQFONwKoSPoka3H50D3vD5EuNwg==",
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.2.tgz",
+      "integrity": "sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g==",
       "dev": true,
       "dependencies": {
         "cli-truncate": "^3.1.0",
@@ -7201,9 +7202,9 @@
       "dev": true
     },
     "node_modules/yaml": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz",
-      "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==",
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.3.tgz",
+      "integrity": "sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==",
       "dev": true,
       "engines": {
         "node": ">= 14"
@@ -8566,9 +8567,9 @@
       }
     },
     "@types/crypto-js": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.2.tgz",
-      "integrity": "sha512-t33RNmTu5ufG/sorROIafiCVJMx3jz95bXUMoPAZcUD14fxMXnuTzqzXZoxpR0tNx2xpw11Dlmem9vGCsrSOfA==",
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.3.tgz",
+      "integrity": "sha512-YP1sYYayLe7Eg5oXyLLvOLfxBfZ5Fgpz6sVWkpB18wDMywCLPWmqzRz+9gyuOoLF0fzDTTFwlyNbx7koONUwqA==",
       "dev": true
     },
     "@types/express": {
@@ -8638,9 +8639,9 @@
       }
     },
     "@types/jest": {
-      "version": "29.5.5",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz",
-      "integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==",
+      "version": "29.5.6",
+      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.6.tgz",
+      "integrity": "sha512-/t9NnzkOpXb4Nfvg17ieHE6EeSjDS2SGSpNYfoLbUAeL/EOueU/RSdOWFpfQTXBEM7BguYW1XQ0EbM+6RlIh6w==",
       "dev": true,
       "requires": {
         "expect": "^29.0.0",
@@ -9480,8 +9481,9 @@
       }
     },
     "crypto-js": {
-      "version": "4.1.1",
-      "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw=="
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
     },
     "css.escape": {
       "version": "1.5.1",
@@ -11087,9 +11089,9 @@
       "dev": true
     },
     "lint-staged": {
-      "version": "15.0.1",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.0.1.tgz",
-      "integrity": "sha512-2IU5OWmCaxch0X0+IBF4/v7sutpB+F3qoXbro43pYjQTOo5wumckjxoxn47pQBqqBsCWrD5HnI2uG/zJA7isew==",
+      "version": "15.0.2",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.0.2.tgz",
+      "integrity": "sha512-vnEy7pFTHyVuDmCAIFKR5QDO8XLVlPFQQyujQ/STOxe40ICWqJ6knS2wSJ/ffX/Lw0rz83luRDh+ET7toN+rOw==",
       "dev": true,
       "requires": {
         "chalk": "5.3.0",
@@ -11097,11 +11099,11 @@
         "debug": "4.3.4",
         "execa": "8.0.1",
         "lilconfig": "2.1.0",
-        "listr2": "7.0.1",
+        "listr2": "7.0.2",
         "micromatch": "4.0.5",
         "pidtree": "0.6.0",
         "string-argv": "0.3.2",
-        "yaml": "2.3.2"
+        "yaml": "2.3.3"
       },
       "dependencies": {
         "chalk": {
@@ -11196,9 +11198,9 @@
       }
     },
     "listr2": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.1.tgz",
-      "integrity": "sha512-nz+7hwgbDp8eWNoDgzdl4hA/xDSLrNRzPu1TLgOYs6l5Y+Ma6zVWWy9Oyt9TQFONwKoSPoka3H50D3vD5EuNwg==",
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/listr2/-/listr2-7.0.2.tgz",
+      "integrity": "sha512-rJysbR9GKIalhTbVL2tYbF2hVyDnrf7pFUZBwjPaMIdadYHmeT+EVi/Bu3qd7ETQPahTotg2WRCatXwRBW554g==",
       "dev": true,
       "requires": {
         "cli-truncate": "^3.1.0",
@@ -12412,9 +12414,9 @@
       "dev": true
     },
     "yaml": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz",
-      "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==",
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.3.tgz",
+      "integrity": "sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==",
       "dev": true
     },
     "yargs": {
diff --git a/package.json b/package.json
index 72d91b23..599efe50 100644
--- a/package.json
+++ b/package.json
@@ -39,13 +39,13 @@
     "prepare": "husky install"
   },
   "dependencies": {
-    "crypto-js": "^4.1.1",
+    "crypto-js": "^4.2.0",
     "jwt-decode": "^3.1.2"
   },
   "devDependencies": {
     "@microsoft/api-extractor": "^7.35.0",
     "@testing-library/jest-dom": "^6.0.0",
-    "@types/crypto-js": "^4.0.2",
+    "@types/crypto-js": "^4.1.3",
     "@types/jest": "^29.2.3",
     "@typescript-eslint/eslint-plugin": "^6.4.1",
     "@typescript-eslint/parser": "^6.4.1",
diff --git a/src/ClaimsService.ts b/src/ClaimsService.ts
index 6437cf33..66752e65 100644
--- a/src/ClaimsService.ts
+++ b/src/ClaimsService.ts
@@ -70,7 +70,7 @@ export class ClaimsService {
         for (const [claim, values] of Object.entries(claims2)) {
             for (const value of Array.isArray(values) ? values : [values]) {
                 const previousValue = result[claim];
-                if (!previousValue) {
+                if (previousValue === undefined) {
                     result[claim] = value;
                 }
                 else if (Array.isArray(previousValue)) {
diff --git a/src/OidcClient.ts b/src/OidcClient.ts
index b4f34166..83ba8242 100644
--- a/src/OidcClient.ts
+++ b/src/OidcClient.ts
@@ -92,6 +92,7 @@ export class OidcClient {
         login_hint,
         skipUserInfo,
         nonce,
+        url_state,
         response_type = this.settings.response_type,
         scope = this.settings.scope,
         redirect_uri = this.settings.redirect_uri,
@@ -122,6 +123,7 @@ export class OidcClient {
             response_type,
             scope,
             state_data: state,
+            url_state,
             prompt, display, max_age, ui_locales, id_token_hint, login_hint, acr_values,
             resource, request, request_uri, extraQueryParams, extraTokenParams, request_type, response_mode,
             client_secret: this.settings.client_secret,
diff --git a/src/ResponseValidator.ts b/src/ResponseValidator.ts
index 68d9d466..1c1f845e 100644
--- a/src/ResponseValidator.ts
+++ b/src/ResponseValidator.ts
@@ -51,7 +51,7 @@ export class ResponseValidator {
     public async validateCredentialsResponse(response: SigninResponse, skipUserInfo: boolean): Promise<void> {
         const logger = this._logger.create("validateCredentialsResponse");
 
-        if (response.isOpenId) {
+        if (response.isOpenId && !!response.id_token) {
             this._validateIdTokenAttributes(response);
         }
         logger.debug("tokens validated");
@@ -133,6 +133,7 @@ export class ResponseValidator {
         // this is important for both success & error outcomes
         logger.debug("state validated");
         response.userState = state.data;
+        response.url_state = state.url_state;
         // if there's no scope on the response, then assume all scopes granted (per-spec) and copy over scopes from original request
         response.scope ??= state.scope;
 
diff --git a/src/SigninRequest.ts b/src/SigninRequest.ts
index c5066cb8..d6b3a15f 100644
--- a/src/SigninRequest.ts
+++ b/src/SigninRequest.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 { Logger } from "./utils";
+import { Logger, URL_STATE_DELIMITER } from "./utils";
 import { SigninState } from "./SigninState";
 
 /**
@@ -42,6 +42,7 @@ export interface SigninRequestArgs {
     disablePKCE?: boolean;
     /** custom "state", which can be used by a caller to have "data" round tripped */
     state_data?: unknown;
+    url_state?: string;
 }
 
 /**
@@ -57,7 +58,7 @@ export class SigninRequest {
         // mandatory
         url, authority, client_id, redirect_uri, response_type, scope,
         // optional
-        state_data, response_mode, request_type, client_secret, nonce,
+        state_data, response_mode, request_type, client_secret, nonce, url_state,
         resource,
         skipUserInfo,
         extraQueryParams,
@@ -93,6 +94,7 @@ export class SigninRequest {
         this.state = new SigninState({
             data: state_data,
             request_type,
+            url_state,
             code_verifier: !disablePKCE,
             client_id, authority, redirect_uri,
             response_mode,
@@ -109,7 +111,11 @@ export class SigninRequest {
             parsedUrl.searchParams.append("nonce", nonce);
         }
 
-        parsedUrl.searchParams.append("state", this.state.id);
+        let state = this.state.id;
+        if (url_state) {
+            state = `${state}${URL_STATE_DELIMITER}${url_state}`;
+        }
+        parsedUrl.searchParams.append("state", state);
         if (this.state.code_challenge) {
             parsedUrl.searchParams.append("code_challenge", this.state.code_challenge);
             parsedUrl.searchParams.append("code_challenge_method", "S256");
diff --git a/src/SigninResponse.ts b/src/SigninResponse.ts
index 10ae67cc..6d619bcb 100644
--- a/src/SigninResponse.ts
+++ b/src/SigninResponse.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 { Timer } from "./utils";
+import { Timer, URL_STATE_DELIMITER } from "./utils";
 import type { UserProfile } from "./User";
 
 const OidcScope = "openid";
@@ -44,6 +44,7 @@ export class SigninResponse {
 
     /** custom state data set during the initial signin request */
     public userState: unknown;
+    public url_state?: string;
 
     /** @see {@link User.profile} */
     public profile: UserProfile = {} as UserProfile;
@@ -51,6 +52,13 @@ export class SigninResponse {
     public constructor(params: URLSearchParams) {
         this.state = params.get("state");
         this.session_state = params.get("session_state");
+        if (this.state) {
+            const splitState = decodeURIComponent(this.state).split(URL_STATE_DELIMITER);
+            this.state = splitState[0];
+            if (splitState.length > 1) {
+                this.url_state = splitState.slice(1).join(URL_STATE_DELIMITER);
+            }
+        }
 
         this.error = params.get("error");
         this.error_description = params.get("error_description");
diff --git a/src/SigninState.ts b/src/SigninState.ts
index 203157ed..df84eeb2 100644
--- a/src/SigninState.ts
+++ b/src/SigninState.ts
@@ -37,6 +37,7 @@ export class SigninState extends State {
         data?: unknown;
         created?: number;
         request_type?: string;
+        url_state?: string;
 
         code_verifier?: string | boolean;
         authority: string;
@@ -79,6 +80,7 @@ export class SigninState extends State {
             data: this.data,
             created: this.created,
             request_type: this.request_type,
+            url_state: this.url_state,
 
             code_verifier: this.code_verifier,
             authority: this.authority,
diff --git a/src/State.ts b/src/State.ts
index 668a6b1f..971dde12 100644
--- a/src/State.ts
+++ b/src/State.ts
@@ -11,6 +11,7 @@ export class State {
     public readonly id: string;
     public readonly created: number;
     public readonly request_type: string | undefined;
+    public readonly url_state: string | undefined;
 
     /** custom "state", which can be used by a caller to have "data" round tripped */
     public readonly data?: unknown;
@@ -20,6 +21,7 @@ export class State {
         data?: unknown;
         created?: number;
         request_type?: string;
+        url_state?: string;
     }) {
         this.id = args.id || CryptoUtils.generateUUIDv4();
         this.data = args.data;
@@ -31,6 +33,7 @@ export class State {
             this.created = Timer.getEpochTime();
         }
         this.request_type = args.request_type;
+        this.url_state = args.url_state;
     }
 
     public toStorageString(): string {
@@ -40,6 +43,7 @@ export class State {
             data: this.data,
             created: this.created,
             request_type: this.request_type,
+            url_state: this.url_state,
         });
     }
 
diff --git a/src/User.ts b/src/User.ts
index 99a037f5..f1f4d1f5 100644
--- a/src/User.ts
+++ b/src/User.ts
@@ -50,6 +50,7 @@ export class User {
 
     /** custom state data set during the initial signin request */
     public readonly state: unknown;
+    public readonly url_state?: string;
 
     public constructor(args: {
         id_token?: string;
@@ -61,6 +62,7 @@ export class User {
         profile: UserProfile;
         expires_at?: number;
         userState?: unknown;
+        url_state?: string;
     }) {
         this.id_token = args.id_token;
         this.session_state = args.session_state ?? null;
@@ -72,6 +74,7 @@ export class User {
         this.profile = args.profile;
         this.expires_at = args.expires_at;
         this.state = args.userState;
+        this.url_state = args.url_state;
     }
 
     /** Computed number of seconds the access token has remaining. */
diff --git a/src/UserManager.ts b/src/UserManager.ts
index 174d9951..bb53ec89 100644
--- a/src/UserManager.ts
+++ b/src/UserManager.ts
@@ -19,7 +19,7 @@ import type { SigninResponse } from "./SigninResponse";
 /**
  * @public
  */
-export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales" | "resource">;
+export type ExtraSigninRequestArgs = Pick<CreateSigninRequestArgs, "nonce" | "extraQueryParams" | "extraTokenParams" | "state" | "redirect_uri" | "prompt" | "acr_values" | "login_hint" | "scope" | "max_age" | "ui_locales" | "resource" | "url_state">;
 /**
  * @public
  */
diff --git a/src/errors/ErrorResponse.ts b/src/errors/ErrorResponse.ts
index 249b56f4..cd35ec6c 100644
--- a/src/errors/ErrorResponse.ts
+++ b/src/errors/ErrorResponse.ts
@@ -29,10 +29,12 @@ export class ErrorResponse extends Error {
 
     public readonly session_state: string | null;
 
+    public url_state?: string;
+
     public constructor(
         args: {
             error?: string | null; error_description?: string | null; error_uri?: string | null;
-            userState?: unknown; session_state?: string | null;
+            userState?: unknown; session_state?: string | null; url_state?: string;
         },
         /** The x-www-form-urlencoded request body sent to the authority server */
         public readonly form?: URLSearchParams,
@@ -50,5 +52,6 @@ export class ErrorResponse extends Error {
 
         this.state = args.userState;
         this.session_state = args.session_state ?? null;
+        this.url_state = args.url_state;
     }
 }
diff --git a/src/utils/UrlUtils.ts b/src/utils/UrlUtils.ts
index e54a0d86..36767c71 100644
--- a/src/utils/UrlUtils.ts
+++ b/src/utils/UrlUtils.ts
@@ -13,3 +13,8 @@ export class UrlUtils {
         return new URLSearchParams(params.slice(1));
     }
 }
+
+/**
+ * @internal
+ */
+export const URL_STATE_DELIMITER = ";";
\ No newline at end of file


Test patch:
diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts
index 68b35497..577d2393 100644
--- a/src/OidcClient.test.ts
+++ b/src/OidcClient.test.ts
@@ -87,6 +87,7 @@ describe("OidcClient", () => {
                 request: "req",
                 request_uri: "req_uri",
                 nonce: "rnd",
+                url_state: "url_state",
             });
 
             // assert
@@ -108,6 +109,7 @@ describe("OidcClient", () => {
             expect(url).toContain("request_uri=req_uri");
             expect(url).toContain("response_mode=fragment");
             expect(url).toContain("nonce=rnd");
+            expect(url.match(/state=.*%3Burl_state/)).toBeTruthy();
         });
 
         it("should pass state in place of data to SigninRequest", async () => {
@@ -128,6 +130,7 @@ describe("OidcClient", () => {
                 login_hint: "lh",
                 acr_values: "av",
                 resource: "res",
+                url_state: "url_state",
             });
 
             // assert
@@ -145,6 +148,7 @@ describe("OidcClient", () => {
             expect(url).toContain("login_hint=lh");
             expect(url).toContain("acr_values=av");
             expect(url).toContain("resource=res");
+            expect(url.match(/state=.*%3Burl_state/)).toBeTruthy();
         });
 
         it("should fail if implicit flow requested", async () => {
diff --git a/src/ResponseValidator.test.ts b/src/ResponseValidator.test.ts
index 7fb52b12..f8cded3d 100644
--- a/src/ResponseValidator.test.ts
+++ b/src/ResponseValidator.test.ts
@@ -85,6 +85,17 @@ describe("ResponseValidator", () => {
             // assert
             expect(stubResponse.userState).toEqual({ some: "data" });
         });
+
+        it("should return url_state for successful responses", () => {
+            // arrange
+            Object.assign(stubResponse, { url_state: "url_state" });
+
+            // act
+            subject.validateSignoutResponse(stubResponse, stubState);
+
+            // assert
+            expect(stubResponse.url_state).toEqual("url_state");
+        });
     });
 
     describe("validateSigninResponse", () => {
@@ -578,6 +589,27 @@ describe("ResponseValidator", () => {
             expect(stubResponse).not.toHaveProperty("profile");
         });
 
+        it("should process an openid signin response without an id_token as a non-openid signin response", async () => {
+            // this is aimed at Authorization servers that don't support the OIDC spec and don't return an id_token if the
+            // openid scope is set in the request scope.
+            // arrange
+            Object.assign(stubResponse, {
+                isOpenId: true,
+            });
+            jest.spyOn(JwtUtils, "decode").mockReturnValue({ sub: "subsub" });
+
+            // act
+            await subject.validateCredentialsResponse(stubResponse, true);
+
+            // assert
+            expect(JwtUtils.decode).not.toHaveBeenCalledWith("id_token");
+            expect(
+                subject["_userInfoService"].getClaims,
+            ).not.toHaveBeenCalledWith();
+            expect(stubResponse).toHaveProperty("profile", {});
+
+        });
+
         it("should process a valid non-openid signin response skipping userInfo", async () => {
             // arrange
             Object.assign(stubResponse, {
diff --git a/src/SigninRequest.test.ts b/src/SigninRequest.test.ts
index 44e1d8a7..77c3ad25 100644
--- a/src/SigninRequest.test.ts
+++ b/src/SigninRequest.test.ts
@@ -2,6 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
 
 import { SigninRequest, type SigninRequestArgs } from "./SigninRequest";
+import { URL_STATE_DELIMITER } from "./utils";
 
 describe("SigninRequest", () => {
 
@@ -248,5 +249,16 @@ describe("SigninRequest", () => {
             // assert
             expect(subject.url).toContain("nonce=");
         });
+
+        it("should include url_state", () => {
+            // arrange
+            settings.url_state = "foo";
+
+            // act
+            subject = new SigninRequest(settings);
+
+            // assert
+            expect(subject.url).toContain("state=" + subject.state.id + encodeURIComponent(URL_STATE_DELIMITER + "foo"));
+        });
     });
 });
diff --git a/src/SigninResponse.test.ts b/src/SigninResponse.test.ts
index 7bf04c7b..318858c4 100644
--- a/src/SigninResponse.test.ts
+++ b/src/SigninResponse.test.ts
@@ -49,6 +49,24 @@ describe("SigninResponse", () => {
             expect(subject.state).toEqual("foo");
         });
 
+        it("should read url_state", () => {
+            // act
+            const subject = new SigninResponse(new URLSearchParams("state=foo;bar"));
+
+            // assert
+            expect(subject.state).toEqual("foo");
+            expect(subject.url_state).toEqual("bar");
+        });
+
+        it("should return url_state that uses the delimiter unmodified", () => {
+            // act
+            const subject = new SigninResponse(new URLSearchParams("state=foo;bar;baz"));
+
+            // assert
+            expect(subject.state).toEqual("foo");
+            expect(subject.url_state).toEqual("bar;baz");
+        });
+
         it("should read code", () => {
             // act
             const subject = new SigninResponse(new URLSearchParams("code=foo"));
diff --git a/src/SigninState.test.ts b/src/SigninState.test.ts
index 0f026bf6..3a150a74 100644
--- a/src/SigninState.test.ts
+++ b/src/SigninState.test.ts
@@ -18,12 +18,14 @@ describe("SigninState", () => {
                 redirect_uri: "http://cb",
                 request_type: "type",
                 scope: "scope",
+                url_state: "foo",
             });
 
             // assert
             expect(subject.id).toEqual("5");
             expect(subject.created).toEqual(6);
             expect(subject.data).toEqual(7);
+            expect(subject.url_state).toEqual("foo");
         });
 
         it("should accept redirect_uri", () => {
diff --git a/src/State.test.ts b/src/State.test.ts
index 87113e86..d9dff40c 100644
--- a/src/State.test.ts
+++ b/src/State.test.ts
@@ -89,12 +89,22 @@ describe("State", () => {
             // assert
             expect(subject.request_type).toEqual("xoxo");
         });
+
+        it("should accept url_state", () => {
+            // act
+            const subject = new State({
+                url_state: "foo",
+            });
+
+            // assert
+            expect(subject.url_state).toEqual("foo");
+        });
     });
 
     it("can serialize and then deserialize", () => {
         // arrange
         const subject1 = new State({
-            data: { foo: "test" }, created: 1000, request_type:"type",
+            data: { foo: "test" }, created: 1000, request_type:"type", url_state: "foo",
         });
 
         // act
diff --git a/src/UserManager.test.ts b/src/UserManager.test.ts
index 30ef5c5c..afaf997f 100644
--- a/src/UserManager.test.ts
+++ b/src/UserManager.test.ts
@@ -248,6 +248,7 @@ describe("UserManager", () => {
                 nonce: "random_nonce",
                 redirect_uri: "http://app/extra_callback",
                 prompt: "login",
+                url_state: "url_state",
             };
 
             // act
diff --git a/src/errors/ErrorResponse.test.ts b/src/errors/ErrorResponse.test.ts
index 8a6bd83f..93e6a83f 100644
--- a/src/errors/ErrorResponse.test.ts
+++ b/src/errors/ErrorResponse.test.ts
@@ -53,6 +53,13 @@ describe("ErrorResponse", () => {
             expect(subject.state).toEqual("foo");
         });
 
+        it("should read url_state", () => {
+            // act
+            const subject = new ErrorResponse({ error:"error", url_state:"foo" });
+
+            // assert
+            expect(subject.url_state).toEqual("foo");
+        });
     });
 
     describe("message", () => {