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

feat: add ability to specify iframe script origin and iframe notify parent for silent auth #514

Merged
merged 3 commits into from
May 11, 2022
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ node_modules/
.DS_Store
.vscode/
temp/
.history/
pavliy marked this conversation as resolved.
Show resolved Hide resolved
6 changes: 6 additions & 0 deletions docs/oidc-client-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,8 @@ export interface UserManagerSettings extends OidcClientSettings {
accessTokenExpiringNotificationTimeInSeconds?: number;
automaticSilentRenew?: boolean;
checkSessionIntervalInSeconds?: number;
iframeNotifyParentOrigin?: string;
iframeScriptOrigin?: string;
includeIdTokenInSilentRenew?: boolean;
// (undocumented)
monitorAnonymousSession?: boolean;
Expand Down Expand Up @@ -1000,6 +1002,10 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
// (undocumented)
readonly checkSessionIntervalInSeconds: number;
// (undocumented)
readonly iframeNotifyParentOrigin: string | undefined;
// (undocumented)
readonly iframeScriptOrigin: string | undefined;
// (undocumented)
readonly includeIdTokenInSilentRenew: boolean;
// (undocumented)
readonly monitorAnonymousSession: boolean;
Expand Down
15 changes: 15 additions & 0 deletions src/UserManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ describe("UserManager", () => {
// act
expect(subject.settings.client_id).toEqual("client");
});

it.each([
{ monitorSession: true, message: "should" },
{ monitorSession: false, message: "should not" },
])("when monitorSession is $monitorSession $message init sessionMonitor", (args) => {
const settings = { ...subject.settings, monitorSession: args.monitorSession };

const userManager = new UserManager(settings);
const sessionMonitor = userManager["_sessionMonitor"];
if (args.monitorSession) {
expect(sessionMonitor).toBeDefined();
} else {
expect(sessionMonitor).toBeNull();
}
});
});

