Skip to content

Conversation

@tarrencev
Copy link
Contributor

@tarrencev tarrencev commented Jan 8, 2026

This PR adds headless mode support to the Controller SDK, enabling programmatic authentication without UI for server-side applications and automated scripts.

Architecture

Controller SDK → Keychain iframe (no UI) → Backend API

Key Changes

  • Added headless option to controller configuration with type-safe credentials
  • Modified connect() to conditionally display modal based on headless mode
  • Added comprehensive documentation and examples for 10 authentication methods
  • Fully backwards compatible - headless is optional

Implementation Details

  • Minimal changes: ~15 lines of logic in connect() method
  • No code duplication: all auth logic remains in keychain
  • Type safety: discriminated unions for credential validation
  • Next step: keychain package implementation required

See HEADLESS_MODE.md and IMPLEMENTATION_SUMMARY.md for complete details.


Note

Enables programmatic, UI-less auth via a new headless mode across the Controller SDK and keychain.

  • SDK: adds headless to ControllerOptions, new type-safe credential unions (HeadlessCredentialData), ConnectOptions, and headless-specific errors; updates Keychain.connect to accept ConnectOptions and controller.connect() to skip opening the modal when headless
  • Keychain: implements authenticateHeadless for password, WebAuthn, and EIP-191 flows; updates connect to parse old/new signatures and route to headless auth
  • API types: extends generated GraphQL with OAuthConnection models and deploy/disconnectOAuth mutations; prunes email/phone filters
  • Examples/Docs: adds HEADLESS_MODE.md, comprehensive examples/headless-simple.ts, and Next.js HeadlessLogin component wired into the example app

Written by Cursor Bugbot for commit 5c34a5e. This will update automatically on new commits. Configure here.

@vercel
Copy link

vercel bot commented Jan 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
controller-example-next Ready Ready Preview Jan 28, 2026 8:20pm
keychain Ready Ready Preview Jan 28, 2026 8:20pm
keychain-storybook Ready Ready Preview Jan 28, 2026 8:20pm

Request Review

// Only open modal if NOT headless
if (!headlessOptions) {
this.iframes.keychain.open();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty username causes connection hang with hidden modal

Medium Severity

The conditions for skipping the modal and for using headless authentication are misaligned. In controller.ts, the modal is skipped when headlessOptions is truthy (!headlessOptions is false). In connect.ts, headless auth is used when username && credentials are both truthy. If headlessOptions is provided with an empty username "", the modal is not opened (object is truthy), but headless auth is skipped (empty string is falsy), causing the code to fall through to the UI flow. Since the modal is hidden, the UI renders invisibly, the Promise never resolves, and the connection hangs indefinitely.

🔬 Verification Test

Why verification test was not possible: This requires the full iframe/modal infrastructure to demonstrate the hang. The bug can be traced through code:

  1. headlessOptions = { username: "", credentials: { type: "password", password: "x" } }
  2. In controller.ts line 254: !headlessOptions = !{} = false → modal NOT opened
  3. In connect.ts line 174: "" && credentials = "" (falsy) → headless auth skipped
  4. Falls through to line 179-191, calling navigate() to show connect UI
  5. The iframe exists but container has display: none (never opened)
  6. User cannot see or interact with the UI
  7. Promise never resolves → connection hangs forever
Additional Locations (1)

Fix in Cursor Fix in Web

if (headless?.username && headless?.credentials) {
// Perform headless authentication without UI
return authenticateHeadless(headless.username, headless.credentials);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent headless detection causes hung promise on empty username

Medium Severity

Inconsistent headless mode detection between controller.ts and connect.ts can cause a hung promise. The controller checks !headlessOptions (object existence) to decide whether to open the modal, while the keychain's connect checks headless?.username && headless?.credentials (truthy property values) to decide whether to use headless authentication. When headless is provided with an empty string username, the modal isn't opened but the code falls through to the UI-based flow, which waits for user interaction callbacks that can never arrive since the modal is hidden.

Additional Locations (1)

Fix in Cursor Fix in Web

password,
},
},
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeated headless login attempts fail due to singleton iframe

High Severity

