Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/platform-mechanisms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ on:
- tests/test_windows_tls.py
- tests/test_windows_trust.py
- tests/test_session_sign_out.py
- tests/test_browser_code.py
- tests/test_windows_service.py
- tests/test_windows_task_runtime.py
- tests/test_windows_installation.py
Expand Down Expand Up @@ -253,11 +254,11 @@ jobs:
run: |
.\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -s -p no:cacheprovider tests/test_windows_trust.py
if ($LASTEXITCODE -ne 0) { throw 'Windows local-root trust checks failed.' }
- name: Check sign-out of all browsers on the real private store
- name: Check sign-out of all browsers and remote sign-in by code (real private store, real HTTPS)
shell: pwsh
run: |
.\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -p no:cacheprovider tests/test_session_sign_out.py
if ($LASTEXITCODE -ne 0) { throw 'Sign-out of all browsers checks failed.' }
.\.native-test-venv\Scripts\python.exe -B -m pytest -q -ra -p no:cacheprovider tests/test_session_sign_out.py tests/test_browser_code.py
if ($LASTEXITCODE -ne 0) { throw 'Sign-out of all browsers or remote sign-in by code checks failed.' }
# Uninstall: real Start-menu shortcuts (ShellLink COM), the real HKCU
# registry transaction, private files and NTFS sharing, in a throwaway
# installation folder that the test removes. Task Scheduler is a stand-in.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ Replace the example origin and Tailscale IP with your host's real addresses. Bef

For the complete setup—including certificate transfer and trust, reverse-proxy limits, and Linux service management—see the [deployment guide](docs/DEPLOYMENT.md).

On a Windows host the steps differ: Unified stays on `127.0.0.1`, Tailscale forwards to it, and a browser on another device signs in with a one-use code that you make at the PC. See [Windows host: reach Unified from another device](docs/DEPLOYMENT.md#windows-host-reach-unified-from-another-device).

</details>

<details>
Expand Down
70 changes: 50 additions & 20 deletions amplifier_web/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,23 @@ def valid_session(app, value: str | None) -> bool:
that sign-out is refused as well.
"""
issued = _issued(app["session_secret"], value, app["server_config"]["session_ttl_seconds"], "session")
return issued is not None and issued > app.get("session_epoch", 0)
return issued is not None and issued > getattr(app.get("sessions"), "epoch", 0)


class Sessions:
"""The running host's sign-out time and the change hint of its saved record.

A holder object: the keys of an application are fixed once it has started,
and this value changes while the host runs.
"""

def __init__(self, epoch=0, hint=None):
self.epoch, self.hint = epoch, hint

@classmethod
def load(cls, data_dir):
hint = _epoch_hint(data_dir) # Read first: a change during the read is seen next time.
return cls(session_epoch(data_dir), hint)


_EPOCH = "session-epoch"
Expand Down Expand Up @@ -280,22 +296,22 @@ def _epoch_hint(data_dir: Path):
def refresh_sessions(app, streams=()) -> bool:
"""Apply a newer saved sign-out to the running host; True when applied.

Pending sign-in tickets are dropped, and open state streams are ended so
that a signed-out browser cannot keep watching. A stream that reconnects
must pass the session check again.
Pending sign-in tickets and codes are dropped, and open state streams are
ended so that a signed-out browser cannot keep watching. A stream that
reconnects must pass the session check again.
"""
data_dir = app["service"].data_dir
data_dir, state = app["service"].data_dir, app["sessions"]
hint = _epoch_hint(data_dir)
if hint == app.get("session_epoch_hint", ...):
if hint == state.hint:
return False
app["session_epoch_hint"] = hint
state.hint = hint
value = session_epoch(data_dir)
if value <= app.get("session_epoch", 0):
if value <= state.epoch:
return False
app["session_epoch"] = value
store = app.get("browser_entry")
if store is not None:
store.clear()
state.epoch = value
for store in (app.get("browser_entry"), app.get("browser_codes")):
if store is not None:
store.clear()
for task in list(streams):
task.cancel()
return True
Expand All @@ -304,16 +320,26 @@ def refresh_sessions(app, streams=()) -> bool:
async def watch_sessions(app, streams, *, interval=2.0):
"""Notice the owner's sign-out while the host runs."""
import asyncio
reported = False
while True:
await asyncio.sleep(interval)
try:
refresh_sessions(app, streams)
except Exception: # A watcher fault must never stop the host.
continue
except Exception as error: # A watcher fault must never stop the host.
if not reported:
# Say it once in the host log: a sign-out that the running
# host cannot apply must not fail in silence.
reported = True
try:
print("Sign-out of all browsers could not be checked: " + type(error).__name__, flush=True)
except (OSError, ValueError):
pass


def new_session(secret: str) -> str:
return _signed(secret, {"kind": "session", "issued": int(time.time()), "nonce": secrets.token_urlsafe(16)})
def new_session(secret: str, *, remote: bool = False) -> str:
"""Mark a session from a typed remote code; a local session is unchanged."""
payload = {"kind": "session", "issued": int(time.time()), "nonce": secrets.token_urlsafe(16)}
return _signed(secret, {**payload, "via": "remote"} if remote else payload)


def new_csrf(secret: str) -> str:
Expand Down Expand Up @@ -394,15 +420,16 @@ async def authenticated(credential):
# This exact handler checks a room/source-bound transport capability.
# Neither that capability nor an owner cookie grants other API access.
return await handler(request)
if request.path == browser_entry.ISSUE_PATH:
if request.path in {browser_entry.ISSUE_PATH, browser_entry.CODE_PATH}:
authorization = request.headers.get("Authorization", "")
if (request.method != "POST" or not authorization.startswith("Bearer ")
or not hmac.compare_digest(authorization[7:].encode(), request.app["control_token"].encode())):
return web.json_response({"error": "The owner control credential is required."}, status=403)
return await handler(request)
if request.path == browser_entry.REDEEM_PATH and request.method == "POST":
# Only this exact public mutation. The handler requires HTTPS, Origin
# and signed form CSRF before atomically consuming the one-use ticket.
if request.path in {browser_entry.REDEEM_PATH, browser_entry.CODE_REDEEM_PATH} and request.method == "POST":
# Only these exact public mutations. Each handler requires HTTPS, Origin
# and signed form CSRF before atomically consuming the one-use ticket
# or typed code.
return await handler(request)
if extension and request.method == 'OPTIONS':
return await handler(request)
Expand Down Expand Up @@ -471,6 +498,9 @@ async def authenticated(credential):


async def login_page(request: web.Request) -> web.Response:
if browser_entry.is_windows() and not browser_entry.loopback_host(urlsplit("//" + request.host).hostname):
# Another device cannot use the local launcher; it types an owner-made code.
return browser_entry.remote_login_page(request)
secret = request.app["session_secret"]
csrf = request.cookies.get(CSRF_COOKIE)
# Preserve valid issuance for favicon redirects and concurrent login forms.
Expand Down
Loading
Loading