describe("settings", () => {
Expand Down
1 change: 1 addition & 0 deletions src/UserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ export class UserManager {
url: signinRequest.url,
state: signinRequest.state.id,
response_mode: signinRequest.state.response_mode,
scriptOrigin: this.settings.iframeScriptOrigin,
});
}
catch (err) {
Expand Down
15 changes: 15 additions & 0 deletions src/UserManagerSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface UserManagerSettings extends OidcClientSettings {
/** The methods window.location method used to redirect (default: "assign") */
redirectMethod?: "replace" | "assign";

/** The target to pass while calling postMessage inside iframe for callback (default: window.location.origin) */
iframeNotifyParentOrigin?: string;

/** The script origin to check during 'message' callback execution while performing silent auth via iframe (default: window.location.origin) */
iframeScriptOrigin?: string;

/** The URL for the page containing the code handling the silent renew */
silent_redirect_uri?: string;
/** Number of seconds to wait for the silent renew to return before assuming it has failed or timed out (default: 10) */
Expand Down Expand Up @@ -86,6 +92,9 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
public readonly popupWindowTarget: string;
public readonly redirectMethod: "replace" | "assign";

public readonly iframeNotifyParentOrigin: string | undefined;
public readonly iframeScriptOrigin: string | undefined;

public readonly silent_redirect_uri: string;
public readonly silentRequestTimeoutInSeconds: number;
public readonly automaticSilentRenew: boolean;
Expand All @@ -112,6 +121,9 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
popupWindowTarget = DefaultPopupTarget,
redirectMethod = "assign",

iframeNotifyParentOrigin = args.iframeNotifyParentOrigin,
iframeScriptOrigin = args.iframeScriptOrigin,

silent_redirect_uri = args.redirect_uri,
silentRequestTimeoutInSeconds = DefaultSilentRequestTimeoutInSeconds,
automaticSilentRenew = true,
Expand Down Expand Up @@ -139,6 +151,9 @@ export class UserManagerSettingsStore extends OidcClientSettingsStore {
this.popupWindowTarget = popupWindowTarget;
this.redirectMethod = redirectMethod;

this.iframeNotifyParentOrigin = iframeNotifyParentOrigin;
this.iframeScriptOrigin = iframeScriptOrigin;

this.silent_redirect_uri = silent_redirect_uri;
this.silentRequestTimeoutInSeconds = silentRequestTimeoutInSeconds;
this.automaticSilentRenew = automaticSilentRenew;
Expand Down
7 changes: 4 additions & 3 deletions src/navigators/AbstractChildWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export abstract class AbstractChildWindow implements IWindow {
const { url, keepOpen } = await new Promise<MessageData>((resolve, reject) => {
const listener = (e: MessageEvent) => {
const data: MessageData | undefined = e.data;
if (e.origin !== window.location.origin || data?.source !== messageSource) {
const origin = params.scriptOrigin ?? window.location.origin;
if (e.origin !== origin || data?.source !== messageSource) {
// silently discard events not intended for us
return;
}
Expand Down Expand Up @@ -86,11 +87,11 @@ export abstract class AbstractChildWindow implements IWindow {
this._disposeHandlers.clear();
}

protected static _notifyParent(parent: Window, url: string, keepOpen = false): void {
protected static _notifyParent(parent: Window, url: string, keepOpen = false, targetOrigin = window.location.origin): void {
parent.postMessage({
source: messageSource,
url,
keepOpen,
} as MessageData, window.location.origin);
} as MessageData, targetOrigin);
}
}
2 changes: 1 addition & 1 deletion src/navigators/IFrameNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ export class IFrameNavigator implements INavigator {

public async callback(url: string): Promise<void> {
this._logger.create("callback");
IFrameWindow.notifyParent(url);
IFrameWindow.notifyParent(url, this._settings.iframeNotifyParentOrigin);
}
}
185 changes: 185 additions & 0 deletions src/navigators/IFrameWindow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { IFrameWindow } from "./IFrameWindow";
import type { NavigateParams } from "./IWindow";

const flushPromises = () => new Promise(process.nextTick);

describe("IFrameWindow", () => {
const postMessageMock = jest.fn();
const fakeWindowOrigin = "https://fake-origin.com";
const fakeUrl = "https://fakeurl.com";

afterEach(() => {
jest.clearAllMocks();
});

describe("hidden frame", () => {
let frameWindow: IFrameWindow;

beforeAll(() => {
frameWindow = new IFrameWindow({ });
});

it("should have appropriate styles for hidden presentation", () => {
const { visibility, position, left, top } = frameWindow["_frame"]!.style;

expect(visibility).toBe("hidden");
expect(position).toBe("fixed");
expect(left).toBe("-1000px");
expect(top).toBe("0px");
});

it("should have 0 width and height", () => {
const { width, height } = frameWindow["_frame"]!;
expect(width).toBe("0");
expect(height).toBe("0");
});

it("should set correct sandbox attribute", () => {
const sandboxAttr = frameWindow["_frame"]!.attributes.getNamedItem("sandbox");
expect(sandboxAttr?.value).toBe("allow-scripts allow-same-origin allow-forms");
});
});

describe("close", () => {
let subject: IFrameWindow;
const parentRemoveChild = jest.fn();
beforeEach(() => {
subject = new IFrameWindow({});
jest.spyOn(subject["_frame"]!, "parentNode", "get").mockReturnValue({
removeChild: parentRemoveChild,
} as unknown as ParentNode);
});

it("should reset window to null", () => {
subject.close();
expect(subject["_window"]).toBeNull();
});

describe("if frame defined", () => {
it("should set blank url for contentWindow", () => {
const replaceMock = jest.fn();
jest.spyOn(subject["_frame"]!, "contentWindow", "get")
.mockReturnValue({ location: { replace: replaceMock } } as unknown as WindowProxy);

subject.close();
expect(replaceMock).toBeCalledWith("about:blank");
});

it("should reset frame to null", () => {
subject.close();
expect(subject["_frame"]).toBeNull();
});
});
});

describe("navigate", () => {
const contentWindowMock = jest.fn();

beforeAll(() => {
jest.spyOn(window, "parent", "get").mockReturnValue({
postMessage: postMessageMock,
} as unknown as WindowProxy);
Object.defineProperty(window, "location", {
enumerable: true,
value: { origin: fakeWindowOrigin },
});

contentWindowMock.mockReturnValue(null);
jest.spyOn(window.document.body, "appendChild").mockImplementation();
jest.spyOn(window.document, "createElement").mockImplementation(() => ({
contentWindow: contentWindowMock(),
style: {},
setAttribute: jest.fn(),
} as unknown as HTMLIFrameElement),
);
});

it("when frame.contentWindow is not defined should throw error", async() => {
const frameWindow = new IFrameWindow({});
await expect(frameWindow.navigate({} as NavigateParams))
.rejects
.toMatchObject({ message: "Attempted to navigate on a disposed window" });
});

describe("when message received", () => {
const fakeState = "fffaaakkkeee_state";
const fakeContentWindow = { location: { replace: jest.fn() } };
const validNavigateParams = {
source: fakeContentWindow,
data: { source: "oidc-client",
url: `https://test.com?state=${fakeState}` },
origin: fakeWindowOrigin,
};
const navigateParamsStub = jest.fn();

beforeAll(() => {
contentWindowMock.mockReturnValue(fakeContentWindow);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jest.spyOn(window, "addEventListener").mockImplementation((_, listener: any) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
listener(navigateParamsStub());
});
});

it.each([
["https://custom-origin.com", "https://custom-origin.com" ],
[ fakeWindowOrigin, undefined],
])("and all parameters match should resolve navigation without issues", async (origin, scriptOrigin) => {
navigateParamsStub.mockReturnValue({ ...validNavigateParams, origin });
const frameWindow = new IFrameWindow({});
await expect(frameWindow.navigate({ state: fakeState, url: fakeUrl, scriptOrigin })).resolves.not.toThrow();
});

it.each([
{ passedOrigin: undefined, type: "window origin" },
{ passedOrigin: "https://custom-origin.com", type: "passed script origi" },
])("and message origin does not match $type should never resolve", async (args) => {
let promiseDone = false;
navigateParamsStub.mockReturnValue({ ...validNavigateParams, origin: "http://different.com" });

const frameWindow = new IFrameWindow({});
const promise = frameWindow.navigate({ state: fakeState, url: fakeUrl, scriptOrigin: args.passedOrigin });

promise.finally(() => promiseDone = true);
await flushPromises();

expect(promiseDone).toBe(false);
});

it("and data url parse fails should reject with error", async () => {
navigateParamsStub.mockReturnValue({ ...validNavigateParams, data: { ...validNavigateParams.data, url: undefined } });
const frameWindow = new IFrameWindow({});
await expect(frameWindow.navigate({ state: fakeState, url: fakeUrl })).rejects.toThrowError("Invalid response from window");
});

it("and args source with state do not match contentWindow should never resolve", async () => {
let promiseDone = false;
navigateParamsStub.mockReturnValue({ ...validNavigateParams, source: {} });

const frameWindow = new IFrameWindow({});
const promise = frameWindow.navigate({ state: "diff_state", url: fakeUrl });

promise.finally(() => promiseDone = true);
await flushPromises();

expect(promiseDone).toBe(false);
});
});
});

describe("notifyParent", () => {
const messageData = {
source: "oidc-client",
url: fakeUrl,
keepOpen: false,
};

it.each([
["https://parent-domain.com", "https://parent-domain.com"],
[undefined, fakeWindowOrigin],
])("should call postMessage with appropriate parameters", (targetOrigin, expectedOrigin) => {
IFrameWindow.notifyParent(messageData.url, targetOrigin);
expect(postMessageMock).toBeCalledWith(messageData, expectedOrigin);
});
});
});
4 changes: 2 additions & 2 deletions src/navigators/IFrameWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class IFrameWindow extends AbstractChildWindow {
this._window = null;
}

public static notifyParent(url: string): void {
return super._notifyParent(window.parent, url);
public static notifyParent(url: string, targetOrigin?: string): void {
return super._notifyParent(window.parent, url, false, targetOrigin);
}
}
1 change: 1 addition & 0 deletions src/navigators/IWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface NavigateParams {
/** The request "state" parameter. For sign out requests, this parameter is optional. */
state?: string;
response_mode?: "query" | "fragment";
scriptOrigin?: string;
}

/**
Expand Down