Skip to main content

signin-window

Let a person sign in to a website themselves, from their own browser or phone, in a browser window that runs on your server. Your server keeps the session afterwards; the password, the one-time code and the site's checks stay with the person.

It is made for MCP servers and other headless services that act on a site for someone (shops, rides, photos, mail) but should never hold their password or automate a sign-in. Your assistant calls a tool, gets a one-time link, and gives it to the person. They open the site's own sign-in page through that link, and the page notices when they are in.

person's browser ──https──> your server (relay) ──private network──> login service
  the link's page              /connect/<id>                          Xvfb + x11vnc + Chromium
  noVNC viewer  <──────────── live view (WebSocket) ────────────────  on the site's sign-in page
  • The login service (signin_window.service) runs sign-in windows: a virtual display, a remote-desktop server on loopback, and your runner, which opens the site's sign-in page on that display (with Playwright, say) and waits until the site has signed the person in. It stays on a private network, behind a bearer.
  • The relay (signin_window.relay) runs in your server: one-time links, their page, noVNC's files and the live view, carried through to the login service. When the window ends, your hook stores the session.
  • The paste add-on lets the person paste into the remote page with Ctrl+V (Cmd+V), which noVNC alone does not.

Nothing in the package clicks, types or fills anything: the person does every step of the sign-in.

Install

pip install signin-window

Python 3.11 or later. The login service runs on Linux with Xvfb, x11vnc, noVNC 1.5 or later (its static files) and a browser; examples/minimal/Dockerfile builds one. Use Debian trixie's Xvfb and x11vnc (CI runs the end-to-end check there): on Ubuntu 24.04's, the live view showed the window but no click or key reached it.

The login service

A runner gets a Window. It opens the sign-in page on the window's display, calls window.waiting(host) once the page is up (the live view opens only then), and returns the session when the site has signed the person in:

import asyncio

from playwright.async_api import async_playwright
from signin_window.hosts import allowed, host_resolver_rules
from signin_window.service import Windows, build_app

HOSTS = ("example.com",)

async def sign_in(window):
    async with async_playwright() as p:
        context = await p.chromium.launch_persistent_context(
            window.temp_profile(), headless=False, env=window.env(), no_viewport=True,
            args=[f"--host-resolver-rules={host_resolver_rules(HOSTS)}", "--window-position=0,0"],
        )
        await context.route("**/*", lambda r: r.continue_() if allowed(r.request.url, HOSTS) else r.abort())
        page = await context.new_page()
        await page.goto("https://www.example.com/signin")
        window.waiting("example.com")
        while not window.cancelled.is_set():
            cookies = await context.cookies()
            if any(c["name"] == "session" for c in cookies):
                return {"cookies": cookies}          # handed over to the server, once
            await asyncio.sleep(1)

app = build_app(Windows(sign_in), token=TOKEN, novnc_dir="/opt/novnc")

examples/minimal/login_service.py is the complete version.

A window ends in one of two ways:

  • Hand-over: the runner returns the session. It waits up to five minutes to be taken, once, by the server, then it is dropped. The login service keeps nothing.
  • Kept: the runner keeps the session itself (a browser profile it refreshes, a snapshot it writes for the server to read) and calls window.finish("connected"), or another final state of its own such as "wrong_account". window.report(account=...) adds public fields to the window's state. Pass a lock to Windows if a keeper uses the same profile.

A runner can be a plain function too (it runs in a thread and should watch window.cancelled), or a command: signin_window.child.run_child(window, argv) runs it on the window's display and follows its progress lines (SIGNIN {"state": "waiting"} ... SIGNIN {"state": "signed_in", ...}), which suits sign-in code that is synchronous or someone else's.

Windows options: slots (windows at a time, each with its own display), minutes (time limit), xvfb and x11vnc (binaries), lock and lock_wait, display_base and vnc_base (window n uses display display_base + n and VNC port vnc_base + n on loopback). build_app options: start_params(body) turns a start request into the window's params (raise ValueError to refuse it; a "key" param makes a newer window for the same key cancel the older), title for the plain view page, and routes of your own behind the bearer.