The HeadlessLogin component creates a new Controller on each login attempt, but the underlying IFrame class uses singleton-style DOM element management - it checks for an existing "controller" element and won't add a second one. After the first login attempt, subsequent Controller instances have their iframes created but never added to the DOM, so the penpal connection never establishes, this.keychain remains undefined, and connect() immediately returns undefined with a "Not ready to connect" error. Users clicking the login button multiple times will see the first attempt work (or fail for unimplemented reasons) but all subsequent attempts fail instantly.

Fix in Cursor Fix in Web

return authenticateHeadless(
headless.username,
headless.credentials,
).then((result) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Headless mode ignores configured chain, always uses mainnet

High Severity

The authenticateHeadless function accepts an optional chainId parameter that defaults to mainnet, but the connect handler never passes a chainId when calling it. The HeadlessOptions type doesn't include chainId, and neither does ConnectOptions, so even though users configure defaultChainId: SN_SEPOLIA in the Controller SDK, headless authentication always uses mainnet. This causes headless authentication to operate on the wrong chain regardless of configuration.

Additional Locations (1)

Fix in Cursor Fix in Web

tarrencev and others added 11 commits January 28, 2026 12:26
Adds programmatic authentication support without UI display for server-side applications and automated scripts.

Architecture: Controller SDK → Keychain iframe (no UI) → Backend API

Key changes:
- Added headless option to controller configuration
- Modified connect() to conditionally display modal
- Added type-safe credential interfaces using discriminated unions
- Added headless-specific error classes
- Supports 10 auth methods: password, webauthn, OAuth, wallets

Implementation preserves existing architecture with minimal changes (~15 lines in connect method). All authentication logic remains in keychain package. Fully backwards compatible.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Adds basic infrastructure for headless authentication in the keychain package.

Changes:
- Updated connect() to accept username and credentials parameters
- Created headless.ts with authentication framework
- Added type imports for HeadlessCredentialData
- Maintains backwards compatibility with old connect signatures

Implementation status:
- ✅ Controller SDK side complete (passes username/credentials)
- ✅ Keychain Penpal method signature updated
- ⏳ Backend authentication logic needs implementation
- ⏳ Password decryption flow needs integration
- ⏳ Controller instance creation needs implementation

The framework is in place but requires backend API integration to:
1. Fetch user controller data
2. Decrypt credentials
3. Create/load Controller instance
4. Store in window.controller

This allows the Controller SDK to work immediately (it passes the data),
while the keychain will return "not yet implemented" errors until the
backend integration is completed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Improves the headless mode API by using a single options parameter
instead of multiple separate parameters.

Changes:
- Added ConnectOptions interface with signupOptions and headless fields
- Updated Keychain.connect() to accept ConnectOptions
- Updated keychain implementation with better signature detection
- Fixed bug: usernames starting with "http" now handled correctly
  (checks for "http://" or "https://" explicitly)
- Fixed bug: example code now returns consistent address values
- Added HeadlessLogin component to Next.js example for testing

Benefits:
- Cleaner API with single options parameter
- More extensible for future options
- Better type safety
- Fixes signature detection edge case

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Implement full authentication flow for password, WebAuthn, and OAuth methods
- Integrate with existing backend APIs and controller login
- Add proper error handling and type safety
- Support all EIP-191 authentication methods (Google, Discord, MetaMask, Rabby, Phantom)
- Mark Argent, Braavos, and SIWS as not yet implemented (require special handling)
- Update HeadlessLogin example to reflect implementation status
- All authentication methods now properly fetch controller data from backend
- Decrypt credentials and create controller instances with proper session management
- Remove password authentication from example (still supported in headless mode)
- Add Passkey (WebAuthn) authentication button with env var configuration
- Add MetaMask authentication button with wallet detection
- Update UI with two side-by-side authentication buttons
- Add proper TypeScript types for window.ethereum
- Improve user feedback with method-specific loading states
- Update documentation to explain requirements for each auth method
- Remove global Window interface declaration that conflicted with existing types
- Use inline type casting with EthereumProvider interface instead
- Prevents TypeScript error: 'Subsequent property declarations must have the same type'
- Fixes CI build failures in ts and storybook jobs
- Always wait for keychain Penpal connection to be established
- Previously only waited when lazy-loading the iframe
- Fixes 'Not ready to connect' error in headless mode
- Controller now properly waits for iframe to establish connection before use
Add temporary logging to help identify where the headless MetaMask
authentication flow hangs. Logs timing for fetchController and
Controller.login calls with enhanced error reporting.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants