signin-window 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. signin_window-0.1.2/.github/workflows/ci.yml +71 -0
  2. signin_window-0.1.2/.github/workflows/pypi.yml +38 -0
  3. signin_window-0.1.2/.github/workflows/release.yml +65 -0
  4. signin_window-0.1.2/.gitignore +7 -0
  5. signin_window-0.1.2/.python-version +1 -0
  6. signin_window-0.1.2/AGENTS.md +27 -0
  7. signin_window-0.1.2/CONTRIBUTING.md +33 -0
  8. signin_window-0.1.2/LICENSE +21 -0
  9. signin_window-0.1.2/Makefile +7 -0
  10. signin_window-0.1.2/PKG-INFO +203 -0
  11. signin_window-0.1.2/README.md +176 -0
  12. signin_window-0.1.2/SECURITY.md +11 -0
  13. signin_window-0.1.2/examples/minimal/Dockerfile +40 -0
  14. signin_window-0.1.2/examples/minimal/login_service.py +74 -0
  15. signin_window-0.1.2/examples/minimal/server.py +67 -0
  16. signin_window-0.1.2/pyproject.toml +64 -0
  17. signin_window-0.1.2/scripts/e2e.py +225 -0
  18. signin_window-0.1.2/scripts/test.sh +8 -0
  19. signin_window-0.1.2/src/signin_window/__init__.py +10 -0
  20. signin_window-0.1.2/src/signin_window/child.py +153 -0
  21. signin_window-0.1.2/src/signin_window/client.py +167 -0
  22. signin_window-0.1.2/src/signin_window/hosts.py +56 -0
  23. signin_window-0.1.2/src/signin_window/page.py +206 -0
  24. signin_window-0.1.2/src/signin_window/py.typed +0 -0
  25. signin_window-0.1.2/src/signin_window/relay.py +427 -0
  26. signin_window-0.1.2/src/signin_window/service.py +638 -0
  27. signin_window-0.1.2/src/signin_window/static/paste.js +117 -0
  28. signin_window-0.1.2/tests/test_child.py +118 -0
  29. signin_window-0.1.2/tests/test_client.py +172 -0
  30. signin_window-0.1.2/tests/test_hosts.py +37 -0
  31. signin_window-0.1.2/tests/test_page.py +40 -0
  32. signin_window-0.1.2/tests/test_paste_dom.py +212 -0
  33. signin_window-0.1.2/tests/test_relay.py +342 -0
  34. signin_window-0.1.2/tests/test_safety.py +62 -0
  35. signin_window-0.1.2/tests/test_service.py +377 -0
  36. signin_window-0.1.2/uv.lock +530 -0
