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.
- signin_window-0.1.2/.github/workflows/ci.yml +71 -0
- signin_window-0.1.2/.github/workflows/pypi.yml +38 -0
- signin_window-0.1.2/.github/workflows/release.yml +65 -0
- signin_window-0.1.2/.gitignore +7 -0
- signin_window-0.1.2/.python-version +1 -0
- signin_window-0.1.2/AGENTS.md +27 -0
- signin_window-0.1.2/CONTRIBUTING.md +33 -0
- signin_window-0.1.2/LICENSE +21 -0
- signin_window-0.1.2/Makefile +7 -0
- signin_window-0.1.2/PKG-INFO +203 -0
- signin_window-0.1.2/README.md +176 -0
- signin_window-0.1.2/SECURITY.md +11 -0
- signin_window-0.1.2/examples/minimal/Dockerfile +40 -0
- signin_window-0.1.2/examples/minimal/login_service.py +74 -0
- signin_window-0.1.2/examples/minimal/server.py +67 -0
- signin_window-0.1.2/pyproject.toml +64 -0
- signin_window-0.1.2/scripts/e2e.py +225 -0
- signin_window-0.1.2/scripts/test.sh +8 -0
- signin_window-0.1.2/src/signin_window/__init__.py +10 -0
- signin_window-0.1.2/src/signin_window/child.py +153 -0
- signin_window-0.1.2/src/signin_window/client.py +167 -0
- signin_window-0.1.2/src/signin_window/hosts.py +56 -0
- signin_window-0.1.2/src/signin_window/page.py +206 -0
- signin_window-0.1.2/src/signin_window/py.typed +0 -0
- signin_window-0.1.2/src/signin_window/relay.py +427 -0
- signin_window-0.1.2/src/signin_window/service.py +638 -0
- signin_window-0.1.2/src/signin_window/static/paste.js +117 -0
- signin_window-0.1.2/tests/test_child.py +118 -0
- signin_window-0.1.2/tests/test_client.py +172 -0
- signin_window-0.1.2/tests/test_hosts.py +37 -0
- signin_window-0.1.2/tests/test_page.py +40 -0
- signin_window-0.1.2/tests/test_paste_dom.py +212 -0
- signin_window-0.1.2/tests/test_relay.py +342 -0
- signin_window-0.1.2/tests/test_safety.py +62 -0
- signin_window-0.1.2/tests/test_service.py +377 -0
- 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 @@
|
|
|
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,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
|