Skip to content
Open
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
114 changes: 114 additions & 0 deletions example/opener-severed-confirm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# `window.opener` severed confirm handoff — real cross-origin iframe repro (OCTA)

Reproduces the production failure the storage harness (`../iframe-test/`)
explicitly does **not** cover: an OAuth connection stuck at `state: authorized`
/ `health: pending_confirmation` with no `vault.connection.callable` webhook,
because the client-driven `POST /confirm` never fires.

## Why it breaks

After OAuth, vault's callback page forwards the `confirm_token` to the widget
**only** via:

```js
if (nonce && confirm_token && service_id && window.opener) { window.opener.postMessage(...) }
// vault/src/pages/oauth/callback.tsx:17 — else the token is silently dropped
```

When the widget is nested in cross-origin iframes (OCTA: OctaFlow → iframe →
OctaCore → iframe → vaultjs), the OAuth popup can end up with `window.opener ===
null` — so the guard is skipped, `POST /confirm` never runs, and the connection
stays `pending_confirmation`.

## What makes this repro "proper"

The previous version monkey-patched `window.open` to force `noopener`. That
faked the symptom but also nulled the `window.open` return handle, so the
Authorize button spun forever instead of resetting — not what production does.

This version induces the **real** browser condition instead:

- **Genuine cross-origin nesting.** One Vite dev server, three loopback origins
(`localhost` → `[::1]` → `127.0.0.1`) so `outer.html` embeds `middle.html`
embeds `widget.html` across real origin boundaries, mirroring OCTA's chain.
- **Real COOP severance, no JS override.** The OAuth popup's landing page is
served with `Cross-Origin-Opener-Policy: same-origin` in severed mode (a Vite
dev-server middleware — see `vite.config.ts`). Because that page is
cross-origin to the widget that opened it, the browser puts the popup in its
own browsing-context group and `window.opener` becomes `null`. COOP is ignored
on iframe documents and we don't control the real vault callback's headers, so
the popup's own landing page is the lever that genuinely applies — and it's the
same header a provider or vault page can set in production, which is exactly
why the failure is "spotty" across accounts.

### Two things run on the widget page

1. **The real `<Vault>`** (`@apideck/react-vault`) against **real Unify** — set a
JWT to drive the full OAuth → confirm flow. Whether the *real* vault callback
keeps its opener is environment-dependent (the production "spotty").
2. **An opener probe** — the deterministic demonstrator. It replays the shape
of vault's `oauth/callback` handoff (`window.open` →
`window.opener.postMessage(token)`) against our own `probe.html`, whose COOP
header we control. **No OAuth login required.** In severed mode the popup's
`window.opener` is null, the message is dropped, and the widget shows exactly
what strands the connection. Note the probe uses its own message type and
listener — it demonstrates the browser mechanism, not vault-core's code path
(only the real `<Vault>` above exercises that). The severance also doesn't
depend on the iframe nesting: COOP on the popup's landing page severs the
opener even for a top-level widget. The nesting mirrors OCTA's embedding and
matters for the real `<Vault>` path (CORS, storage partitioning), not for
the probe.

## Run

The example imports the built `../../dist`, so build the library first.

```bash
# repo root
yarn build

cd example
cp .env.example .env # optional — only needed to drive the real <Vault>
yarn install
yarn start # serves on http://localhost:1234 (or next free port)
```

Open the **top-level host on `localhost`** (the origins assume it):

- **Working:** http://localhost:1234/opener-severed-confirm/outer.html?opener=intact
- **OCTA bug:** http://localhost:1234/opener-severed-confirm/outer.html?opener=severed

Click **Test opener handoff** and watch the widget banner:

- `?opener=intact` → popup keeps its opener → **DELIVERED ✅** → `/confirm` would fire → `callable`.
- `?opener=severed` → popup's `window.opener` is null → **DROPPED ❌** → `/confirm` never fires → stuck `pending_confirmation`.

Allow popups for the host. To also exercise the real `<Vault>` OAuth flow, set
`VITE_VAULT_TOKEN` in `example/.env` (an account in unify's `oauthCsrf` allowlist,
an OAuth connector such as `accounting/xero` or `accounting/quickbooks`).

## Files

| File | Origin | Role |
|---|---|---|
| `outer.html` / `outer.tsx` | `localhost` | OctaFlow — top-level host, mode toggle |
| `middle.html` / `middle.tsx` | `[::1]` | OctaCore — middle cross-origin iframe |
| `widget.html` / `widget.tsx` | `127.0.0.1` | vaultjs — real `<Vault>` + opener probe |
| `probe.html` / `probe.tsx` | `localhost` | stands in for vault's `oauth/callback`; COOP toggled here |
| `origins.ts` | — | shared origin/mode helpers |