The service's API (build_app): POST /logins, GET /logins/{id}, POST /logins/{id}/result (the session, once), DELETE /logins/{id}, WS /logins/{id}/vnc?t= (the live view, with the window's view token), GET /view/{id}?t= (a plain page showing the window, for someone on the service's own network), GET /novnc/... and GET /health.

The relay, in your server

from signin_window.client import LoginClient
from signin_window.page import PageText
from signin_window.relay import Relay

def store(link, session):
    save_session(link.key, session["cookies"])  # yours
    return "connected"

relay = Relay(
    LoginClient("http://login:8080", TOKEN),
    "https://mcp.example.com/connect",
    text=PageText(service="Example", secure_host=r"(^|\.)example\.com$"),
    on_signed_in=store,
)
app.routes.extend(relay.routes("/connect"))   # outside your server's bearer

link = relay.create(account_id)               # in your connect tool
return {"url": link.url, "expires_at": link.expires_at}

A link works for link_ttl (20 minutes) and one successful sign-in, up to max_starts windows (a reload or "try again" opens a new one); a newer link for the same key replaces it, and each key gets links_per_hour links. Its id has 192 random bits; requests for ids that do not exist are counted per client address (from X-Forwarded-For, or your client_address) and slowed long before guessing could matter, without ever locking a real link out.

Hooks run one at a time, in a worker thread:

  • on_signed_in(link, session) -> state: check the session and store it for link.key. Return "connected", or a state of your own the page has a message for ("wrong_account", say). Anything else, or an exception, is "failed".
  • on_ended(link, window_state) -> state: the window ended without a hand-over. By default its state is kept when the page has a message for it (a kept session's "connected"), else "failed".

link.show(name=...) adds public fields to the link's state, which the page's messages can use ({name}).

PageText holds everything the page says: service, title, help_html, intro_html (with {service} and {label}), button, secure_host (a host it matches gets a padlock), starting, waiting, outcomes (state -> message; with_outcomes(...) adds your own) and script_extra (it may define window.signinBeforeStart, an async function that must return true before a window opens, for an invite code the person enters first, say).

The relay talks to the login service through LoginClient; any object with the same methods (Backend) works, for a login service of another shape.

Security

  • The login service never faces the internet: put it on a private network with your server. Every route but /health, noVNC's files and the person's own view needs its bearer (32+ characters); a window's live view needs that window's view token, which only the caller that started it gets, and it is open only while the sign-in page is shown (waiting). The VNC server listens on loopback without a password; the service's WebSocket bridge is the only way in.
  • A link's id is its credential. Pages are no-store, no-referrer, not framable, with a strict Content-Security-Policy; nothing in the link's state or page is ever the session.
  • Keep the window on the site: signin_window.hosts checks every request against the site's hosts and makes every other host unresolvable for the browser, so neither a link on the page nor a redirect turns the window into a general browser. On a shared login service, also lock the browser down with Chromium's policies (no file access, no extensions, no other profiles).
  • A hand-over session leaves the login service once, to your server's hook, and is dropped if nobody takes it.
  • Passkeys do not work in a remote window (they need the person's own device nearby): people sign in with a password and a code.

Report vulnerabilities privately (SECURITY.md).

Development

make bootstrap   # uv sync --locked
make check       # lint, format, tests
uv sync --group browser && uv run playwright install chromium && uv run pytest tests/test_paste_dom.py
NOVNC_DIR=/opt/novnc uv run --group browser python scripts/e2e.py   # with Xvfb and x11vnc installed

scripts/e2e.py runs the whole path with real browsers and a stand-in site: a link, its live view, paste, a hand-over and closing the page; CI runs it on every change.

License

MIT

Metadata

Release files for signin-window 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for signin-window 0.1.2
File Size Uploaded Provenance
signin_window-0.1.2.tar.gz 84.7 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for signin-window 0.1.2
File Interpreter ABI Platform
signin_window-0.1.2-py3-none-any.whl Python 3 none any Details

Total release size: 119.5 kB

Release files / signin_window-0.1.2.tar.gz

Download URL signin_window-0.1.2.tar.gz
Size 84.7 kB
Tags Source
SHA-256 checksum
How to use checksums
8142a3ddd3ed4ecf62741bbaef44d322b53b3307fed5e94df57df50934cdee55
BLAKE2b-256 checksum
How to use checksums
d864506e3aa71184258046670dd90d579b55272c2bf750aeeaa41e79eb9ae7fd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / signin_window-0.1.2-py3-none-any.whl

Download URL signin_window-0.1.2-py3-none-any.whl
Size 34.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
05a142b67953464812b4d96a25c857b83752d08ea25e2281f11a6f6539c0fe95
BLAKE2b-256 checksum
How to use checksums
34f31beef023b06c85cb39922d549feca3542be6c500f67968e463d637657ba3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.11 {"installer":{"name":"uv","version":"0.12.11","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for signin-window 0.1.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page