diff --git a/docs/oidc-client-ts.api.md b/docs/oidc-client-ts.api.md index 801fa087c..057bb4cf3 100644 --- a/docs/oidc-client-ts.api.md +++ b/docs/oidc-client-ts.api.md @@ -46,7 +46,7 @@ export class CheckSessionIFrame { } // @public (undocumented) -export interface CreateSigninRequestArgs extends Omit { +export interface CreateSigninRequestArgs extends Omit { // (undocumented) redirect_uri?: string; // (undocumented) @@ -622,7 +622,8 @@ 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, url_state, resource, skipUserInfo, extraQueryParams, extraTokenParams, disablePKCE, ...optionalParams }: SigninRequestArgs); + // (undocumented) + static create({ 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 }: SigninRequestCreateArgs): Promise; // (undocumented) readonly state: SigninState; // (undocumented) @@ -630,7 +631,7 @@ export class SigninRequest { } // @public (undocumented) -export interface SigninRequestArgs { +export interface SigninRequestCreateArgs { // (undocumented) acr_values?: string; // (undocumented) @@ -731,22 +732,6 @@ export type SigninSilentArgs = IFrameWindowParams & ExtraSigninRequestArgs; // @public (undocumented) export class SigninState extends State { - constructor(args: { - id?: string; - data?: unknown; - created?: number; - request_type?: string; - url_state?: string; - code_verifier?: string | boolean; - authority: string; - client_id: string; - redirect_uri: string; - scope: string; - client_secret?: string; - extraTokenParams?: Record; - response_mode?: "query" | "fragment"; - skipUserInfo?: boolean; - }); // (undocumented) readonly authority: string; // (undocumented) @@ -756,9 +741,11 @@ export class SigninState extends State { readonly code_challenge: string | undefined; readonly code_verifier: string | undefined; // (undocumented) + static create(args: SigninStateCreateArgs): Promise; + // (undocumented) readonly extraTokenParams: Record | undefined; // (undocumented) - static fromStorageString(storageString: string): SigninState; + static fromStorageString(storageString: string): Promise; // (undocumented) readonly redirect_uri: string; // (undocumented) @@ -771,6 +758,45 @@ export class SigninState extends State { toStorageString(): string; } +// @public (undocumented) +export interface SigninStateArgs { + // (undocumented) + authority: string; + // (undocumented) + client_id: string; + // (undocumented) + client_secret?: string; + // (undocumented) + code_challenge?: string; + // (undocumented) + code_verifier?: string; + // (undocumented) + created?: number; + // (undocumented) + data?: unknown; + // (undocumented) + extraTokenParams?: Record; + // (undocumented) + id?: string; + // (undocumented) + redirect_uri: string; + // (undocumented) + request_type?: string; + // (undocumented) + response_mode?: "query" | "fragment"; + // (undocumented) + scope: string; + // (undocumented) + skipUserInfo?: boolean; + // (undocumented) + url_state?: string; +} + +// @public (undocumented) +export type SigninStateCreateArgs = Omit & { + code_verifier?: string | boolean; +}; + // @public (undocumented) export type SignoutPopupArgs = PopupWindowParams & ExtraSignoutRequestArgs; @@ -838,7 +864,7 @@ export class State { readonly created: number; readonly data?: unknown; // (undocumented) - static fromStorageString(storageString: string): State; + static fromStorageString(storageString: string): Promise; // (undocumented) readonly id: string; // (undocumented) diff --git a/package-lock.json b/package-lock.json index 02a3183a5..a33d4d39a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,13 @@ "version": "2.4.0", "license": "Apache-2.0", "dependencies": { - "crypto-js": "^4.2.0", "jwt-decode": "^4.0.0" }, "devDependencies": { "@microsoft/api-extractor": "^7.35.0", "@testing-library/jest-dom": "^6.0.0", - "@types/crypto-js": "^4.1.3", "@types/jest": "^29.2.3", + "@types/node": "^20.8.2", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", "esbuild": "^0.19.5", @@ -1827,12 +1826,6 @@ "@types/node": "*" } }, - "node_modules/@types/crypto-js": { - "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": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -1935,9 +1928,9 @@ "peer": true }, "node_modules/@types/node": { - "version": "14.18.36", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.36.tgz", - "integrity": "sha512-FXKWbsJ6a1hIrRxv+FoukuHnGTgEzKYGi7kilfMae96AL9UNkPFNWJEEYWzdRI9ooIkbr4AKldyuSTLql06vLQ==", + "version": "20.8.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.2.tgz", + "integrity": "sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==", "dev": true }, "node_modules/@types/qs": { @@ -3104,11 +3097,6 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "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", "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", @@ -7284,6 +7272,19 @@ "optionalDependencies": { "commander": "^9.4.1" } + }, + "samples/Parcel": { + "name": "parcel-sample", + "extraneous": true, + "dependencies": { + "express": "^4.17.1", + "jsrsasign": "^10.3.0", + "open": "^8.2.1" + }, + "devDependencies": { + "concurrently": "^7.0.0", + "parcel": "^2.0.1" + } } } } diff --git a/package.json b/package.json index cf4d8ca56..e960b6b84 100644 --- a/package.json +++ b/package.json @@ -39,14 +39,13 @@ "prepare": "husky install" }, "dependencies": { - "crypto-js": "^4.2.0", "jwt-decode": "^4.0.0" }, "devDependencies": { "@microsoft/api-extractor": "^7.35.0", "@testing-library/jest-dom": "^6.0.0", - "@types/crypto-js": "^4.1.3", "@types/jest": "^29.2.3", + "@types/node": "^20.8.2", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", "esbuild": "^0.19.5", diff --git a/src/OidcClient.test.ts b/src/OidcClient.test.ts index 577d23937..aa3d43dc3 100644 --- a/src/OidcClient.test.ts +++ b/src/OidcClient.test.ts @@ -263,15 +263,15 @@ describe("OidcClient", () => { it("should deserialize stored state and return state and response", async () => { // arrange - const item = new SigninState({ + const item = await SigninState.create({ id: "1", authority: "authority", client_id: "client", redirect_uri: "http://app/cb", scope: "scope", request_type: "type", - }).toStorageString(); - jest.spyOn(subject.settings.stateStore, "get").mockImplementation(() => Promise.resolve(item)); + }); + jest.spyOn(subject.settings.stateStore, "get").mockImplementation(() => Promise.resolve(item.toStorageString())); // act const { state, response } = await subject.readSigninResponseState("http://app/cb?state=1"); @@ -318,7 +318,7 @@ describe("OidcClient", () => { it("should deserialize stored state and call validator", async () => { // arrange - const item = new SigninState({ + const item = await SigninState.create({ id: "1", authority: "authority", client_id: "client", diff --git a/src/OidcClient.ts b/src/OidcClient.ts index 83ba82421..f77695024 100644 --- a/src/OidcClient.ts +++ b/src/OidcClient.ts @@ -7,7 +7,7 @@ import { type OidcClientSettings, OidcClientSettingsStore } from "./OidcClientSe import { ResponseValidator } from "./ResponseValidator"; import { MetadataService } from "./MetadataService"; import type { RefreshState } from "./RefreshState"; -import { SigninRequest, type SigninRequestArgs } from "./SigninRequest"; +import { SigninRequest, type SigninRequestCreateArgs } from "./SigninRequest"; import { SigninResponse } from "./SigninResponse"; import { SignoutRequest, type SignoutRequestArgs } from "./SignoutRequest"; import { SignoutResponse } from "./SignoutResponse"; @@ -20,7 +20,7 @@ import { ClaimsService } from "./ClaimsService"; * @public */ export interface CreateSigninRequestArgs - extends Omit { + extends Omit { redirect_uri?: string; response_type?: string; scope?: string; @@ -73,7 +73,7 @@ export class OidcClient { protected readonly _tokenClient: TokenClient; public constructor(settings: OidcClientSettings); - public constructor(settings: OidcClientSettingsStore, metadataService: MetadataService); + public constructor(settings: OidcClientSettingsStore, metadataService: MetadataService); public constructor(settings: OidcClientSettings | OidcClientSettingsStore, metadataService?: MetadataService) { this.settings = settings instanceof OidcClientSettingsStore ? settings : new OidcClientSettingsStore(settings); @@ -115,7 +115,7 @@ export class OidcClient { const url = await this.metadataService.getAuthorizationEndpoint(); logger.debug("Received authorization endpoint", url); - const signinRequest = new SigninRequest({ + const signinRequest = await SigninRequest.create({ url, authority: this.settings.authority, client_id: this.settings.client_id, @@ -156,7 +156,7 @@ export class OidcClient { throw null; // https://github.com/microsoft/TypeScript/issues/46972 } - const state = SigninState.fromStorageString(storedStateString); + const state = await SigninState.fromStorageString(storedStateString); return { state, response }; } @@ -286,7 +286,7 @@ export class OidcClient { throw null; // https://github.com/microsoft/TypeScript/issues/46972 } - const state = State.fromStorageString(storedStateString); + const state = await State.fromStorageString(storedStateString); return { state, response }; } diff --git a/src/SigninRequest.test.ts b/src/SigninRequest.test.ts index 77c3ad258..a2e779daf 100644 --- a/src/SigninRequest.test.ts +++ b/src/SigninRequest.test.ts @@ -1,15 +1,15 @@ // 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 { SigninRequest, type SigninRequestArgs } from "./SigninRequest"; +import { SigninRequest, type SigninRequestCreateArgs } from "./SigninRequest"; import { URL_STATE_DELIMITER } from "./utils"; describe("SigninRequest", () => { let subject: SigninRequest; - let settings: SigninRequestArgs; + let settings: SigninRequestCreateArgs; - beforeEach(() => { + beforeEach(async () => { settings = { url: "http://sts/signin", client_id: "client", @@ -19,186 +19,203 @@ describe("SigninRequest", () => { authority : "op", state_data: { data: "test" }, }; - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); }); describe("constructor", () => { - it.each(["url", "client_id", "redirect_uri", "response_type", "scope", "authority"])("should require a %s param", (param) => { + it.each(["url", "client_id", "redirect_uri", "response_type", "scope", "authority"])("should require a %s param", async (param) => { // arrange Object.assign(settings, { [param]: undefined }); // act - expect(() => new SigninRequest(settings)) + await expect(() => SigninRequest.create(settings)) // assert - .toThrow(param); + .rejects.toThrow(param); }); }); describe("url", () => { + let url: string; + + beforeEach(async () => { + url = subject.url; + }); it("should include url", () => { // assert - expect(subject.url.indexOf("http://sts/signin")).toEqual(0); + expect(url.indexOf("http://sts/signin")).toEqual(0); }); it("should include client_id", () => { // assert - expect(subject.url).toContain("client_id=client"); + expect(url).toContain("client_id=client"); }); it("should include redirect_uri", () => { // assert - expect(subject.url).toContain("redirect_uri=" + encodeURIComponent("http://app")); + expect(url).toContain("redirect_uri=" + encodeURIComponent("http://app")); }); it("should include response_type", () => { // assert - expect(subject.url).toContain("response_type=code"); + expect(url).toContain("response_type=code"); }); it("should include scope", () => { // assert - expect(subject.url).toContain("scope=openid"); + expect(url).toContain("scope=openid"); }); it("should include state", () => { // assert - expect(subject.url).toContain("state=" + subject.state.id); + expect(url).toContain("state=" + subject.state.id); }); - it("should include prompt", () => { + it("should include prompt", async () => { // arrange settings.prompt = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("prompt=foo"); + expect(url).toContain("prompt=foo"); }); - it("should include display", () => { + it("should include display", async () => { // arrange settings.display = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("display=foo"); + expect(url).toContain("display=foo"); }); - it("should include max_age", () => { + it("should include max_age", async () => { // arrange settings.max_age = 42; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("max_age=42"); + expect(url).toContain("max_age=42"); }); - it("should include ui_locales", () => { + it("should include ui_locales", async () => { // arrange settings.ui_locales = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("ui_locales=foo"); + expect(url).toContain("ui_locales=foo"); }); - it("should include id_token_hint", () => { + it("should include id_token_hint", async () => { // arrange settings.id_token_hint = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("id_token_hint=foo"); + expect(url).toContain("id_token_hint=foo"); }); - it("should include login_hint", () => { + it("should include login_hint", async () => { // arrange settings.login_hint = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("login_hint=foo"); + expect(url).toContain("login_hint=foo"); }); - it("should include acr_values", () => { + it("should include acr_values", async () => { // arrange settings.acr_values = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("acr_values=foo"); + expect(url).toContain("acr_values=foo"); }); - it("should include a resource", () => { + it("should include a resource", async () => { // arrange settings.resource = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("resource=foo"); + expect(url).toContain("resource=foo"); }); - it("should include multiple resources", () => { + it("should include multiple resources", async () => { // arrange settings.resource = ["foo", "bar"]; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("resource=foo&resource=bar"); + expect(url).toContain("resource=foo&resource=bar"); }); - it("should include response_mode", () => { + it("should include response_mode", async () => { // arrange settings.response_mode = "fragment"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("response_mode=fragment"); + expect(url).toContain("response_mode=fragment"); }); - it("should include request", () => { + it("should include request", async () => { // arrange settings.request = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("request=foo"); + expect(url).toContain("request=foo"); }); - it("should include request_uri", () => { + it("should include request_uri", async () => { // arrange settings.request_uri = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("request_uri=foo"); + expect(url).toContain("request_uri=foo"); }); - it("should include extra query params", () => { + it("should include extra query params", async () => { // arrange settings.extraQueryParams = { "hd": "domain.com", @@ -206,20 +223,21 @@ describe("SigninRequest", () => { }; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("hd=domain.com&foo=bar"); + expect(url).toContain("hd=domain.com&foo=bar"); }); - it("should store extra token params in state", () => { + it("should store extra token params in state", async () => { // arrange settings.extraTokenParams = { "resourceServer": "abc", }; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); // assert expect(subject.state.extraTokenParams).toEqual({ @@ -227,35 +245,37 @@ describe("SigninRequest", () => { }); }); - it("should include code flow params", () => { + it("should include code flow params", async () => { // arrange settings.response_type = "code"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("code_challenge="); - expect(subject.url).toContain("code_challenge_method=S256"); + expect(url).toContain("code_challenge="); + expect(url).toContain("code_challenge_method=S256"); }); - it("should include nonce", () => { + it("should include nonce", async () => { // arrange settings.nonce = "random_nonce"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); + url = subject.url; // assert - expect(subject.url).toContain("nonce="); + expect(url).toContain("nonce="); }); - it("should include url_state", () => { + it("should include url_state", async () => { // arrange settings.url_state = "foo"; // act - subject = new SigninRequest(settings); + subject = await SigninRequest.create(settings); // assert expect(subject.url).toContain("state=" + subject.state.id + encodeURIComponent(URL_STATE_DELIMITER + "foo")); diff --git a/src/SigninRequest.ts b/src/SigninRequest.ts index d6b3a15fe..b44bfb534 100644 --- a/src/SigninRequest.ts +++ b/src/SigninRequest.ts @@ -8,7 +8,7 @@ import { SigninState } from "./SigninState"; * @public * @see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest */ -export interface SigninRequestArgs { +export interface SigninRequestCreateArgs { // mandatory url: string; authority: string; @@ -49,12 +49,20 @@ export interface SigninRequestArgs { * @public */ export class SigninRequest { - private readonly _logger = new Logger("SigninRequest"); + private static readonly _logger = new Logger("SigninRequest"); public readonly url: string; public readonly state: SigninState; - public constructor({ + private constructor(args: { + url: string; + state: SigninState; + }) { + this.url = args.url; + this.state = args.state; + } + + public static async create({ // mandatory url, authority, client_id, redirect_uri, response_type, scope, // optional @@ -65,33 +73,33 @@ export class SigninRequest { extraTokenParams, disablePKCE, ...optionalParams - }: SigninRequestArgs) { + }: SigninRequestCreateArgs): Promise { if (!url) { - this._logger.error("ctor: No url passed"); + this._logger.error("create: No url passed"); throw new Error("url"); } if (!client_id) { - this._logger.error("ctor: No client_id passed"); + this._logger.error("create: No client_id passed"); throw new Error("client_id"); } if (!redirect_uri) { - this._logger.error("ctor: No redirect_uri passed"); + this._logger.error("create: No redirect_uri passed"); throw new Error("redirect_uri"); } if (!response_type) { - this._logger.error("ctor: No response_type passed"); + this._logger.error("create: No response_type passed"); throw new Error("response_type"); } if (!scope) { - this._logger.error("ctor: No scope passed"); + this._logger.error("create: No scope passed"); throw new Error("scope"); } if (!authority) { - this._logger.error("ctor: No authority passed"); + this._logger.error("create: No authority passed"); throw new Error("authority"); } - this.state = new SigninState({ + const state = await SigninState.create({ data: state_data, request_type, url_state, @@ -111,13 +119,13 @@ export class SigninRequest { parsedUrl.searchParams.append("nonce", nonce); } - let state = this.state.id; + let stateParam = state.id; if (url_state) { - state = `${state}${URL_STATE_DELIMITER}${url_state}`; + stateParam = `${stateParam}${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("state", stateParam); + if (state.code_challenge) { + parsedUrl.searchParams.append("code_challenge", state.code_challenge); parsedUrl.searchParams.append("code_challenge_method", "S256"); } @@ -134,6 +142,9 @@ export class SigninRequest { } } - this.url = parsedUrl.href; + return new SigninRequest({ + url: parsedUrl.href, + state, + }); } } diff --git a/src/SigninState.test.ts b/src/SigninState.test.ts index 3a150a74e..ddde83aae 100644 --- a/src/SigninState.test.ts +++ b/src/SigninState.test.ts @@ -6,9 +6,9 @@ import { SigninState } from "./SigninState"; describe("SigninState", () => { describe("constructor", () => { - it("should call base ctor", () => { + it("should call base ctor", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ id: "5", created: 6, data: 7, @@ -28,9 +28,9 @@ describe("SigninState", () => { expect(subject.url_state).toEqual("foo"); }); - it("should accept redirect_uri", () => { + it("should accept redirect_uri", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ authority: "authority", client_id: "client", scope: "scope", @@ -42,9 +42,9 @@ describe("SigninState", () => { expect(subject.redirect_uri).toEqual("http://cb"); }); - it("should accept code_verifier", () => { + it("should accept code_verifier", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ authority: "authority", client_id: "client", redirect_uri: "http://cb", @@ -57,9 +57,9 @@ describe("SigninState", () => { expect(subject.code_verifier).toEqual("5"); }); - it("should generate code_verifier", () => { + it("should generate code_verifier", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ authority: "authority", client_id: "client", redirect_uri: "http://cb", @@ -72,11 +72,11 @@ describe("SigninState", () => { expect(subject.code_verifier).toBeDefined(); }); - it("should generate code_challenge", () => { + it("should generate code_challenge", async () => { // arrange // act - const subject = new SigninState({ + const subject = await SigninState.create({ authority: "authority", client_id: "client", redirect_uri: "http://cb", @@ -89,9 +89,9 @@ describe("SigninState", () => { expect(subject.code_challenge).toBeDefined(); }); - it("should accept client_id", () => { + it("should accept client_id", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ authority: "authority", redirect_uri: "http://cb", scope: "scope", @@ -103,9 +103,9 @@ describe("SigninState", () => { expect(subject.client_id).toEqual("client"); }); - it("should accept authority", () => { + it("should accept authority", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ client_id: "client", redirect_uri: "http://cb", scope: "scope", @@ -117,9 +117,9 @@ describe("SigninState", () => { expect(subject.authority).toEqual("test"); }); - it("should accept request_type", () => { + it("should accept request_type", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ authority: "authority", client_id: "client", redirect_uri: "http://cb", @@ -131,9 +131,9 @@ describe("SigninState", () => { expect(subject.request_type).toEqual("xoxo"); }); - it("should accept extraTokenParams", () => { + it("should accept extraTokenParams", async () => { // act - const subject = new SigninState({ + const subject = await SigninState.create({ authority: "authority", client_id: "client", redirect_uri: "http://cb", @@ -149,9 +149,9 @@ describe("SigninState", () => { }); }); - it("can serialize and then deserialize", () => { + it("can serialize and then deserialize", async () => { // arrange - const subject1 = new SigninState({ + const subject1 = await SigninState.create({ data: { foo: "test" }, created: 1000, code_verifier: true, @@ -164,7 +164,7 @@ describe("SigninState", () => { // act const storage = subject1.toStorageString(); - const subject2 = SigninState.fromStorageString(storage); + const subject2 = await SigninState.fromStorageString(storage); // assert expect(subject2).toEqual(subject1); diff --git a/src/SigninState.ts b/src/SigninState.ts index df84eeb2a..a53be0e90 100644 --- a/src/SigninState.ts +++ b/src/SigninState.ts @@ -4,6 +4,31 @@ import { Logger, CryptoUtils } from "./utils"; import { State } from "./State"; +/** @public */ +export interface SigninStateArgs { + id?: string; + data?: unknown; + created?: number; + request_type?: string; + + code_verifier?: string; + code_challenge?: string; + authority: string; + client_id: string; + redirect_uri: string; + scope: string; + client_secret?: string; + extraTokenParams?: Record; + response_mode?: "query" | "fragment"; + skipUserInfo?: boolean; + url_state?: string; +} + +/** @public */ +export type SigninStateCreateArgs = Omit & { + code_verifier?: string | boolean; +}; + /** * @public */ @@ -32,36 +57,11 @@ export class SigninState extends State { public readonly skipUserInfo: boolean | undefined; - public constructor(args: { - id?: string; - data?: unknown; - created?: number; - request_type?: string; - url_state?: string; - - code_verifier?: string | boolean; - authority: string; - client_id: string; - redirect_uri: string; - scope: string; - client_secret?: string; - extraTokenParams?: Record; - response_mode?: "query" | "fragment"; - skipUserInfo?: boolean; - }) { + private constructor(args: SigninStateArgs) { super(args); - if (args.code_verifier === true) { - this.code_verifier = CryptoUtils.generateCodeVerifier(); - } - else if (args.code_verifier) { - this.code_verifier = args.code_verifier; - } - - if (this.code_verifier) { - this.code_challenge = CryptoUtils.generateCodeChallenge(this.code_verifier); - } - + this.code_verifier = args.code_verifier; + this.code_challenge = args.code_challenge; this.authority = args.authority; this.client_id = args.client_id; this.redirect_uri = args.redirect_uri; @@ -73,6 +73,17 @@ export class SigninState extends State { this.skipUserInfo = args.skipUserInfo; } + public static async create(args: SigninStateCreateArgs): Promise { + const code_verifier = args.code_verifier === true ? CryptoUtils.generateCodeVerifier() : (args.code_verifier || undefined); + const code_challenge = code_verifier ? (await CryptoUtils.generateCodeChallenge(code_verifier)) : undefined; + + return new SigninState({ + ...args, + code_verifier, + code_challenge, + }); + } + public toStorageString(): string { new Logger("SigninState").create("toStorageString"); return JSON.stringify({ @@ -94,9 +105,9 @@ export class SigninState extends State { }); } - public static fromStorageString(storageString: string): SigninState { + public static fromStorageString(storageString: string): Promise { Logger.createStatic("SigninState", "fromStorageString"); const data = JSON.parse(storageString); - return new SigninState(data); + return SigninState.create(data); } } diff --git a/src/State.test.ts b/src/State.test.ts index d9dff40c9..462cb43a0 100644 --- a/src/State.test.ts +++ b/src/State.test.ts @@ -101,7 +101,7 @@ describe("State", () => { }); }); - it("can serialize and then deserialize", () => { + it("can serialize and then deserialize", async () => { // arrange const subject1 = new State({ data: { foo: "test" }, created: 1000, request_type:"type", url_state: "foo", @@ -109,7 +109,7 @@ describe("State", () => { // act const storage = subject1.toStorageString(); - const subject2 = State.fromStorageString(storage); + const subject2 = await State.fromStorageString(storage); // assert expect(subject2).toEqual(subject1); diff --git a/src/State.ts b/src/State.ts index 971dde12a..b9294b1cd 100644 --- a/src/State.ts +++ b/src/State.ts @@ -47,9 +47,9 @@ export class State { }); } - public static fromStorageString(storageString: string): State { + public static fromStorageString(storageString: string): Promise { Logger.createStatic("State", "fromStorageString"); - return new State(JSON.parse(storageString)); + return Promise.resolve(new State(JSON.parse(storageString))); } public static async clearStaleState(storage: StateStore, age: number): Promise { @@ -66,7 +66,7 @@ export class State { if (item) { try { - const state = State.fromStorageString(item); + const state = await State.fromStorageString(item); logger.debug("got item from key:", key, state.created); if (state.created <= cutoff) { diff --git a/src/index.ts b/src/index.ts index 7f6301969..387328c67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,10 +19,11 @@ export type { OidcClientSettings, SigningKey, ExtraHeader } from "./OidcClientSe export type { OidcMetadata } from "./OidcMetadata"; export { SessionMonitor } from "./SessionMonitor"; export type { SessionStatus } from "./SessionStatus"; -export type { SigninRequest, SigninRequestArgs } from "./SigninRequest"; +export type { SigninRequest, SigninRequestCreateArgs } from "./SigninRequest"; export type { RefreshState } from "./RefreshState"; export { SigninResponse } from "./SigninResponse"; export { SigninState } from "./SigninState"; +export type { SigninStateArgs, SigninStateCreateArgs } from "./SigninState"; export type { SignoutRequest, SignoutRequestArgs } from "./SignoutRequest"; export { SignoutResponse } from "./SignoutResponse"; export { State } from "./State"; diff --git a/src/utils/CryptoUtils.ts b/src/utils/CryptoUtils.ts index b1dc0b2d6..66e77782f 100644 --- a/src/utils/CryptoUtils.ts +++ b/src/utils/CryptoUtils.ts @@ -1,18 +1,20 @@ -import CryptoJS from "crypto-js/core.js"; -import sha256 from "crypto-js/sha256.js"; -import Base64 from "crypto-js/enc-base64.js"; -import Utf8 from "crypto-js/enc-utf8.js"; - import { Logger } from "./Logger"; const UUID_V4_TEMPLATE = "10000000-1000-4000-8000-100000000000"; +const toBase64 = (val: ArrayBuffer): string => + btoa([...new Uint8Array(val)] + .map((chr) => String.fromCharCode(chr)) + .join("")); + /** * @internal */ export class CryptoUtils { private static _randomWord(): number { - return CryptoJS.lib.WordArray.random(1).words[0]; + const arr = new Uint32Array(1); + crypto.getRandomValues(arr); + return arr[0]; } /** @@ -35,10 +37,12 @@ export class CryptoUtils { /** * PKCE: Generate a code challenge */ - public static generateCodeChallenge(code_verifier: string): string { + public static async generateCodeChallenge(code_verifier: string): Promise { try { - const hashed = sha256(code_verifier); - return Base64.stringify(hashed).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const encoder = new TextEncoder(); + const data = encoder.encode(code_verifier); + const hashed = await crypto.subtle.digest("SHA-256", data); + return toBase64(hashed).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } catch (err) { Logger.error("CryptoUtils.generateCodeChallenge", err); @@ -50,7 +54,8 @@ export class CryptoUtils { * Generates a base64-encoded string for a basic auth header */ public static generateBasicAuth(client_id: string, client_secret: string): string { - const basicAuth = Utf8.parse([client_id, client_secret].join(":")); - return Base64.stringify(basicAuth); + const encoder = new TextEncoder(); + const data = encoder.encode([client_id, client_secret].join(":")); + return toBase64(data); } } diff --git a/test/setup.ts b/test/setup.ts index 34b5b46de..a38681ba8 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,6 +1,12 @@ import { Log } from "../src"; +import { TextEncoder } from "util"; +import { webcrypto } from "node:crypto"; beforeAll(() => { + globalThis.TextEncoder = TextEncoder; + Object.assign(globalThis.crypto, { + subtle: webcrypto.subtle, + }); globalThis.fetch = jest.fn(); const unload = () =>