## Notes

- If `[::1]` (IPv6 loopback) isn't served in your environment, adjust the
hostnames in `origins.ts` — any three distinct origins work.
- The real `<Vault>` on `127.0.0.1` needs Unify to allow that Origin (CORS); the
probe demonstrator does not depend on Unify at all.

## The fix (see the plan)

`../../../unify/thoughts/shared/plans/2026-07-13-vault-oauth-csrf-confirm-handoff-fix.md`
and the problem/requirements writeup at
`../../thoughts/shared/research/2026-07-14-oauth-confirm-iframe-context.md`.
The security-critical constraint: the confirm secret must be minted
post-completion and delivered only to the initiating context — which is exactly
the `window.opener` channel this harness severs.
12 changes: 12 additions & 0 deletions example/opener-severed-confirm/middle.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>opener severed confirm — OctaCore (middle iframe)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./middle.tsx"></script>
</body>
</html>
34 changes: 34 additions & 0 deletions example/opener-severed-confirm/middle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { ORIGINS, readMode } from './origins';

// Middle iframe ("OctaCore"), served from [::1] — cross-origin to both the
// top-level host (localhost) and the widget it embeds (127.0.0.1). Its only job
// is to add a second cross-origin boundary above the widget, matching OCTA's
// OctaFlow → OctaCore → vaultjs nesting.

const mode = readMode();
const widgetSrc = `${ORIGINS.widget}/opener-severed-confirm/widget.html?opener=${mode}`;

const App = () => (
<div style={{ margin: 0, fontFamily: 'system-ui' }}>
<div
style={{
padding: '4px 12px',
font: '11px/1.4 system-ui',
color: '#334155',
background: '#e2e8f0',
}}
>
middle iframe · <code>{ORIGINS.middle}</code> (cross-origin to host and
widget)
</div>
<iframe
title="widget (vaultjs)"
src={widgetSrc}
style={{ width: '100%', height: 'calc(100vh - 84px)', border: 'none' }}
/>
</div>
);