@@ -0,0 +1,71 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+ workflow_dispatch:
8
+ workflow_call:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ concurrency:
14
+ group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
15
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
16
+
17
+ jobs:
18
+ test:
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ python: ['3.11', '3.12', '3.13']
23
+ runs-on: ubuntu-latest
24
+ timeout-minutes: 15
25
+ steps:
26
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
27
+ - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
28
+ with:
29
+ python-version: ${{ matrix.python }}
30
+ - run: make check
31
+
32
+ # The paste add-on in a real browser.
33
+ browser:
34
+ runs-on: ubuntu-latest
35
+ timeout-minutes: 15
36
+ steps:
37
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
38
+ - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
39
+ - run: uv sync --locked --group browser
40
+ - run: uv run --frozen playwright install --with-deps --only-shell chromium
41
+ - run: uv run --frozen pytest tests/test_paste_dom.py
42
+
43
+ # A link, its live view, paste and a hand-over, with Xvfb, x11vnc, noVNC and a real Chromium (scripts/e2e.py), on
44
+ # Debian trixie: the base a login service image normally uses (Ubuntu 24.04's x11vnc injects no input on Xvfb).
45
+ e2e:
46
+ runs-on: ubuntu-latest
47
+ container: python:3.12-slim-trixie
48
+ timeout-minutes: 15
49
+ env:
50
+ NOVNC_VERSION: 1.6.0
51
+ NOVNC_SHA256: 5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa
52
+ UV_LINK_MODE: copy
53
+ steps:
54
+ - name: Xvfb, x11vnc, noVNC and uv
55
+ run: |
56
+ apt-get update -q && apt-get install -y -q --no-install-recommends ca-certificates curl git xvfb x11vnc
57
+ curl --fail --silent --show-error --location \
58
+ "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" -o /tmp/novnc.tar.gz
59
+ printf '%s /tmp/novnc.tar.gz\n' "$NOVNC_SHA256" | sha256sum --check --status
60
+ mkdir -p /tmp/novnc && tar -xzf /tmp/novnc.tar.gz -C /tmp/novnc --strip-components=1
61
+ pip install --quiet uv
62
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
63
+ - run: uv sync --locked --group browser
64
+ - run: uv run --frozen playwright install --with-deps chromium
65
+ - run: E2E_SHOTS=/tmp/e2e-shots NOVNC_DIR=/tmp/novnc timeout 600 uv run --frozen python scripts/e2e.py
66
+ - if: failure()
67
+ uses: actions/upload-artifact@v4
68
+ with:
69
+ name: e2e-screenshots
70
+ path: /tmp/e2e-shots
71
+ retention-days: 7
@@ -0,0 +1,38 @@
1
+ name: PyPI
2
+ # A vX.Y.Z tag builds the wheel and sdist from the tagged tree and uploads them to PyPI as
3
+ # `signin-window` with trusted publishing (OIDC through the `pypi` environment; no token is
4
+ # stored). Tag pushes upload once the repository variable PYPI_PUBLISH is `true`; until then,
5
+ # or to retry, run it by hand on the tag: `gh workflow run pypi.yml --ref vX.Y.Z`. Files that
6
+ # are already on PyPI are skipped, so a repeated run is harmless.
7
+ on:
8
+ push:
9
+ tags: ['v*.*.*']
10
+ workflow_dispatch: {}
11
+
12
+ permissions: {}
13
+
14
+ concurrency:
15
+ group: pypi-${{ github.ref }}
16
+ cancel-in-progress: false
17
+
18
+ jobs:
19
+ publish:
20
+ if: github.ref_type == 'tag' && (github.event_name == 'workflow_dispatch' || vars.PYPI_PUBLISH == 'true')
21
+ runs-on: ubuntu-latest
22
+ timeout-minutes: 10
23
+ environment: pypi
24
+ permissions:
25
+ contents: read
26
+ id-token: write
27
+ steps:
28
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
29
+ with:
30
+ persist-credentials: false
31
+ - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
32
+ - name: Build
33
+ run: |
34
+ version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
35
+ [ "${GITHUB_REF_NAME#v}" = "$version" ] || { echo "tag $GITHUB_REF_NAME is not version $version" >&2; exit 1; }
36
+ uv build
37
+ - name: Upload
38
+ run: uv publish --trusted-publishing always --check-url https://pypi.org/simple/
@@ -0,0 +1,65 @@
1
+ name: Release
2
+ # A v1.2.3 tag: run the tests on the tagged commit, build the wheel and sdist, and publish a GitHub release with
3
+ # them (pypi.yml uploads the same tag to PyPI). Only the built-in token is used.
4
+ on:
5
+ push:
6
+ tags: ['v*.*.*']
7
+ workflow_dispatch: {}
8
+
9
+ permissions: {}
10
+
11
+ # GitHub can deliver one tag push twice: the runs of a ref queue one behind the other, and a
12
+ # run whose tag already has a GitHub release (the last job) stops at `released`.
13
+ concurrency:
14
+ group: release-${{ github.ref }}
15
+ cancel-in-progress: false
16
+
17
+ jobs:
18
+ released:
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 5
21
+ permissions:
22
+ contents: read
23
+ outputs:
24
+ done: ${{ steps.check.outputs.done }}
25
+ steps:
26
+ - id: check
27
+ env:
28
+ GH_TOKEN: ${{ github.token }}
29
+ TAG: ${{ github.ref_name }}
30
+ run: |
31
+ done=false
32
+ if [ "$GITHUB_REF_TYPE" = tag ] && gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
33
+ done=true
34
+ echo "$TAG is already released: nothing to do"
35
+ fi
36
+ echo "done=$done" >> "$GITHUB_OUTPUT"
37
+
38
+ ci:
39
+ needs: released
40
+ if: needs.released.outputs.done != 'true'
41
+ uses: ./.github/workflows/ci.yml
42
+ permissions:
43
+ contents: read
44
+
45
+ github-release:
46
+ needs: ci
47
+ if: github.event_name == 'push' && github.ref_type == 'tag'
48
+ runs-on: ubuntu-latest
49
+ timeout-minutes: 10
50
+ permissions:
51
+ contents: write
52
+ steps:
53
+ - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
54
+ with:
55
+ persist-credentials: false
56
+ - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
57
+ - name: Build
58
+ run: |
59
+ version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
60
+ [ "${GITHUB_REF_NAME#v}" = "$version" ] || { echo "tag $GITHUB_REF_NAME is not version $version" >&2; exit 1; }
61
+ uv build
62
+ - env:
63
+ GH_TOKEN: ${{ github.token }}
64
+ TAG: ${{ github.ref_name }}
65
+ run: gh release create "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --verify-tag --title "$TAG" --generate-notes
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ .artifacts/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,27 @@
1
+ # Working on signin-window
2
+
3
+ Instructions for contributors and coding agents. `AGENTS.md` is the only agent instruction file.
4
+
5
+ **This repository is public.** Never commit personal data or anything about a particular deployment: no names,
6
+ email addresses, accounts, host names, networks or infrastructure of where it runs, and no anecdotes about who used
7
+ it when. Tests and examples use `example.com` and invented values. This applies to code comments, docs, tests and
8
+ commit messages alike.
9
+
10
+ ## Verify
11
+
12
+ `make bootstrap`, then `make check`: ruff, the format check and pytest. The paste add-on's browser test runs with
13
+ `uv sync --group browser`, `uv run playwright install chromium` and `uv run pytest tests/test_paste_dom.py` (CI runs
14
+ it in a job of its own). Documentation-only changes need `git diff --check` and link checks.
15
+
16
+ ## Boundaries
17
+
18
+ - `tests/test_safety.py` is the boundary: nothing in `src/` clicks, types or fills a page; only `client.py` opens
19
+ connections (to the login service); the VNC server listens on loopback; `static/paste.js` logs and keeps nothing.
20
+ Changing any of these is a design change: argue it in the pull request.
21
+ - The login service's live view is open only while a window is `waiting` and only with its view token; a link's
22
+ live view only while its window is `waiting`. A session crosses from the service to the server once
23
+ (`POST /logins/{id}/result`) and is never part of a state, a page or a log line.
24
+ - `static/paste.js` is noVNC-version-sensitive (it imports `app/ui.js` and the keysym tables): test it against the
25
+ noVNC release the README names.
26
+ - The public API is what `README.md` documents. Within 0.x, a minor version may change it; say how in the release
27
+ notes.
@@ -0,0 +1,33 @@
1
+ # Contributing
2
+
3
+ Thanks for helping. Issues and pull requests are welcome.
4
+
5
+ ## Setup
6
+
7
+ ```sh
8
+ git clone https://github.com/benjamin-di/signin-window
9
+ cd signin-window
10
+ make bootstrap # uv sync --locked
11
+ make check # lint, format, tests
12
+ ```
13
+
14
+ The paste add-on's browser test needs Playwright's Chromium:
15
+ `uv sync --group browser && uv run playwright install chromium && uv run pytest tests/test_paste_dom.py`.
16
+
17
+ ## Rules
18
+
19
+ - **The person does every step of a sign-in.** Nothing in this package may click, type or fill a page
20
+ (`tests/test_safety.py`). A change that automates a sign-in will not be taken.
21
+ - **Only `client.py` talks to the network**, and only to the login service.
22
+ - **No personal data** in code, tests, fixtures, docs or commit messages: use `example.com` and invented values.
23
+ - Keep the API stable within a minor version; say in the pull request what a change does to existing users.
24
+
25
+ AGENTS.md has the same rules for coding agents.
26
+
27
+ ## How changes land
28
+
29
+ Each release of this repository is exported from a private working repository as one
30
+ `Release X.Y.Z` commit, so pull requests are not merged here: the next release would replace
31
+ them. When a change fits, the maintainer applies it there, credits you in the release notes
32
+ and closes the pull request with a link to the release that contains it. Issues are welcome;
33
+ answers are best effort.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 benjamin-di
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ .PHONY: bootstrap check lock
2
+ bootstrap:
3
+ uv sync --locked
4
+ check:
5
+ ./scripts/test.sh
6
+ lock:
7
+ uv lock
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.5
2
+ Name: signin-window
3
+ Version: 0.1.2
4
+ Summary: Let a person sign in to a website themselves, from their own browser, in a remote browser window your server runs (noVNC sign-in links for MCP servers and other headless services)
5
+ Project-URL: Homepage, https://github.com/benjamin-di/signin-window
6
+ Project-URL: Issues, https://github.com/benjamin-di/signin-window/issues
7
+ Author: benjamin-di
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: login,mcp,model-context-protocol,novnc,playwright,remote-browser,sign-in
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Security
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: anyio>=4
24
+ Requires-Dist: starlette<2,>=0.40
25
+ Requires-Dist: websockets>=13
26
+ Description-Content-Type: text/markdown
27
+
28
+ # signin-window
29
+
30
+ Let a person sign in to a website **themselves**, from their own browser or phone, in a browser window that runs on
31
+ your server. Your server keeps the session afterwards; the password, the one-time code and the site's checks stay
32
+ with the person.
33
+
34
+ It is made for [MCP](https://modelcontextprotocol.io) servers and other headless services that act on a site for
35
+ someone (shops, rides, photos, mail) but should never hold their password or automate a sign-in. Your assistant
36
+ 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
37
+ link, and the page notices when they are in.
38
+
39
+ ```
40
+ person's browser ──https──> your server (relay) ──private network──> login service
41
+ the link's page /connect/<id> Xvfb + x11vnc + Chromium
42
+ noVNC viewer <──────────── live view (WebSocket) ──────────────── on the site's sign-in page
43
+ ```
44
+
45
+ - **The login service** (`signin_window.service`) runs sign-in windows: a virtual display, a remote-desktop server
46
+ on loopback, and your *runner*, which opens the site's sign-in page on that display (with Playwright, say) and
47
+ waits until the site has signed the person in. It stays on a private network, behind a bearer.
48
+ - **The relay** (`signin_window.relay`) runs in your server: one-time links, their page, noVNC's files and the live
49
+ view, carried through to the login service. When the window ends, your hook stores the session.
50
+ - **The paste add-on** lets the person paste into the remote page with Ctrl+V (Cmd+V), which noVNC alone does not.
51
+
52
+ Nothing in the package clicks, types or fills anything: the person does every step of the sign-in.
53
+
54
+ ## Install
55
+
56
+ ```sh
57
+ pip install signin-window
58
+ ```
59
+
60
+ Python 3.11 or later. The login service runs on Linux with `Xvfb`, `x11vnc`, noVNC 1.5 or later (its static files)
61
+ and a browser; [`examples/minimal/Dockerfile`](examples/minimal/Dockerfile) builds one. Use Debian trixie's Xvfb and
62
+ 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
63
+ reached it.
64
+
65
+ ## The login service
66
+
67
+ A runner gets a `Window`. It opens the sign-in page on the window's display, calls `window.waiting(host)` once the
68
+ page is up (the live view opens only then), and returns the session when the site has signed the person in:
69
+
70
+ ```python
71
+ import asyncio
72
+
73
+ from playwright.async_api import async_playwright
74
+ from signin_window.hosts import allowed, host_resolver_rules
75
+ from signin_window.service import Windows, build_app
76
+
77
+ HOSTS = ("example.com",)
78
+
79
+ async def sign_in(window):
80
+ async with async_playwright() as p:
81
+ context = await p.chromium.launch_persistent_context(
82
+ window.temp_profile(), headless=False, env=window.env(), no_viewport=True,
83
+ args=[f"--host-resolver-rules={host_resolver_rules(HOSTS)}", "--window-position=0,0"],
84
+ )
85
+ await context.route("**/*", lambda r: r.continue_() if allowed(r.request.url, HOSTS) else r.abort())
86
+ page = await context.new_page()
87
+ await page.goto("https://www.example.com/signin")
88
+ window.waiting("example.com")
89
+ while not window.cancelled.is_set():
90
+ cookies = await context.cookies()
91
+ if any(c["name"] == "session" for c in cookies):
92
+ return {"cookies": cookies} # handed over to the server, once
93
+ await asyncio.sleep(1)
94
+
95
+ app = build_app(Windows(sign_in), token=TOKEN, novnc_dir="/opt/novnc")
96
+ ```
97
+
98
+ [`examples/minimal/login_service.py`](examples/minimal/login_service.py) is the complete version.
99
+
100
+ A window ends in one of two ways:
101
+
102
+ - **Hand-over**: the runner returns the session. It waits up to five minutes to be taken, once, by the server, then
103
+ it is dropped. The login service keeps nothing.
104
+ - **Kept**: the runner keeps the session itself (a browser profile it refreshes, a snapshot it writes for the server
105
+ to read) and calls `window.finish("connected")`, or another final state of its own such as `"wrong_account"`.
106
+ `window.report(account=...)` adds public fields to the window's state. Pass a `lock` to `Windows` if a keeper
107
+ uses the same profile.
108
+
109
+ A runner can be a plain function too (it runs in a thread and should watch `window.cancelled`), or a command:
110
+ `signin_window.child.run_child(window, argv)` runs it on the window's display and follows its progress lines
111
+ (`SIGNIN {"state": "waiting"}` ... `SIGNIN {"state": "signed_in", ...}`), which suits sign-in code that is
112
+ synchronous or someone else's.
113
+
114
+ `Windows` options: `slots` (windows at a time, each with its own display), `minutes` (time limit), `xvfb` and
115
+ `x11vnc` (binaries), `lock` and `lock_wait`, `display_base` and `vnc_base` (window *n* uses display
116
+ `display_base + n` and VNC port `vnc_base + n` on loopback). `build_app` options: `start_params(body)` turns a start
117
+ request into the window's `params` (raise `ValueError` to refuse it; a `"key"` param makes a newer window for the
118
+ same key cancel the older), `title` for the plain view page, and `routes` of your own behind the bearer.
119
+
120
+ The service's API (`build_app`): `POST /logins`, `GET /logins/{id}`, `POST /logins/{id}/result` (the session, once),
121
+ `DELETE /logins/{id}`, `WS /logins/{id}/vnc?t=` (the live view, with the window's view token), `GET /view/{id}?t=`
122
+ (a plain page showing the window, for someone on the service's own network), `GET /novnc/...` and `GET /health`.
123
+
124
+ ## The relay, in your server
125
+
126
+ ```python
127
+ from signin_window.client import LoginClient
128
+ from signin_window.page import PageText
129
+ from signin_window.relay import Relay
130
+
131
+ def store(link, session):
132
+ save_session(link.key, session["cookies"]) # yours
133
+ return "connected"
134
+
135
+ relay = Relay(
136
+ LoginClient("http://login:8080", TOKEN),
137
+ "https://mcp.example.com/connect",
138
+ text=PageText(service="Example", secure_host=r"(^|\.)example\.com$"),
139
+ on_signed_in=store,
140
+ )
141
+ app.routes.extend(relay.routes("/connect")) # outside your server's bearer
142
+
143
+ link = relay.create(account_id) # in your connect tool
144
+ return {"url": link.url, "expires_at": link.expires_at}
145
+ ```
146
+
147
+ A link works for `link_ttl` (20 minutes) and one successful sign-in, up to `max_starts` windows (a reload or "try
148
+ again" opens a new one); a newer link for the same key replaces it, and each key gets `links_per_hour` links. Its id
149
+ has 192 random bits; requests for ids that do not exist are counted per client address (from `X-Forwarded-For`, or
150
+ your `client_address`) and slowed long before guessing could matter, without ever locking a real link out.
151
+
152
+ Hooks run one at a time, in a worker thread:
153
+
154
+ - `on_signed_in(link, session) -> state`: check the session and store it for `link.key`. Return `"connected"`, or a
155
+ state of your own the page has a message for (`"wrong_account"`, say). Anything else, or an exception, is
156
+ `"failed"`.
157
+ - `on_ended(link, window_state) -> state`: the window ended without a hand-over. By default its state is kept when
158
+ the page has a message for it (a kept session's `"connected"`), else `"failed"`.
159
+
160
+ `link.show(name=...)` adds public fields to the link's state, which the page's messages can use (`{name}`).
161
+
162
+ `PageText` holds everything the page says: `service`, `title`, `help_html`, `intro_html` (with `{service}` and
163
+ `{label}`), `button`, `secure_host` (a host it matches gets a padlock), `starting`, `waiting`, `outcomes` (state ->
164
+ message; `with_outcomes(...)` adds your own) and `script_extra` (it may define `window.signinBeforeStart`, an async
165
+ function that must return true before a window opens, for an invite code the person enters first, say).
166
+
167
+ The relay talks to the login service through `LoginClient`; any object with the same methods (`Backend`) works,
168
+ for a login service of another shape.
169
+
170
+ ## Security
171
+
172
+ - The login service never faces the internet: put it on a private network with your server. Every route but
173
+ `/health`, noVNC's files and the person's own view needs its bearer (32+ characters); a window's live view needs
174
+ that window's view token, which only the caller that started it gets, and it is open only while the sign-in page
175
+ is shown (`waiting`). The VNC server listens on loopback without a password; the service's WebSocket bridge is
176
+ the only way in.
177
+ - A link's id is its credential. Pages are `no-store`, `no-referrer`, not framable, with a strict
178
+ Content-Security-Policy; nothing in the link's state or page is ever the session.
179
+ - Keep the window on the site: `signin_window.hosts` checks every request against the site's hosts and makes every
180
+ other host unresolvable for the browser, so neither a link on the page nor a redirect turns the window into a
181
+ general browser. On a shared login service, also lock the browser down with Chromium's policies (no file
182
+ access, no extensions, no other profiles).
183
+ - A hand-over session leaves the login service once, to your server's hook, and is dropped if nobody takes it.
184
+ - Passkeys do not work in a remote window (they need the person's own device nearby): people sign in with a
185
+ password and a code.
186
+
187
+ Report vulnerabilities privately ([SECURITY.md](SECURITY.md)).
188
+
189
+ ## Development
190
+
191
+ ```sh
192
+ make bootstrap # uv sync --locked
193
+ make check # lint, format, tests
194
+ uv sync --group browser && uv run playwright install chromium && uv run pytest tests/test_paste_dom.py
195
+ NOVNC_DIR=/opt/novnc uv run --group browser python scripts/e2e.py # with Xvfb and x11vnc installed
196
+ ```
197
+
198
+ `scripts/e2e.py` runs the whole path with real browsers and a stand-in site: a link, its live view, paste, a
199
+ hand-over and closing the page; CI runs it on every change.
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,176 @@
1
+ # signin-window
2
+
3
+ Let a person sign in to a website **themselves**, from their own browser or phone, in a browser window that runs on
4
+ your server. Your server keeps the session afterwards; the password, the one-time code and the site's checks stay
5
+ with the person.
6
+
7
+ It is made for [MCP](https://modelcontextprotocol.io) servers and other headless services that act on a site for
8
+ someone (shops, rides, photos, mail) but should never hold their password or automate a sign-in. Your assistant
9
+ 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
10
+ link, and the page notices when they are in.
11
+
12
+ ```
13
+ person's browser ──https──> your server (relay) ──private network──> login service
14
+ the link's page /connect/<id> Xvfb + x11vnc + Chromium
15
+ noVNC viewer <──────────── live view (WebSocket) ──────────────── on the site's sign-in page
16
+ ```
17
+
18
+ - **The login service** (`signin_window.service`) runs sign-in windows: a virtual display, a remote-desktop server
19
+ on loopback, and your *runner*, which opens the site's sign-in page on that display (with Playwright, say) and
20
+ waits until the site has signed the person in. It stays on a private network, behind a bearer.
21
+ - **The relay** (`signin_window.relay`) runs in your server: one-time links, their page, noVNC's files and the live
22
+ view, carried through to the login service. When the window ends, your hook stores the session.
23
+ - **The paste add-on** lets the person paste into the remote page with Ctrl+V (Cmd+V), which noVNC alone does not.
24
+
25
+ Nothing in the package clicks, types or fills anything: the person does every step of the sign-in.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ pip install signin-window
31
+ ```
32
+
33
+ Python 3.11 or later. The login service runs on Linux with `Xvfb`, `x11vnc`, noVNC 1.5 or later (its static files)
34
+ and a browser; [`examples/minimal/Dockerfile`](examples/minimal/Dockerfile) builds one. Use Debian trixie's Xvfb and
35
+ 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
36
+ reached it.
37
+
38
+ ## The login service
39
+
40
+ A runner gets a `Window`. It opens the sign-in page on the window's display, calls `window.waiting(host)` once the
41
+ page is up (the live view opens only then), and returns the session when the site has signed the person in:
42
+
43
+ ```python
44
+ import asyncio
45
+
46
+ from playwright.async_api import async_playwright
47
+ from signin_window.hosts import allowed, host_resolver_rules
48
+ from signin_window.service import Windows, build_app
49
+
50
+ HOSTS = ("example.com",)
51
+
52
+ async def sign_in(window):
53
+ async with async_playwright() as p:
54
+ context = await p.chromium.launch_persistent_context(
55
+ window.temp_profile(), headless=False, env=window.env(), no_viewport=True,
56
+ args=[f"--host-resolver-rules={host_resolver_rules(HOSTS)}", "--window-position=0,0"],
57
+ )
58
+ await context.route("**/*", lambda r: r.continue_() if allowed(r.request.url, HOSTS) else r.abort())
59
+ page = await context.new_page()
60
+ await page.goto("https://www.example.com/signin")
61
+ window.waiting("example.com")
62
+ while not window.cancelled.is_set():
63
+ cookies = await context.cookies()
64
+ if any(c["name"] == "session" for c in cookies):
65
+ return {"cookies": cookies} # handed over to the server, once
66
+ await asyncio.sleep(1)
67
+
68
+ app = build_app(Windows(sign_in), token=TOKEN, novnc_dir="/opt/novnc")
69
+ ```
70
+
71
+ [`examples/minimal/login_service.py`](examples/minimal/login_service.py) is the complete version.
72
+
73
+ A window ends in one of two ways:
74
+
75
+ - **Hand-over**: the runner returns the session. It waits up to five minutes to be taken, once, by the server, then
76
+ it is dropped. The login service keeps nothing.
77
+ - **Kept**: the runner keeps the session itself (a browser profile it refreshes, a snapshot it writes for the server
78
+ to read) and calls `window.finish("connected")`, or another final state of its own such as `"wrong_account"`.
79
+ `window.report(account=...)` adds public fields to the window's state. Pass a `lock` to `Windows` if a keeper
80
+ uses the same profile.
81
+
82
+ A runner can be a plain function too (it runs in a thread and should watch `window.cancelled`), or a command:
83
+ `signin_window.child.run_child(window, argv)` runs it on the window's display and follows its progress lines
84
+ (`SIGNIN {"state": "waiting"}` ... `SIGNIN {"state": "signed_in", ...}`), which suits sign-in code that is
85
+ synchronous or someone else's.
86
+
87
+ `Windows` options: `slots` (windows at a time, each with its own display), `minutes` (time limit), `xvfb` and
88
+ `x11vnc` (binaries), `lock` and `lock_wait`, `display_base` and `vnc_base` (window *n* uses display
89
+ `display_base + n` and VNC port `vnc_base + n` on loopback). `build_app` options: `start_params(body)` turns a start
90
+ request into the window's `params` (raise `ValueError` to refuse it; a `"key"` param makes a newer window for the
91
+ same key cancel the older), `title` for the plain view page, and `routes` of your own behind the bearer.
92
+
93
+ The service's API (`build_app`): `POST /logins`, `GET /logins/{id}`, `POST /logins/{id}/result` (the session, once),
94
+ `DELETE /logins/{id}`, `WS /logins/{id}/vnc?t=` (the live view, with the window's view token), `GET /view/{id}?t=`
95
+ (a plain page showing the window, for someone on the service's own network), `GET /novnc/...` and `GET /health`.
96
+
97
+ ## The relay, in your server
98
+
99
+ ```python
100
+ from signin_window.client import LoginClient
101
+ from signin_window.page import PageText
102
+ from signin_window.relay import Relay
103
+
104
+ def store(link, session):
105
+ save_session(link.key, session["cookies"]) # yours
106
+ return "connected"
107
+
108
+ relay = Relay(
109
+ LoginClient("http://login:8080", TOKEN),
110
+ "https://mcp.example.com/connect",
111
+ text=PageText(service="Example", secure_host=r"(^|\.)example\.com$"),
112
+ on_signed_in=store,
113
+ )
114
+ app.routes.extend(relay.routes("/connect")) # outside your server's bearer
115
+
116
+ link = relay.create(account_id) # in your connect tool
117
+ return {"url": link.url, "expires_at": link.expires_at}
118
+ ```
119
+
120
+ A link works for `link_ttl` (20 minutes) and one successful sign-in, up to `max_starts` windows (a reload or "try
121
+ again" opens a new one); a newer link for the same key replaces it, and each key gets `links_per_hour` links. Its id
122
+ has 192 random bits; requests for ids that do not exist are counted per client address (from `X-Forwarded-For`, or
123
+ your `client_address`) and slowed long before guessing could matter, without ever locking a real link out.
124
+
125
+ Hooks run one at a time, in a worker thread:
126
+
127
+ - `on_signed_in(link, session) -> state`: check the session and store it for `link.key`. Return `"connected"`, or a
128
+ state of your own the page has a message for (`"wrong_account"`, say). Anything else, or an exception, is
129
+ `"failed"`.
130
+ - `on_ended(link, window_state) -> state`: the window ended without a hand-over. By default its state is kept when
131
+ the page has a message for it (a kept session's `"connected"`), else `"failed"`.
132
+
133
+ `link.show(name=...)` adds public fields to the link's state, which the page's messages can use (`{name}`).
134
+
135
+ `PageText` holds everything the page says: `service`, `title`, `help_html`, `intro_html` (with `{service}` and
136
+ `{label}`), `button`, `secure_host` (a host it matches gets a padlock), `starting`, `waiting`, `outcomes` (state ->
137
+ message; `with_outcomes(...)` adds your own) and `script_extra` (it may define `window.signinBeforeStart`, an async
138
+ function that must return true before a window opens, for an invite code the person enters first, say).
139
+
140
+ The relay talks to the login service through `LoginClient`; any object with the same methods (`Backend`) works,
141
+ for a login service of another shape.
142
+
143
+ ## Security
144
+
145
+ - The login service never faces the internet: put it on a private network with your server. Every route but
146
+ `/health`, noVNC's files and the person's own view needs its bearer (32+ characters); a window's live view needs
147
+ that window's view token, which only the caller that started it gets, and it is open only while the sign-in page
148
+ is shown (`waiting`). The VNC server listens on loopback without a password; the service's WebSocket bridge is
149
+ the only way in.
150
+ - A link's id is its credential. Pages are `no-store`, `no-referrer`, not framable, with a strict
151
+ Content-Security-Policy; nothing in the link's state or page is ever the session.
152
+ - Keep the window on the site: `signin_window.hosts` checks every request against the site's hosts and makes every
153
+ other host unresolvable for the browser, so neither a link on the page nor a redirect turns the window into a
154
+ general browser. On a shared login service, also lock the browser down with Chromium's policies (no file
155
+ access, no extensions, no other profiles).
156
+ - A hand-over session leaves the login service once, to your server's hook, and is dropped if nobody takes it.
157
+ - Passkeys do not work in a remote window (they need the person's own device nearby): people sign in with a
158
+ password and a code.
159
+
160
+ Report vulnerabilities privately ([SECURITY.md](SECURITY.md)).
161
+
162
+ ## Development
163
+
164
+ ```sh
165
+ make bootstrap # uv sync --locked
166
+ make check # lint, format, tests
167
+ uv sync --group browser && uv run playwright install chromium && uv run pytest tests/test_paste_dom.py
168
+ NOVNC_DIR=/opt/novnc uv run --group browser python scripts/e2e.py # with Xvfb and x11vnc installed
169
+ ```
170
+
171
+ `scripts/e2e.py` runs the whole path with real browsers and a stand-in site: a link, its live view, paste, a
172
+ hand-over and closing the page; CI runs it on every change.
173
+
174
+ ## License
175
+
176
+ MIT