Skip to content

Commit

Permalink
feat: add ability to specify iframe script origin and iframe notify p…
Browse files Browse the repository at this point in the history
…arent for silent auth (authts#514)

* feat: silent refresh using different domains
- add ability to specify target origin when doing postMessage for parent
- add ability to specify script origin to listen on silent callback
- update unit tests
- update gitignore for local history plugin
  • Loading branch information
pavliy authored May 11, 2022
1 parent eef6a64 commit e28dcef
Show file tree
Hide file tree
Showing 10 changed files with 231 additions and 6 deletions.
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/
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 @@ -44,6 +44,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

0 comments on commit e28dcef

Please sign in to comment.