8000 feat(auth): open Lens's other addresses signed in, to add a passkey there by adeelahmad · Pull Request #537 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version
every client signs in with a hub login, with too many wrong passwords from one address turned away for a while. It's
the same broker as on port 1883. SMB, NFS and syslog stay on the local network, and Remote access now says why
([Sensors](docs/sensors.md#at-the-web-apps-address), [Remote access](docs/remote-access.md#what-it-reaches)).
- **Passkeys at your remote address in one step.** A passkey works only at the address it was made at, so one made at
`localhost` or on the network didn't work at the Remote access or HTTPS address, and getting one there took a
sign-in link from email or an admin. Now Profile and sign-in › Passkeys lists Lens's other addresses with **Open
signed in**, which opens one with a 15-minute sign-in link of your own, where one fingerprint adds a passkey for it
([Authentication](docs/authentication.md#signing-in-passkeys)).
- **Export a conversation.** `GET /api/v1/chats/<id>/export.md`, `.pdf` or `.json` gives the version showing, one
message (`?message=`) or, as JSON, every version (`?all=true`). The Markdown has each question and answer, its
citations as footnotes linking back to the moment in Lens and the tools it used; the PDF is that Markdown printed by
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,14 +181,15 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
machine; the same daily events can also go to a Google Analytics 4 property of your own (off unless you turn it on).
([Logging](docs/logging.md), [telemetry](docs/telemetry.md), [Prometheus](docs/prometheus.md),
[usage analytics](docs/analytics.md); [checks](docs/requirements.md#watch-it-run))
- **Stay private and safe.** Passkeys instead of passwords, encryption at rest with a key per namespace, roles per
namespace (viewer, editor, owner, or custom roles made of permissions), email invitations, removing someone for
good, and an audit log. Prompt injection meets several layers: what the assistant reads (recordings,
documents, web pages, tool results) reaches the model marked as data, not instructions, and text that reads like
instructions to an AI is flagged (by free patterns, or an optional classifier model that runs on your machine and is
fetched only when you pick it). Once an answer has read such text, sending anything out, or the browser opening or
typing what the assistant wrote, waits for your yes. Chat-room answers have links they built themselves broken up,
agents over MCP get the archive as data, and what Lens caught goes into the audit log. Serve Lens over HTTPS, set
- **Stay private and safe.** Passkeys instead of passwords (one at Lens's remote address added from a signed-in one with
Open signed in), encryption at rest with a key per namespace, roles per namespace (viewer, editor, owner, or custom
roles made of permissions), email invitations, removing someone for good, and an audit log. Prompt injection meets
several layers: what the assistant reads (recordings, documents, web pages, tool results) reaches the model marked as
data, not instructions, and text that reads like instructions to an AI is flagged (by free patterns, or an optional
classifier model that runs on your machine and is fetched only when you pick it). Once an answer has read such text,
sending anything out, or the browser opening or typing what the assistant wrote, waits for your yes. Chat-room answers
have links they built themselves broken up, agents over MCP get the archive as data, and what Lens caught goes into
the audit log. Serve Lens over HTTPS, set
up in Settings, with certificates from Let's Encrypt or ZeroSSL that renew on their own, answering only for its own
domains and, if you choose, only to devices holding a client certificate; or reach it from anywhere through a
Cloudflare tunnel with nothing opened on your router. Either way, the S3 and WebDAV shares and the MQTT hub answer at
Expand Down
13 changes: 12 additions & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ no passwords on a fresh install (`auth.passwords` is off), so there is nothing t
A passkey belongs to the address it was made at (its host name): one made at `https://lens.example.com` doesn't work at
`http://localhost:3000`. Browsers only use passkeys on `https://` addresses and on `localhost`. The API learns the
address from the web app (`X-Forwarded-Host`), accepted only for host names Lens is served at (`FRONTEND_URL`,
`localhost`, `server.allowed_hosts`) or from `server.trusted_proxies`; anything else falls back to `FRONTEND_URL`.
`localhost`, `server.allowed_hosts`, the [Remote access](remote-access.md) and [HTTPS](https.md) addresses) or from
`server.trusted_proxies`; anything else falls back to `FRONTEND_URL`.

When Lens opens at another address too (Remote access, HTTPS), Profile and sign-in › Passkeys lists it, with **Open
signed in** for one you have no passkey for yet: it opens that address with a sign-in link of your own, where one
fingerprint adds a passkey for it and signs you in. No email, admin or copied link is needed. The link lasts 15 minutes,
works once and leaves other links alone; only someone signed in themselves can make one, not an API token or app
(`GET` · `POST /api/v1/auth/passkeys/elsewhere`, audited as `passkey.elsewhere`). Remote access's quick address changes
each time the tunnel starts, and a passkey made there stops working with it: use your own domain to keep one. When a
passkey sign-in is cancelled or finds nothing, the sign-in page says that a passkey works only where it was made and
points to **Open signed in**.

Passkeys must verify the person (fingerprint, face or PIN, not just a tap), and a key whose signature counter goes
backwards (a clone) is refused. Failed passkey sign-ins are throttled per visitor address like passwords.
Expand Down Expand Up @@ -121,6 +131,7 @@ Sign out ──► POST /api/v1/auth/logout (ends the session on the API too)
| `POST /api/v1/auth/signin-link/use` | sign in with a sign-in link alone, without adding a passkey |
| `POST /api/v1/auth/signin-link/lost` | email a sign-in link; answers the same for unknown addresses |
| `GET` · `POST /api/v1/auth/passkeys` (and `/options`) · `PATCH` · `DELETE /api/v1/auth/passkeys/{id}` | your passkeys: list, add, rename, remove (not the last); audited as `passkey.add` and `passkey.remove` |
| `GET` · `POST /api/v1/auth/passkeys/elsewhere` | the other addresses Lens opens at, and whether you have a passkey for each; a 15-minute sign-in link of your own at one, to add a passkey there; audited as `passkey.elsewhere` |
| `POST /api/v1/users/{id}/signin-link` · `DELETE /api/v1/users/{id}/passkeys` | admins: a sign-in link for someone (`?email=true` emails it to them when mail is set up); remove all their passkeys |
| `DELETE /api/v1/users/{id}` | admins: [remove someone for good](#removing-someone) |
| `GET /api/v1/auth/external` | the outside accounts people can sign in with (for the sign-in page) |
Expand Down
5 changes: 4 additions & 1 deletion docs/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ command (the long string after `--token`).
- When cloudflared stops, Lens starts it again after 5 s, 15 s, 1 min, then every 5 min; changing a setting starts
it again at once.
- The tunnel's address counts as one Lens is served at: passkeys and sign-in redirects use `https://<that host>`,
and email links use the fixed hostname unless Settings › Notifications sets another address.
and email links use the fixed hostname unless Settings › Notifications sets another address. A passkey works only
at the address it was made at, so Profile and sign-in › Passkeys offers **Open signed in** there, to add one for the
tunnel's address in one step ([Authentication](authentication.md#signing-in-passkeys)). The quick address changes
each time, and passkeys made there stop working with it.

## What it reaches

Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Requirement | Checks |
|---|---|
| Passkeys instead of passwords | [test_passkeys.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_passkeys.py) `test_first_admin_with_a_passkey_and_no_password`, `test_sign_in_with_a_passkey` |
| A passkey at another of Lens's addresses (remote access, HTTPS) added from a signed-in one: Open signed in, then one fingerprint | [test_passkeys.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_passkeys.py) `test_taking_your_sign_in_to_another_address`, `test_the_addresses_lens_opens_at`; [passkeys-panel.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/passkeys-panel.test.tsx) `opens an address you have no passkey for signed in, to add one there`; [webauthn.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/webauthn.test.ts) `points a failed sign-in to the address the passkey was made at` |
| Encryption at rest, with a key per namespace | [test_keyring.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_keyring.py) `test_each_namespace_gets_its_own_key_kept_only_wrapped`; [test_encryption.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_encryption.py) `test_a_new_archive_encrypts_and_still_plays_seeks_and_processes` |
| Roles per namespace: viewer, editor, owner, or custom roles made of permissions | [test_roles.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_roles.py) `test_builtin_roles_keep_their_meaning`, `test_custom_roles_allow_exactly_their_permissions`; [journeys.mjs](https://github.com/adeelahmad/lense/blob/main/ci/clicks/journeys.mjs) `person-add` |
| Email invitations, and removing someone for good | [test_users.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_users.py) `test_invites_are_emailed_when_mail_is_set_up`, `test_admins_remove_people_for_good` |
Expand Down
27 changes: 27 additions & 0 deletions fastapi_backend/app/api/v1/routes/passkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import secrets
import urllib.parse

from fastapi import APIRouter, BackgroundTasks, HTTPException, Request

Expand All @@ -20,6 +21,8 @@
ForgotPasswordRequest,
LoginTicket,
Passkey,
PasskeyAddress,
PasskeyAddressAsk,
PasskeyAnswer,
PasskeyOptions,
PasskeyRename,
Expand Down Expand Up @@ -277,6 +280,30 @@ def remove_passkey(pid: str, user: Writer, request: Request, db: Db, cfg: Cfg) -
return Ok()


@router.get("/passkeys/elsewhere")
def passkey_addresses(user: CurrentUser, request: Request, db: Db, cfg: Cfg) -> list[PasskeyAddress]:
"""The other https:// addresses Lens opens at (Settings › Remote access and HTTPS), and whether you have a passkey
for each: a passkey works only on the address it was made at."""
here = web_origin(request).rstrip("/").lower()
mine = {p.get("rp_id") for p in passkeys.list_for(db, user.id)}
return [PasskeyAddress(url=u, passkey=urllib.parse.urlsplit(u).hostname in mine) for u in passkeys.addresses(db, cfg) if u != here]


@router.post("/passkeys/elsewhere")
def passkey_elsewhere(body: PasskeyAddressAsk, user: Writer, db: Db, cfg: Cfg) -> SigninLink:
"""A sign-in link of your own at another address Lens opens at, to sign in there and add a passkey for it, with no
email and no admin. It lasts 15 minutes and works once; links others have are left alone. For people signed in
themselves, not API tokens or apps. Audited as `passkey.elsewhere`."""
_signed_in(user)
url = body.url.strip().rstrip("/").lower()
if url not in passkeys.addresses(db, cfg):
raise HTTPException(400, "that isn't an address Lens opens at: see Settings › Remote access and Settings › HTTPS")
raw = passkeys.create_link(db, user.id, hours=passkeys.ELSEWHERE_MINUTES / 60, by=user.email, replace=False)
auth.audit(db, user.as_audit(), "passkey.elsewhere", f"account:{user.id}", [urllib.parse.urlsplit(url).netloc])
row = db.one("SELECT expires_at FROM $r", r=auth.R("signin_link", auth.sha(raw)))
return SigninLink(url=passkeys.link_url(raw, url), expires_at=row["expires_at"])


# ---------- admins ----------
@people.post("/users/{uid}/signin-link")
def make_signin_link(
Expand Down
19 changes: 18 additions & 1 deletion fastapi_backend/app/domain/passkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

A passkey belongs to the site it was made on (its RP ID, the host name): one made at https://lens.example.com doesn't
work at http://localhost:3000, so the passkeys list shows each one's site. Browsers only offer passkeys on https://
pages and on localhost.
pages and on localhost. Someone signed in at one address carries their sign-in to the others Lens opens at (Remote
access, HTTPS: addresses()) with a short sign-in link of their own (ELSEWHERE_MINUTES), and adds a passkey there.

People without a passkey yet get one through a sign-in link (`signin_link`): an admin makes one for a new person or
someone who lost theirs, `lens users link` prints one on the server, and "Lost your passkey?" emails one when mail is
Expand Down Expand Up @@ -45,6 +46,7 @@
FLOW_MINUTES = 5
TICKET_SECONDS = 120
LINK_HOURS = 72
ELSEWHERE_MINUTES = 15 # a sign-in link of your own, to take your sign-in to another address Lens opens at
MAX_PER_ACCOUNT = 20
NAME_MAX = 60
TRANSPORTS = {t.value for t in AuthenticatorTransport}
Expand Down Expand Up @@ -252,6 +254,21 @@ def create_link(db, uid, hours=LINK_HOURS, by=None, replace=True):
return raw


def addresses(db, cfg):
"""The https:// addresses Lens opens at besides the one it's set up at: the Cloudflare tunnel's (Settings › Remote
access) and HTTPS's domains (Settings › HTTPS), lower case, without a trailing slash."""
from . import https, tunnel

out = []
if ((cfg.get("tunnel") or {}).get("mode") or "off") != "off":
url = tunnel.status(db, cfg).get("url")
if url:
out.append(url)
port = https.public_port()
out += [f"https://{h}" + ("" if port == 443 else f":{port}") for h in https.hosts(cfg)]
return list(dict.fromkeys(u.rstrip("/").lower() for u in out))


def link_url(raw, base=None):
"""The web app's page for a sign-in link."""
from app.config import settings as env
Expand Down
9 changes: 9 additions & 0 deletions fastapi_backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ class SigninLinkInfo(ResponseModel):
name: str | None = None


class PasskeyAddress(ResponseModel):
url: str = Field(description="an https:// address Lens opens at besides this one (Remote access, HTTPS)")
passkey: bool = Field(description="whether you have a passkey for it: a passkey works only where it was made")


class PasskeyAddressAsk(RequestModel):
url: str = Field(description="one of GET /auth/passkeys/elsewhere's addresses", max_length=300)


class SigninLink(ResponseModel):
url: str = Field(description="open it on the device to sign in with; it works once")
expires_at: str
Expand Down
49 changes: 49 additions & 0 deletions fastapi_backend/tests/api/test_passkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import datetime as dt
import threading
from unittest import mock

Expand Down Expand Up @@ -150,6 +151,54 @@ def test_api_tokens_cant_add_passkeys(app, client, db):
assert client.post("/api/v1/auth/passkeys/options", headers={"Authorization": f"Bearer {raw}", **WEB}).status_code == 403


def test_taking_your_sign_in_to_another_address(app, client, db):
laptop = Authenticator()
h = _session(client, _setup(app, client, laptop))
here = {**h, **WEB}
assert client.get("/api/v1/auth/passkeys/elsewhere", headers=here).json() == []
me = client.get("/api/v1/auth/me", headers=h).json()["user"]["id"]
invite = client.post(f"/api/v1/users/{me}/signin-link", headers=here).json()["url"].split("#", 1)[1]
settings.save(db, app.state.archive.base, "https", {"enabled": True, "domains": ["lens.example.com"]})
there = {"x-forwarded-host": "lens.example.com", "x-forwarded-proto": "https"}
assert client.get("/api/v1/auth/passkeys/elsewhere", headers=here).json() == [{"url": "https://lens.example.com", "passkey": False}]
assert client.get("/api/v1/auth/passkeys/elsewhere", headers={**h, **there}).json() == [] # where you are already

assert client.post("/api/v1/auth/passkeys/elsewhere", json={"url": "https://evil.example"}, headers=here).status_code == 400
raw = client.post("/api/v1/tokens", json={"name": "t", "scope": "write"}, headers=h).json()["token"]
as_token = {"Authorization": f"Bearer {raw}", **WEB}
assert client.post("/api/v1/auth/passkeys/elsewhere", json={"url": "https://lens.example.com"}, headers=as_token).status_code == 403
r = client.post("/api/v1/auth/passkeys/elsewhere", json={"url": "https://Lens.example.com/"}, headers=here)
assert r.status_code == 200, r.text
url = r.json()["url"]
assert url.startswith("https://lens.example.com/signin-link#") and r.json()["emailed"] is False
left = dt.datetime.fromisoformat(r.json()["expires_at"]) - dt.datetime.now(dt.UTC)
assert dt.timedelta(minutes=14) < left <= dt.timedelta(minutes=15)
assert client.post("/api/v1/auth/signin-link/info", json={"token": invite}).status_code == 200 # an admin's link stays

# there: a passkey for that address, and signed in
token = url.split("#", 1)[1]
o = client.post("/api/v1/auth/signin-link/options", json={"token": token}, headers=there).json()
assert o["options"]["rp"]["id"] == "lens.example.com"
credential = laptop.create(o["options"], "https://lens.example.com")
r = client.post("/api/v1/auth/signin-link", json={"token": token, "flow": o["flow"], "credential": credential}, headers=there)
assert r.status_code == 200, r.text
assert _signin(client, laptop, headers=there, origin="https://lens.example.com").status_code == 200
assert client.get("/api/v1/auth/passkeys/elsewhere", headers=here).json() == [{"url": "https://lens.example.com", "passkey": True}]
assert client.post("/api/v1/auth/signin-link/info", json={"token": token}).status_code == 404 # used up
assert "passkey.elsewhere" in db.values("SELECT VALUE action FROM audit_log")


def test_the_addresses_lens_opens_at(db, cfg, monkeypatch):
assert passkeys.addresses(db, cfg) == []
cfg["tunnel"] = {"mode": "managed", "hostname": "Lens.Example.com"}
cfg["https"] = {"enabled": True, "domains": ["lens.example.com", "home.example.org", "*.example.org"]}
monkeypatch.setenv("LENS_HTTPS_PORT", "8443")
assert passkeys.addresses(db, cfg) == ["https://lens.example.com", "https://lens.example.com:8443", "https://home.example.org:8443"]
cfg["tunnel"]["mode"] = "off"
cfg["https"]["enabled"] = False
assert passkeys.addresses(db, cfg) == []


def test_sign_in_links_for_new_people(app, client, db):
admin = _session(client, _setup(app, client, Authenticator()))
settings.save(db, app.state.archive.base, "auth", {"passwords": False})
Expand Down
Loading
Loading
0