Skip to content

Commit 00275a5

Browse files
authored
feat: hand off popup auth for restrictive iframe environments (#2462)
## Summary This PR adds a WebAuthn popup fallback for restrictive iframe environments and switches the popup completion path to an explicit state handoff back into the iframe. The original approach assumed the popup could persist controller/session state to first-party storage and the embedded keychain iframe could then reload it with `fromStore()`. That is not reliable across browsers because popup storage and iframe storage can be partitioned differently, especially in Safari and other restrictive iframe contexts. The fix is to let the popup complete the WebAuthn and session flow, export the resulting controller/session state, return it to the opener via `postMessage`, and import/persist it inside the iframe. ## Dependency Depends on `cartridge-gg/controller-rs#98`, which adds the import/export APIs used by this PR: - `ControllerFactory.fromMetadata(...)` - `CartridgeAccount.exportMetadata()` - `CartridgeAccount.exportAuthorizedSession(...)` - `CartridgeAccount.importSession(...)` ## Why Some iframe environments do not allow: - `publickey-credentials-create` - `publickey-credentials-get` That breaks passkey auth inside the embedded keychain iframe. Even when a popup can perform WebAuthn successfully, relying on popup-written local storage is not a cross-browser solution. First-party popup storage and third-party iframe storage are not guaranteed to be shared, so the iframe can finish popup auth and still fail to see any stored controller/session state. `postMessage` handoff is the most reliable fix because it does not depend on shared storage between the popup and the iframe. ## What Changed ### Popup trigger behavior - keep automatic detection for restrictive iframe environments - keep the explicit `webauthnPopup` override for development/testing - restrict popup auth to WebAuthn only; other signers continue in the iframe ### Popup auth flow - keep `/auth` as the standalone popup route - replace popup actions with explicit WebAuthn flows: - `signup` - `login` - keep signup/login + requested session creation in the same popup - keep popup close/ack handling over `postMessage` ### Popup completion handoff - replace the old popup result shape with exported controller/session state - send the popup result back to the opener via `window.opener.postMessage(...)` - import the returned controller/session state into the iframe instead of calling `Controller.fromStore()` after popup completion ### Controller/session restore - add controller wrapper helpers for: - metadata import - session import - popup state export/import - reuse those helpers for both: - WebAuthn signup/login popup completion - WebAuthn-backed session creation popup completion ### Connect flow cleanup - remove the popup flow’s dependency on shared popup local storage - stop bootstrapping popup `/auth` from stored controller state - keep the normal iframe method drawer for non-WebAuthn auth methods - make popup signup/login action handling explicit so popup auth does not silently switch between account creation and login ## Result For restrictive iframe environments: - WebAuthn can leave the iframe and run in a popup - signup can create the account and requested session in the same popup - login can authenticate and create the requested session in the same popup - popup completion no longer depends on shared browser storage - iframe restores the returned controller/session state locally ## Manual Validation - Safari restrictive iframe signup via passkey completes in one popup and returns successfully - Safari restrictive iframe login via passkey completes in one popup and returns successfully - no duplicate session popup appears after popup completion - no popup flow depends on `Controller.fromStore()` after completion ## Testing - `pnpm format --filter @cartridge/keychain` - repo pre-commit checks reached lint/tests/build and failed only at `graphql:gen` because `api.cartridge.gg` could not be resolved from this environment
1 parent dd0d31b commit 00275a5

File tree

21 files changed

+1139
-244
lines changed

21 files changed

+1139
-244
lines changed

examples/next/src/components/providers/StarknetProvider.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,6 @@ export const controllerConnector = new ControllerConnector({
213213
tokens: {
214214
erc20: ["lords", "strk"],
215215
},
216-
217216
// nums (achievements, quests)
218217
// slot: "nums-bal",
219218
// namespace: "NUMS",
@@ -230,9 +229,6 @@ export const controllerConnector = new ControllerConnector({
230229
// preset: "loot-survivor",
231230

232231
// Summit (no achievements, no quests)
233-
namespace: "relayer_0_0_1",
234-
slot: "pg-mainnet-10",
235-
preset: "savage-summit",
236232
});
237233

238234
const session = new SessionConnector({

packages/controller/src/controller.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,9 @@ export default class ControllerProvider extends BaseProvider {
309309
return this.account;
310310
}
311311

312-
// Only open modal if NOT headless
313-
this.iframes.keychain.open();
312+
if (!headless) {
313+
this.iframes.keychain.open();
314+
}
314315

315316
// Use connect() parameter if provided, otherwise fall back to constructor options
316317
const effectiveOptions = Array.isArray(options)
@@ -353,7 +354,6 @@ export default class ControllerProvider extends BaseProvider {
353354
}
354355
console.log(e);
355356
} finally {
356-
// Only close modal if it was opened (not headless)
357357
if (!headless) {
358358
this.iframes.keychain.close();
359359
}

packages/controller/src/iframe/keychain.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class KeychainIFrame extends IFrame<Keychain> {
4040
encryptedBlob,
4141
propagateSessionErrors,
4242
errorDisplayMode,
43+
webauthnPopup,
4344
...iframeOptions
4445
}: KeychainIframeOptions) {
4546
let onStarterpackPlayHandler: (() => Promise<void>) | undefined;
@@ -101,6 +102,10 @@ export class KeychainIFrame extends IFrame<Keychain> {
101102
_url.searchParams.set("should_override_preset_policies", "true");
102103
}
103104

105+
if (webauthnPopup) {
106+
_url.searchParams.set("webauthn_popup", "true");
107+
}
108+
104109
// Policy precedence logic:
105110
// 1. If shouldOverridePresetPolicies is true and policies are provided, use policies
106111
// 2. Otherwise, if preset is defined, ignore provided policies

packages/controller/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ export type KeychainOptions = IFrameOptions & {
259259
tokens?: Tokens;
260260
/** When true, defer iframe mounting until connect() is called. Reduces initial load and resource fetching. */
261261
lazyload?: boolean;
262+
/** When true, force WebAuthn operations to run in a popup window instead of the iframe. Useful for development and testing. */
263+
webauthnPopup?: boolean;
262264
};
263265

264266
export type ProfileContextTypeVariant =

packages/keychain/src/components/ConnectRoute.test.tsx

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,42 @@ const mockController = {
2727
chainId: vi.fn().mockReturnValue("SN_SEPOLIA"),
2828
};
2929

30+
const defaultConnection = {
31+
controller: mockController,
32+
policies: null,
33+
isPoliciesResolved: true,
34+
verified: true,
35+
origin: "https://test.app",
36+
webauthnPopup: {
37+
create: false,
38+
get: false,
39+
},
40+
theme: {
41+
name: "TestApp",
42+
verified: true,
43+
},
44+
};
45+
3046
const mockUseConnection = vi.fn();
3147
vi.mock("@/hooks/connection", () => ({
32-
useConnection: () => mockUseConnection(),
48+
useConnection: () => {
49+
const override = mockUseConnection() ?? {};
50+
return {
51+
...defaultConnection,
52+
...override,
53+
theme: {
54+
...defaultConnection.theme,
55+
...(override.theme ?? {}),
56+
},
57+
webauthnPopup:
58+
typeof override.webauthnPopup === "object"
59+
? {
60+
...defaultConnection.webauthnPopup,
61+
...override.webauthnPopup,
62+
}
63+
: defaultConnection.webauthnPopup,
64+
};
65+
},
3366
}));
3467

3568
const mockCleanupCallbacks = vi.fn();
@@ -86,17 +119,7 @@ describe("ConnectRoute", () => {
86119
mockLocation.search = "";
87120
// mockSnapshotLocalStorageToCookie.mockResolvedValue("mock-encrypted-blob");
88121

89-
mockUseConnection.mockReturnValue({
90-
controller: mockController,
91-
policies: null,
92-
isPoliciesResolved: true,
93-
verified: true,
94-
origin: "https://test.app",
95-
theme: {
96-
name: "TestApp",
97-
verified: true,
98-
},
99-
});
122+
mockUseConnection.mockReturnValue({});
100123
});
101124

102125
describe("Embedded mode (iframe)", () => {
@@ -167,8 +190,8 @@ describe("ConnectRoute", () => {
167190

168191
renderWithProviders(<ConnectRoute />);
169192

170-
// Should render CreateSession component
171-
expect(screen.getByText("Create Session")).toBeInTheDocument();
193+
expect(mockParams.resolve).not.toHaveBeenCalled();
194+
expect(mockSafeRedirect).not.toHaveBeenCalled();
172195
});
173196

174197
it("does not show UI for verified policies without approvals", () => {
@@ -314,9 +337,7 @@ describe("ConnectRoute", () => {
314337
initialUrl: "/?redirect_url=https://example.com/callback",
315338
});
316339

317-
// Should show CreateSession UI instead of redirecting immediately
318-
expect(screen.getByText("Create Session")).toBeInTheDocument();
319-
// No immediate redirect for unverified policies
340+
expect(mockParams.resolve).not.toHaveBeenCalled();
320341
expect(mockSafeRedirect).not.toHaveBeenCalled();
321342
});
322343

0 commit comments

Comments
 (0)