ReactDOM.render(<App />, document.getElementById('root'));
36 changes: 36 additions & 0 deletions example/opener-severed-confirm/origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Three distinct origins served by the ONE Vite dev server (see vite.config.ts
// `server.host: true`). Different hostnames = different origins, so nesting
// outer → middle → widget gives genuine cross-origin iframe boundaries, and the
// widget → probe popup is cross-origin too — the conditions COOP needs to sever
// window.opener. Port is inherited from wherever the page was opened.
//
// outer (localhost) OctaFlow — top-level host, carries the COOP lever
// middle ([::1]) OctaCore — middle cross-origin iframe
// widget (127.0.0.1) vaultjs — inner cross-origin iframe, real <Vault>
// probe (localhost) stands in for vault's oauth/callback popup
//
// The real <Vault> lives on 127.0.0.1 (not the IPv6 literal) so its Unify API
// calls carry a friendlier Origin; the probe demonstrator needs no such luck.
//
// Open the harness via http://localhost:<port>/opener-severed-confirm/outer.html
// so `outer` is the localhost origin these URLs assume.
const PORT =
typeof window !== 'undefined' && window.location.port
? window.location.port
: '1234';

export const ORIGINS = {
outer: `http://localhost:${PORT}`,
middle: `http://[::1]:${PORT}`,
widget: `http://127.0.0.1:${PORT}`,
probe: `http://localhost:${PORT}`,
};

export type OpenerMode = 'intact' | 'severed';

export function readMode(): OpenerMode {
const m = new URLSearchParams(window.location.search).get('opener');
return m === 'severed' ? 'severed' : 'intact';
}

export const PROBE_MESSAGE = 'opener-severed-probe';
12 changes: 12 additions & 0 deletions example/opener-severed-confirm/outer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>opener severed confirm — OctaFlow (top-level host)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./outer.tsx"></script>
</body>
</html>
62 changes: 62 additions & 0 deletions example/opener-severed-confirm/outer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { ORIGINS, readMode } from './origins';

// Top-level host page ("OctaFlow"). It embeds the middle frame from a DIFFERENT
// origin ([::1]), which in turn embeds the widget from a third origin
// (127.0.0.1) — reproducing OCTA's nested cross-origin iframe chain with real
// browser isolation, not a window.open override.
//
// The ?opener=severed | intact mode is threaded down to every frame via the
// iframe src query, and (for severed) the Vite dev middleware attaches
// `Cross-Origin-Opener-Policy: same-origin` to the popup's landing page so the
// popup's window.opener is genuinely null.

const mode = readMode();
const severed = mode === 'severed';
const middleSrc = `${ORIGINS.middle}/opener-severed-confirm/middle.html?opener=${mode}`;

const link = (m: string, label: string) => (
<a
href={`?opener=${m}`}
style={{
color: '#fff',
fontWeight: mode === m ? 700 : 400,
textDecoration: mode === m ? 'underline' : 'none',
}}
>
{label}
</a>
);

const App = () => (
<div style={{ fontFamily: 'system-ui', margin: 0 }}>
<div
style={{
padding: '10px 16px',
color: '#fff',
background: severed ? '#b91c1c' : '#15803d',
font: '13px/1.5 system-ui',
}}
>
<strong>
window.opener: {severed ? 'SEVERED (COOP: same-origin)' : 'intact'}
</strong>{' '}
— top <code>{ORIGINS.outer}</code> ▸ iframe <code>{ORIGINS.middle}</code>{' '}
▸ iframe <code>{ORIGINS.widget}</code> · {link('intact', 'intact')} |{' '}
{link('severed', 'severed (OCTA)')}
<div style={{ marginTop: 4, opacity: 0.9 }}>
{severed
? 'The OAuth popup lands on a page served with COOP: same-origin, cross-origin to the widget → window.opener is null → vault would drop the confirm_token → stuck pending_confirmation.'
: 'The OAuth popup keeps its opener → vault postMessages the confirm_token back → widget calls /confirm → callable.'}
</div>
</div>
<iframe
title="middle (OctaCore)"
src={middleSrc}
style={{ width: '100%', height: 'calc(100vh - 62px)', border: 'none' }}
/>
</div>
);

ReactDOM.render(<App />, document.getElementById('root'));
12 changes: 12 additions & 0 deletions example/opener-severed-confirm/probe.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>opener probe — stands in for vault oauth/callback</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./probe.tsx"></script>
</body>
</html>
70 changes: 70 additions & 0 deletions example/opener-severed-confirm/probe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { PROBE_MESSAGE, readMode } from './origins';

// This page stands in for vault's real oauth/callback (vault/src/pages/oauth/
// callback.tsx). It runs the identical guarded handoff:
//
// if (window.opener) window.opener.postMessage({ confirm_token, ... }, '*')
// else // token silently dropped
//
// It is opened as a popup by the widget ([::1]) and is itself served from
// localhost — cross-origin to the opener. In `?opener=severed` the Vite dev
// middleware serves THIS page with `Cross-Origin-Opener-Policy: same-origin`;
// because the opener is cross-origin, the browser puts the popup in its own
// browsing-context group and `window.opener` is null. No JS override — this is
// the real COOP mechanism, and it's the same header a provider/vault page can
// set in production to cause the "spotty" OCTA failure.

const mode = readMode();
const hasOpener = !!window.opener;

// Mirror the callback guard exactly.
if (hasOpener) {
window.opener.postMessage(
{
type: PROBE_MESSAGE,
confirmToken: 'probe-confirm-token',
serviceId: 'probe',
success: true,
},
'*'
);
}

const App = () => (
<div style={{ fontFamily: 'system-ui', padding: 24 }}>
<h2 style={{ marginTop: 0 }}>OAuth callback (probe)</h2>
<div
style={{
padding: '10px 14px',
borderRadius: 6,
color: '#fff',
background: hasOpener ? '#15803d' : '#b91c1c',
}}
>
<strong>
window.opener is {hasOpener ? 'LIVE' : 'null'} (mode: {mode})
</strong>
<div style={{ marginTop: 6, fontSize: 13, opacity: 0.95 }}>
{hasOpener
? 'Posted the confirm_token back to the opener — the widget receives it and would call POST /confirm.'
: 'No opener to postMessage to → the confirm_token is dropped, just like vault/src/pages/oauth/callback.tsx. The connection would stay pending_confirmation.'}
</div>
</div>
<p style={{ fontSize: 13, color: '#475569' }}>
You can close this window.
</p>
<button onClick={() => window.close()} style={{ padding: '6px 12px' }}>
Close
</button>
</div>
);

ReactDOM.render(<App />, document.getElementById('root'));

// In the working case, auto-close shortly after handing back the token so the
// popup behaves like the real callback (which calls window.close()).
if (hasOpener) {
window.setTimeout(() => window.close(), 1200);
}
12 changes: 12 additions & 0 deletions example/opener-severed-confirm/widget.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>opener severed confirm — vaultjs (widget iframe)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./widget.tsx"></script>
</body>
</html>
Loading
Loading