Skip to content
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "processout.js",
"version": "1.9.18",
"version": "1.9.19",
"description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.",
"scripts": {
"build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js",
Expand Down
8 changes: 8 additions & 0 deletions src/processout/actionhandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ module ProcessOut {
// gatewayLogo is shown when the action is done on another tab or window
public gatewayLogo?: string;

// Set for 3DS challenges so scheme passkeys (WebAuthn in the ACS frame) aren't blocked
public allowPasskeys: boolean = false;

/** When set, iframe modal and new-window overlay are appended here instead of document.body */
public overlayMountParent?: HTMLElement;

Expand All @@ -103,6 +106,7 @@ module ProcessOut {
// in an iframe (if it is supported)
case ActionHandlerOptions.ThreeDSChallengeFlow:
this.flow = ActionFlow.IFrame;
this.allowPasskeys = true;
break;

// The 3DS flow where iframe is not supported
Expand Down Expand Up @@ -238,6 +242,10 @@ module ProcessOut {
var iframe = document.createElement("iframe");
iframe.setAttribute("style", `margin: 1em auto; width: ${this.options.iframeWidth}px; height: ${this.options.iframeHeight}px; max-width: 100%; max-height: 100%; display: block; box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07); background-color: #ECEFF1; background-image: url("${this.instance.endpoint("js", "/images/loader.gif")}"); background-repeat: no-repeat; background-position: center;")`);
iframe.setAttribute("frameborder", "0");
if (this.options.allowPasskeys) {
// "*" because the frame redirects to issuer ACS origins we can't enumerate
iframe.setAttribute("allow", "payment *; publickey-credentials-get *; publickey-credentials-create *");
}
iframe.onload = function() {
// Remove the background loader once it is actually loaded
iframe.style.backgroundImage = null;
Expand Down
38 changes: 38 additions & 0 deletions test/processout/actionhandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest"
import { loadActionHandler } from "../support/loadNamespace"

const PASSKEY_PERMISSIONS = "payment *; publickey-credentials-get *; publickey-credentials-create *"

const fakeInstance = { endpoint: (_sub: string, path: string) => `https://js.example${path}` }

function createdIframes(actionType?: string, override?: { width: number; height: number }) {
const { namespace, createdElements } = loadActionHandler()
const { ActionHandler, ActionHandlerOptions } = namespace
new ActionHandler(fakeInstance, new ActionHandlerOptions(actionType, undefined, override))
return createdElements.filter((el) => el.tagName === "iframe")
}

describe("ActionHandler iframe permissions", () => {
it("delegates passkey and payment permissions to the 3DS challenge iframe", () => {
const iframes = createdIframes("three-d-s-challenge-flow")
expect(iframes).toHaveLength(1)
expect(iframes[0].attributes.allow).toBe(PASSKEY_PERMISSIONS)
expect(iframes[0].attributes.frameborder).toBe("0")
})

it("keeps the 3DS challenge permissions when an iframe size override is set", () => {
const [iframe] = createdIframes("three-d-s-challenge-flow", { width: 600, height: 400 })
expect(iframe.attributes.allow).toBe(PASSKEY_PERMISSIONS)
})

it("does not delegate permissions to non-3DS iframe overrides", () => {
const [iframe] = createdIframes("some-apm-gateway", { width: 600, height: 400 })
expect(iframe).toBeDefined()
expect(iframe.attributes.allow).toBeUndefined()
})

it("creates no iframe for flows that leave the page", () => {
expect(createdIframes("three-d-s-challenge-flow-no-iframe")).toHaveLength(0)
expect(createdIframes("paypal")).toHaveLength(0)
})
})
53 changes: 53 additions & 0 deletions test/support/loadNamespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,56 @@ export function loadTelemetry(): Record<string, any> {
run(moduleShim, moduleShim.exports)
return moduleShim.exports
}

export interface FakeElement {
tagName: string
attributes: Record<string, string>
style: Record<string, unknown>
children: FakeElement[]
setAttribute(name: string, value: string): void
appendChild(child: FakeElement): void
}

/**
* Load `src/processout/actionhandler.ts` with a minimal DOM shim that records
* every element the handler creates, so tests can inspect iframe attributes.
*/
export function loadActionHandler(): {
namespace: Record<string, any>
createdElements: FakeElement[]
} {
const createdElements: FakeElement[] = []
const makeElement = (tagName: string): FakeElement => {
const el: FakeElement = {
tagName,
attributes: {},
style: {},
children: [],
setAttribute(name, value) {
el.attributes[name] = value
},
appendChild(child) {
el.children.push(child)
},
}
createdElements.push(el)
return el
}
const documentShim = { createElement: makeElement, createEvent: () => ({ initEvent() {} }) }
const windowShim = { addEventListener() {}, dispatchEvent() {} }
const translatorShim = { translateMessage: (_key: string, fallback: string) => fallback }

const js = compile("src/processout/actionhandler.ts")
const moduleShim = { exports: {} as Record<string, any> }
const run = new Function(
"module",
"exports",
"document",
"window",
"navigator",
"Translator",
`${js}\nmodule.exports = ProcessOut;`,
)
run(moduleShim, moduleShim.exports, documentShim, windowShim, { userAgent: "" }, translatorShim)
return { namespace: moduleShim.exports, createdElements }
}
Loading