Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom URL state values #1212

Merged
merged 3 commits into from
Oct 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 17 additions & 3 deletions docs/oidc-client-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -79,6 +80,8 @@ export class ErrorResponse extends Error {
// (undocumented)
readonly session_state: string | null;
state?: unknown;
// (undocumented)
url_state?: string;
}

// @public
Expand All @@ -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">;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -687,6 +690,8 @@ export interface SigninRequestArgs {
ui_locales?: string;
// (undocumented)
url: string;
// (undocumented)
url_state?: string;
}

// @public (undocumented)
Expand Down Expand Up @@ -726,6 +731,8 @@ export class SigninResponse {
readonly state: string | null;
// (undocumented)
token_type: string;
// (undocumented)
url_state?: string;
userState: unknown;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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>;
Expand All @@ -846,6 +855,8 @@ export class State {
readonly request_type: string | undefined;
// (undocumented)
toStorageString(): string;
// (undocumented)
readonly url_state: string | undefined;
}

// @public (undocumented)
Expand All @@ -872,6 +883,7 @@ export class User {
profile: UserProfile;
expires_at?: number;
userState?: unknown;
url_state?: string;
});
access_token: string;
get expired(): boolean | undefined;
Expand All @@ -890,6 +902,8 @@ export class User {
token_type: string;
// (undocumented)
toStorageString(): string;
// (undocumented)
readonly url_state?: string;
}

// @public (undocumented)
Expand Down
4 changes: 4 additions & 0 deletions src/OidcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe("OidcClient", () => {
request: "req",
request_uri: "req_uri",
nonce: "rnd",
url_state: "url_state",
});

// assert
Expand All @@ -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 () => {
Expand All @@ -128,6 +130,7 @@ describe("OidcClient", () => {
login_hint: "lh",
acr_values: "av",
resource: "res",
url_state: "url_state",
});

// assert
Expand All @@ -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 () => {
Expand Down
2 changes: 2 additions & 0 deletions src/OidcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/ResponseValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions src/ResponseValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions src/SigninRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {

Expand Down Expand Up @@ -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"));
});
});
});
12 changes: 9 additions & 3 deletions src/SigninRequest.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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}`;
pamapa marked this conversation as resolved.
Show resolved Hide resolved
}
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");
Expand Down
18 changes: 18 additions & 0 deletions src/SigninResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
10 changes: 9 additions & 1 deletion src/SigninResponse.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -44,13 +44,21 @@ 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;

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");
Expand Down
2 changes: 2 additions & 0 deletions src/SigninState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/SigninState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion src/State.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading