From 2496d4a9d969c7454c171079160aad24566821a6 Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Mon, 2 Nov 2026 15:00:06 +0000 Subject: [PATCH 1/2] feat: allow passkeys in the 3DS challenge iframe Browsers block WebAuthn in cross-origin iframes unless the embedder delegates the permission, so scheme passkeys (Visa/Mastercard payment passkeys run inside the 3DS challenge) silently fell back to OTP. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/processout/actionhandler.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 76bc687e..ce7cd406 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/processout/actionhandler.ts b/src/processout/actionhandler.ts index 4dab547e..6d929585 100644 --- a/src/processout/actionhandler.ts +++ b/src/processout/actionhandler.ts @@ -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; @@ -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 @@ -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; From 0bcfb6b9b736546e1ff20291629f288946895ef4 Mon Sep 17 00:00:00 2001 From: Roshan Gorasia Date: Mon, 2 Nov 2026 15:03:25 +0000 Subject: [PATCH 2/2] test: cover 3DS challenge iframe permissions Co-Authored-By: Claude Opus 5.5 --- test/processout/actionhandler.test.ts | 38 +++++++++++++++++++ test/support/loadNamespace.ts | 53 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 test/processout/actionhandler.test.ts diff --git a/test/processout/actionhandler.test.ts b/test/processout/actionhandler.test.ts new file mode 100644 index 00000000..1e5323ec --- /dev/null +++ b/test/processout/actionhandler.test.ts @@ -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) + }) +}) diff --git a/test/support/loadNamespace.ts b/test/support/loadNamespace.ts index b1ef93dd..969e096c 100644 --- a/test/support/loadNamespace.ts +++ b/test/support/loadNamespace.ts @@ -167,3 +167,56 @@ export function loadTelemetry(): Record { run(moduleShim, moduleShim.exports) return moduleShim.exports } + +export interface FakeElement { + tagName: string + attributes: Record + style: Record + 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 + 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 } + 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 } +}