Compare commits

...
33 Commits
Author SHA1 Message Date
LeoVasanko 444eca651e Frontend poll-condition and Set-reactivity fixes; drop now-unused input CSS
The post-mint watch polled only while a known link was disconnected —
but the incoming satellite is not a row yet, so poll unconditionally for
the minute. The scope Set is replaced, not mutated, to stay reactive.
The password/number/url input styling came in with the superseded
domain-dialog remote form; no such inputs remain.
2026-10-01 06:48:36 +00:00
LeoVasanko 6d49b59cec CLI db binding fix + true two-process satellite setup e2e
cmd_satellite constructs its own Kanta bound to the global db object on
the resolved path — the lifecycle module's import-time binding would
follow the wrong directory if CWD changed after import. The new e2e runs
'paskia satellite <url>' as a real subprocess against the live upstream:
own database created, link key material identical on both sides, pushed
domain stored as remote.
2026-10-01 06:46:36 +00:00
LeoVasanko fa0d03729b Censor link keys in transaction logs; watch for a minted link's satellite
kanta's log formatter now censors uplinks/satellite_links key material
like the OIDC key. After minting a link URL, the Satellites panel polls
for a minute so the registering satellite appears live.
2026-10-01 06:42:26 +00:00
LeoVasanko 0a59e26e45 Bound handshake reads; audit ctx for domain-delete scope shrinking
An accepted-but-silent peer no longer holds a sync connection open
forever: each dialogue frame read has a 15 s bound, closed with 1011 —
never 1008, which the satellite reads as revocation. Domain deletion
passes the admin's session context to link scope shrinks for the audit
log.
2026-10-01 06:40:07 +00:00
LeoVasanko 9da1abc2c7 Handshake: carry ready-frame rejections into the connection state
The satellite's domain rejections at registration/reconnect were dropped
until the next domains_ack; seed the connection with them so the upstream
link list shows conflicts immediately. Plus a dead frontend import.
2026-10-01 06:36:15 +00:00
LeoVasanko 0638117279 Drop the dead registry-rebuild listener machinery, guard the minted-link dialog
Satellite replicas no longer reconcile off registry rebuilds (uplinks are
db-driven now), so the listener mechanism has no users left. The minted
link URL step of the dialog dismisses with Done instead of re-minting.
2026-10-01 06:33:59 +00:00
LeoVasanko 28fa0ff61f Tests for the link model + lint/format pass
test_remote.py: link URL parsing, RAM-only offer mint/spend/expiry,
pushed-domain application (accept/idempotence/removal/local conflict/
auth-host collision), admin link lifecycle (offer, scope, revoke) and
uplink list/unlink, remote-write rejection on the domains API, live feed
scope mutation.

test_satellite_e2e.py: real registration handshake over TCP (key material
identical on both sides, duplicate and bad-URL rejections), established
link reconnect, revocation unlinking the satellite, live scope shrink
stopping the feed, domain conflicts reported upstream.

Also: ruff format drift fixes in startupbox/test_cli.
2026-10-01 06:30:32 +00:00
LeoVasanko 05ea188ac9 Docs: RemoteProxy.md for the link model
Configuration is one copy-paste of a wss link URL (Satellites panel or
the paskia satellite CLI), registration is the online 3-way handshake,
scope is adjustable post-registration, the upstream is authoritative for
pushed domains, revocation unlinks both sides.
2026-10-01 06:16:50 +00:00
LeoVasanko ebe18912fc Admin UI: Satellites panel replaces domain-dialog satellite config
The domain edit dialog returns to pure local domain editing — the remote
toggle, sync URL field, uplink probing and per-domain token management
are gone (the bad checkbox-on-its-own-line UX with them).

Satellite setup now lives in a self-contained Satellites section of the
admin overview, covering both roles:

- Upstream: links table (satellite label, served domains, live status,
  the satellite's rejection reports), New satellite link dialog with
  domain scope checkboxes minting the wss link URL (shown once, 10 min,
  single-use), scope editing pushed live, revocation with confirm.
- Satellite: uplinks table (upstream URL, served domains, live
  connected/cached/offline status with gentle polling while
  disconnected), Add uplink dialog taking the pasted link URL with the
  registration handshake running synchronously (immediate errors),
  unlink with confirm.

Remote domains in the domains list are no longer clickable/editable or
deletable — they carry a 'via uplink' badge pointing at the Satellites
panel.
2026-10-01 06:15:48 +00:00
LeoVasanko 3a6e2bb370 CLI: paskia satellite <wss-url> — one-step satellite-only setup
The same register_uplink path as the admin UI, runnable without any web
interface (a satellite may be a purely local instance serving backend
apps on one server): the handshake runs immediately, the link credential
and pushed domains are stored, rejections are printed. With no database
yet, one is created first (localhost domain + admin bootstrap) — the
command is the whole setup; 'paskia' then serves the linked domains.
2026-10-01 06:09:14 +00:00
LeoVasanko 0f38a8b096 Admin API for satellite links and uplinks
/satellites (upstream role): mint registration offers (RAM-only initial
keys shown once as a wss link URL), list links with live state and the
satellite's domain rejections, adjust scope (pushed live), revoke (drops
connections; the satellite unlinks on the policy close).

/uplinks (satellite role): register from a pasted link URL with the full
handshake running synchronously in the request (immediate feedback), list
uplinks with served domains and replica state, unlink.

Domain writes no longer accept a remote object: remote domains are
uplink-managed. Editing a domain pushes its new configuration to the
links serving it; deleting one shrinks their scopes.
2026-10-01 06:07:27 +00:00
LeoVasanko d0c7949052 Satellite links: online registration over a 3-way handshake
Replace pre-minted sync tokens with links created by an online
registration dialogue. The auth host mints a RAM-only, short-lived,
single-use registration offer shown as a wss://<initial-key>@<host> URL
(a scheme browsers refuse to open — the URL is only for a satellite to
connect to). The existing sync WebSocket endpoint multiplexes both
flows on the first frame:

  satellite -> upstream  hello    {link: null, key: <initial key>}
  upstream  -> satellite  welcome  {link, key: <server part>, domains}
  satellite -> upstream  ready    {key: <client part>, rejected}

The permanent credential is created during the handshake from parts both
sides contribute; both sides then store the link in their databases
(SatelliteLink / Uplink, with a key: bytes field). Placeholder
cryptography: parts are exchanged as-is, the final version derives the
channel key from the handshake. Established links run the same dialogue
with hello {link, key} as proof; JSON frames follow as before (to be
transparently encrypted in the final version).

Scope is no longer bound to the credential: a link's rp-id set is
adjustable on the upstream at any time and pushed live as a domains
frame — register once, adjust scope later. The upstream is authoritative
for the pushed domains (rp-id, name, origins verbatim); the satellite
validates them against its local config (conflicting rp-ids and origin
collisions are rejected and reported in the ready/ack frames), persists
the accepted set, and rebuilds its registry.

Satellite side: one replica connection per uplink (not per domain),
keyed by link; dispatch resolves host -> domain -> uplink. A policy
close (1008) at the handshake means the link was revoked — the satellite
unlinks its side. Registration is one function (register_uplink) shared
by the admin UI and the CLI.
2026-10-01 06:04:58 +00:00
LeoVasanko 2209e2395c Satellite v2: upstream-minted scoped sync tokens, per-session contexts
Registration simplified to a single sync URL: minted in the upstream's
admin UI (Satellite access section of the domain dialog), carrying the
token as userinfo — https://<token>@<auth-host>. Tokens live hashed in
the upstream database, scoped to one rp-id, revocable (drops live
connections). PASKIA_SYNC_TOKENS env is gone.

Data minimization: the sync feed now carries only the scope's sessions
and credentials; the user graph never leaves the upstream. Each session
upsert embeds its master-computed ApiSessionContext (identity display
data + effective permissions for the session's host); user-graph
mutations eagerly re-push affected sessions with fresh contexts, so
revocations land within ~1 RTT. /check and /user-info are proxied to
the upstream (they need the user graph).

Satellite side: replicas keyed by rp-id (no more shared-replica config
conflicts — each remote domain has its own token), the replica refuses
to go live on a ready-message scope mismatch, write-backs are confined
to the token's scope server-side.

Admin dialog: one sync-URL paste field (token split from userinfo,
write-only), uplink verification required to save (reachability, rp-id
match, no nesting, auth host present); a verified uplink takes over the
domain's origins (**.<rp-id> + upstream auth host) and auth host. New
domains adopt the upstream's rp-id and name.
2026-10-01 05:40:32 +00:00
LeoVasanko da0b3e0e8b Proxy to another Paskia (#5)
Geographically separate servers may have notable latency between them, making forward/validate request roundtrip unnecessarily slow. This adds a proxy support, where the local server can keep a local copy of a remote domain, while the auth host remains authoritative, but requests can be answered in less than 1ms from local copy.

Reviewed-on: https://git.zi.fi/LeoVasanko/paskia/pulls/5
2026-09-29 03:49:23 +00:00
LeoVasanko b5733657f9 Fastapi-vue-setup 1.7.2 logging fixes. 2026-09-18 19:07:33 +00:00
LeoVasanko 4de164c457 Use fastapi-vue RuntimeConfig passing, simplifying code. 2026-09-18 19:00:13 +00:00
LeoVasanko 3e4f77ba93 Update fastapi-vue-setup 1.6.1 with major changes. 2026-09-18 03:22:54 +00:00
LeoVasanko 66c2a9bf07 release.py: print the publish command unindented, on its own line 2026-09-18 01:35:09 +00:00
LeoVasanko c4360df110 Release 2.1.0 2026-09-18 01:32:45 +00:00
LeoVasanko c676665795 Add release script with version bump, tagging and rollback 2026-09-18 01:32:09 +00:00
LeoVasanko 55dd43661c Rewritten README. 2026-09-18 01:11:45 +00:00
LeoVasanko 753ce868c6 Examples: simplify profile demo to print the result code 2026-09-18 00:27:21 +00:00
LeoVasanko 3e0152e688 Profile iframe handles no-session internally with in-place login
The restricted entry validates the session before rendering anything
(no load-time flash): 401/403 switches to the existing login component
in place of the profile, success renders the panel fully populated via
props, other failures show a minimal card with Back only. The dialog
drops the standalone page's heading and help text.
2026-09-18 00:27:21 +00:00
LeoVasanko c5efa03908 profile(): resolve 'login' when login flow completes inside the dialog
The overlay now tracks which dialog kind is open: auth-success from a
profile dialog resolves 'login', auth-back only rejects
AuthCancelledError for auth dialogs.
2026-09-18 00:27:21 +00:00
LeoVasanko 0ca4e07e23 Dev server: serve paskia-js module, rework examples page
The Vite dev server now maps /paskia-js/ to the local paskia-js build
(the examples page's module import has 404'd since it was introduced,
leaving all buttons dead). Navigation actions on the examples page are
now plain same-window links so the flows' back navigation returns to
the page; added a Profile Summary button exercising profile().
2026-09-17 19:17:00 +00:00
LeoVasanko baa993e586 Theme precedence: URL param, then localStorage, then browser default
The restricted page's early script now honors the URL theme parameter
before the cached profile theme, so a fresh server-provided override
wins and the host color scheme applies without a flash. After session
load, an empty profile theme clears the cache but keeps the URL
parameter in effect instead of reverting to the system default.
2026-09-17 19:17:00 +00:00
LeoVasanko 42240dd2c7 Unify host profile view as framed dialog, add profile iframe mode
HostProfileView now renders the same centered frame card as the login
flows, whether shown full-page at /auth/ (host mode) or inside the new
#mode=profile restricted iframe. The component self-fetches its data
when the parent does not provide it, and emits back/logout so each
context reacts appropriately: the full page reloads, the iframe posts
auth-back / auth-logout to the host.
2026-09-17 19:17:00 +00:00
LeoVasanko 2ec709905e paskia-js: profile() dialog, auth-logout message, host color-scheme adoption
New profile() function opens the minimal profile in a compact dialog
iframe and always resolves ('logout' | 'back'), keeping the auth flow's
resolve/reject contract separate and unchanged. The overlay now injects
the host page's computed color-scheme into the iframe URL theme param
when the server has not provided one.
2026-09-17 19:17:00 +00:00
LeoVasanko 6eb862278f Add tests for startup box sign-in summary wildcard pruning 2026-09-10 19:57:37 +00:00
LeoVasanko dbd697772a Updated systemd unit in README for better messages. 2026-09-10 01:55:44 +00:00
LeoVasanko 17abcc48c0 Replace ua-parser wrapper with uarite uaparse 2026-09-09 19:22:59 +00:00
LeoVasanko 97ce10dd6f Improved formatting of origin configuration in startup box. 2026-09-09 19:10:01 +00:00
LeoVasanko 3a7ba09ddd Fix legacy conversion dropping 'empty origins = allow all' when an auth host was set
The **.{rp-id} wildcard was only added when the resulting origins dict
was empty, so a legacy database with a dedicated auth host but no
configured origins ended up allowing only the auth host.
2026-09-09 17:35:04 +00:00
55 changed files with 4946 additions and 582 deletions

No files matched your search

+2 -1
View File
@@ -188,11 +188,12 @@ Paste the following and save:
```ini
[Unit]
Description=Paskia
Description=Paskia authentication system
[Service]
Type=simple
User=paskia
SyslogIdentifier=paskia
WorkingDirectory=/srv/paskia
ExecStart=uvx paskia@latest
+252
View File
@@ -0,0 +1,252 @@
# Remote Proxy / Satellite
Paskia can serve configured domains (rp-ids) from a **remote** paskia
instance instead of the local database, so latency-sensitive checks
(`/auth/api/forward`, `/auth/api/validate`) answer in ~1 ms even when the
auth server is on another continent. Example: `app2.example.com` runs on
our local host and needs fast local checks, while `app1.example.com` and
`auth.example.com` run far away — all sharing `example.com` as rp-id.
Client applications that used `https://auth.example.com` as their auth
backend (forward-auth checks) only repoint to the local satellite
(`http://127.0.0.1:4401`); both remain usable interchangeably, and the
satellite ultimately uses `auth.example.com`.
Status: **implemented**. The feature lives in `paskia/satellite.py`
(satellite side: uplinks, replicas, host dispatch, forwarding) and
`paskia/syncfeed.py` + `paskia/fastapi/sync.py` (upstream side: change
feed, registration handshake, sync WebSocket). The design review comparing
the rejected alternatives is at the end of this document.
## Configuration
Setup is a single copy-paste of a **link URL**, and everything else flows
from it. On the **upstream** (the authoritative instance), open the admin
*Satellites* panel and use *New satellite link*, choosing the domains the
satellite will serve. That mints a registration offer and shows one URL:
```
wss://<initial-key>@auth.example.com
```
The `wss://` scheme is deliberate: the URL is meant only for a satellite
to connect to, and browsers refuse to open it. The initial key is held in
the upstream's RAM only, expires in 10 minutes, and is single-use — losing
or leaking it just means minting a new one. The permanent credentials are
created during registration (see *How it works*).
On the **satellite**, either:
- paste the URL in its admin *Satellites* panel under *Uplinks → Add
uplink* — registration runs immediately and reports failures inline; or
- run `paskia satellite <url>` on the satellite host — the whole setup of
a satellite-only installation (it creates the database on first run; no
local domain or admin site is needed).
That's all: the upstream pushes the link's domain set — rp-id, display
name and the origins table verbatim, including its auth host — and the
satellite persists it. The upstream is authoritative: pushed domains are
validated against the satellite's local configuration (an rp-id already
served locally, or origins colliding with it, are rejected and reported
back into the upstream's link list), and remote domains are read-only on
the satellite — the only satellite-side configuration is linking and
unlinking.
A link's scope is **adjustable at any time** from the upstream's link list
(edit which domains it serves): the new set is pushed over the live link —
register once, no new cryptographic setup. Revoking a link drops the
satellite's connections; the satellite observes the policy close and
unlinks its side (its copy of the domains is removed).
A satellite-only instance serves its backend apps on local addresses and
needs no inbound connectivity at all — the link is an outbound WebSocket
from the satellite.
In the stored config, a remote domain carries only the link reference:
```json
"remote": { "link": "<uplink id>" }
```
and the uplink itself lives in the database's `uplinks` table (the
upstream URL, the shared `key` bytes from the handshake, and the local
tolerance knobs):
- `cache_ttl` (default 60) — seconds the replica remains trusted after
the sync channel goes down; then checks fail closed (503). Set it large
(up to the 24 h session lifetime) for fail-open behavior.
- `refresh_interval` (default 300) — seconds between reconnects; every
connect starts from a full snapshot, which reconciles any drift.
On the upstream, links live in the `satellite_links` table: the peer's
`key` bytes, its self-reported label, and the scope (`rp_ids`).
## How it works
**Registration and the sync channel share one endpoint** —
`{upstream}/auth/api/sync/ws` — multiplexed by the first binary frame.
Registration is a 3-way dialogue over WebSocket binary frames:
```
satellite → upstream hello {link: null, key: <initial key>, label}
upstream → satellite welcome {link, key: <server part>, domains: [...]}
satellite → upstream ready {key: <client part>, rejected: {...}}
```
The permanent link credential is created during the handshake from key
parts both sides contribute; both sides then store the link in their
databases. (Cryptography is currently a placeholder: the parts are
exchanged as-is. The final version derives the channel key from the
handshake without sending key material, and the JSON frames that follow
are transparently encrypted.)
An established link runs the same dialogue with `hello {link, key}`
proving the stored credential; the welcome re-pushes the link's domain set
(the scope may have changed while the satellite was offline), the ready is
a bare ack carrying any domain rejections. After the handshake the channel
switches to plain JSON frames: a snapshot of the scoped tables, `ready`,
then live upsert/delete events; the satellite sends `session_refresh`
write-backs. A live `domains` frame pushes scope or origin changes;
the satellite applies and acknowledges.
**Dispatch is keyed by host, and only the satellite module knows about
stores.** `satellite.store_for_host(host)` returns the local DB or the
replica of the uplink backing the host's domain (raising 503
`HTTPException` when the replica is unavailable). The session read path
(`session_ctx`, `authz.verify`, `build_user_info`) just passes the host it
already has; writes dispatch likewise (`satellite.refresh_session` —
write-behind for remote, `db.update_session` for local;
`satellite.evict_session` on logout). `satellite.forward_request(request)`
returns the proxied response for remote domains or `None` for local ones.
**The replica** is a `ReplicaDB` instance in RAM, never persisted, one per
uplink connection. On connect the upstream sends a snapshot of the scoped
tables (the link's domains' credentials and sessions), then live
upsert/delete events emitted from the struct `store()`/`delete()` hooks
(which also cover cascade deletes) and field-mutating operations. The feed
is scoped to the link's rp-ids, and the user graph never leaves the
upstream: every session upsert carries an embedded `ctx` — the same
`ApiSessionContext` sent to users — holding the session's user/org/role
display data and effective permissions, computed for the session's bound
host. Mutations of users, orgs, roles or permissions are not published;
instead the upstream eagerly re-pushes the affected sessions with freshly
computed contexts, so renames and permission revocations take effect
within ~1 RTT. A single ordered WebSocket cannot gap; a slow subscriber is
dropped and resyncs. The feed carries no usable secrets: sessions are
keyed by `hash_secret` output, credentials carry public keys only, and the
OIDC signing key is never replicated.
## Endpoint behavior for remote domains
| Endpoint | Handling |
|---|---|
| `GET /auth/api/forward`, `GET /settings` | served from the replica (sub-ms) |
| `POST /auth/api/validate` | verified from the replica; the throttled refresh updates the replica and is written back over the sync channel; cookie renewed locally |
| `GET /check`, `GET /user-info` | proxied — these need the user graph (per-session contexts do not cover arbitrary user lookups or full profile data) |
| `POST /auth/api/logout` | proxied to the upstream (original Host preserved) and evicted from the replica immediately |
| `POST /auth/api/set-session`, `GET /token-info` | proxied (the exchange code/reset token lives on the upstream); the session arrives via sync event |
| `/auth/oidc/*` | proxied (signing key and OIDC sessions stay on the upstream) |
| `/auth/ws/*`, `/auth/remote-auth/*`, admin, profile | not served — the auth host requirement means these are reached on the upstream directly |
Proxied calls answer 503 when the upstream is unreachable.
`/auth/api/settings` reports `remote: true` for a satellite-served domain.
Should a remote domain's auth host be misrouted to the satellite anyway,
profile/avatar requests (`/auth/api/user/*`) are forwarded to the upstream
rather than answered from local storage — the satellite never takes writes
for a remote domain.
Freshness hierarchy:
1. Changes made **through** the satellite: immediate (write-behind,
optimistic eviction).
2. Changes made **directly on the upstream**: a sync event, ~1 network
RTT.
3. Channel down: the replica stays authoritative until `cache_ttl` past
the disconnect (dead-peer detection is bounded by the ~10 s keepalive),
then 503. Every reconnect starts from a fresh snapshot.
## The upstream side
Nearly additive: the link table, RAM-only registration offers and the
`syncfeed` subscriber set fed by the commit hooks, plus the
`/auth/api/sync/ws` endpoint doing the handshake, serving snapshots and
live events, pushing domain configuration, and accepting
`session_refresh` write-backs within the link's scope. With no satellites
connected, the hooks are a no-op.
## Trust and caveats
- The satellite host holds only its link's domains' sessions (with their
identity contexts) and credentials in RAM — it learns the users that
actually sign in to those domains, never the directory, and never the
OIDC key. Still treat it as trusted: it is authoritative for session
checks within `cache_ttl`.
- Avatars are stored on the upstream's disk; profile data such as
`user-info` is proxied to the upstream rather than answered locally.
- OIDC sessions in a proxied `user-info` show the client UUID
rather than its name (OIDC clients are not replicated).
---
# Design review (the rejected alternatives)
## Option A — caching HTTP reverse proxy
A thin proxy caching `/auth/api/forward`, `/check`, `/user-info`,
`/settings` responses keyed by `(Host, cookie, query)` with
`TTL = min(configured TTL, Remote-Session-Expires − now)`; everything
else forwarded verbatim, WebSockets tunneled, `/logout` intercepted for
eviction. **Option B** adds a remote change feed so eviction happens
within one RTT instead of at TTL.
- Remote changes: none for A; one additive endpoint for B.
- The proxy needs no credentials — requests are authenticated by the end
user's cookie, forwarded on a miss.
## What the read-only local state buys over the HTTP cache
- **Full `SessionContext` locally.** A replays the byte-response it once
saw; the satellite *computes* the answer. Query combinations never seen
before (new `perm`/`max_age`/`public` shapes) are served locally but
miss A's cache. The replica holds the *domain model*, so derived
answers (effective permissions per host, `max_age` against
`credential.last_used`, `Remote-*` composition) are correct without
having been witnessed.
- **One invalidation model.** A hand-builds invalidation rules per
endpoint (query-key mapping, cookie re-keying on renew, 401 variants).
Events mutate the replica (upsert/delete by table+key) and every
endpoint becomes consistent at once — including future ones.
- **Degradation behaves like a real instance.** With the remote down, the
satellite serves a coherent auth service from the replica (expiry
enforced locally, bounded by `cache_ttl`); A serves unrelated cached
responses with gaps wherever the cache was cold.
- **Multi-domain uniformity.** Remote backing is a property of a domain
in the existing registry; local and remote rp-ids coexist in one
instance. A is a separate component bolted in front of specific URLs.
- **User simplicity.** One link registration per satellite; A needs
deployment and cache-key discipline per frontend application.
## What it costs
- The read path must be honest about which DB it reads: `DB.session_ctx`
and `/check` were rewritten to use their own tables instead of struct
convenience properties that reach the global database. (A first draft's
contextvar-dependent `db.data()` was rejected: a global accessor whose
meaning shifts under the caller. Dispatch is instead keyed explicitly
by the request host.)
- A sync protocol (registration handshake, snapshot + live events +
reconnect reconciliation, live domain pushes).
- A trusted satellite host (its link's sessions and credentials in RAM).
- Additive upstream code (sync endpoint + commit hooks), where A needs
none.
- Replica housekeeping (expiry sweeper, write-behind, optimistic
eviction).
## Summary
A(+B) is the right tool to "make forward-auth fast in front of an
untouched server". The satellite — implemented here — is the right tool
when it should *be* a paskia instance for its remote domains: one
consistency model, correct answers for un-cached query shapes, graceful
degradation, and per-domain mixing with local rp-ids, at the price of the
read-path cleanup, the sync protocol, and a trusted satellite host.
+37 -26
View File
@@ -8,6 +8,17 @@
:root {
color-scheme: light dark; /* Automatic themes by browser */
}
.section a, .section button {
display: inline-block;
padding: 0.4em;
margin-right: 0.3em;
border: none;
background: #aaa2;
color: inherit;
font: inherit;
text-decoration: none;
cursor: pointer;
}
</style>
</head>
<body>
@@ -20,13 +31,14 @@
<div class="content">
<div class="section">
<h2>Management Site</h2>
<button onclick="window.open('/auth/', '_blank')">👤 User Profile</button>
<button onclick="window.open('/auth/admin/', '_blank')">⚙️ Admin Panel</button>
<a href="/auth/">👤 User Profile</a>
<a href="/auth/admin/">⚙️ Admin Panel</a>
</div>
<div class="section">
<h2>API Mode (not leaving the page)</h2>
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
<button onclick="profileDemo()">👤 Login/Profile</button>
<button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
@@ -35,10 +47,10 @@
<div class="section">
<h2>Browser Mode (full page)</h2>
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):</p>
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button>
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx). If not authenticated, you'll see the login page; after auth, a 204 response (blank page = success). Back returns here:</p>
<a href="/auth/api/forward">🔐 Basic Auth</a>
<a href="/auth/api/forward?max_age=10s">🔄 Reauth (max_age=10s)</a>
<a href="/auth/api/forward?perm=auth:admin">🛡️ Admin Only</a>
</div>
<pre id="output">Click a button to test...</pre>
@@ -46,52 +58,51 @@
</div>
<script type="module">
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
const output = document.getElementById('output');
import { apiFetch, apiJson, AuthCancelledError, profile } from '/paskia-js/dist/paskia.js'
function log(msg) {
output.textContent = msg;
console.log(msg)
document.getElementById('output').textContent = msg
}
// Make an API call using paskia module (handles 401/403 automatically)
window.apiCall = async function(url, method = 'GET') {
log(`${method} ${url}...`);
log(`${method} ${url}...`)
try {
const response = await apiFetch(url, { method });
const response = await apiFetch(url, { method })
// Forward endpoint returns 204 on success
if (response.status === 204) {
log('✓ Success (204 No Content)');
return;
log('✓ Success (204 No Content)')
return
}
if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`);
return;
log(`Error: ${response.status} ${response.statusText}`)
return
}
const data = await response.json();
log('✓ Response:\n' + JSON.stringify(data, null, 2));
const data = await response.json()
log('✓ Response:\n' + JSON.stringify(data, null, 2))
} catch (e) {
if (e instanceof AuthCancelledError) {
log('Authentication cancelled');
log('Authentication cancelled')
} else {
log(`Error: ${e.message}`);
log(`Error: ${e.message}`)
}
}
}
window.logout = async function() {
await fetch('/auth/api/logout', { method: 'POST' });
log('Logged out');
await fetch('/auth/api/logout', { method: 'POST' })
log('Logged out')
}
// Browser mode: open the forward endpoint directly in a new window.
window.browserNav = function(url) {
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
window.open(url, '_blank');
// Profile dialog: resolves 'login' / 'logout' / 'back'
window.profileDemo = async function() {
log(`Profile return: ${await profile()}`)
}
</script>
</body>
</html>
+15 -1
View File
@@ -2,7 +2,14 @@
<div class="app-shell">
<StatusMessage />
<main class="app-main">
<HostProfileView v-if="viewState === 'profile' && isHostMode" />
<HostProfileView
v-if="viewState === 'profile' && isHostMode"
:ctx="store.ctx"
:user-info="store.userInfo"
:settings="store.settings"
@back="goBack"
@logout="onHostLogout"
/>
<ProfileView v-else-if="viewState === 'profile'" />
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
<AccessDenied v-else-if="viewState === 'terminal'" />
@@ -15,6 +22,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import { goBack } from '@/utils/helpers'
import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue'
@@ -48,6 +56,12 @@ const isHostMode = computed(() => {
return currentHost !== configuredHost
})
// HostProfileView already posted /auth/api/logout; clear local state and reload.
function onHostLogout() {
sessionStorage.clear()
window.location.reload()
}
function onSessionLost(e) {
store.userInfo = null
store.ctx = null
+1
View File
@@ -1009,6 +1009,7 @@ async function submitDialog() {
@create-domain="createDomain"
@open-domain="openDomain"
@delete-domain="deleteDomain"
@domains-changed="loadDomains"
@navigate-out="handlePanelNavigateOut"
/>
+81 -2
View File
@@ -1,5 +1,36 @@
<template>
<template v-if="authMode === 'profile'">
<!--
Profile mode: render nothing until the session check completes (avoids
a load-time flash of the wrong view). Without a session, the login flow
runs in place of the profile; on success auth-success is posted and the
host resolves profile() with 'login'.
-->
<RestrictedAuth
v-if="profileState === 'login'"
mode="login"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
<HostProfileView
v-else-if="profileState === 'ready'"
:ctx="profileCtx"
:user-info="profileInfo"
:settings="profileSettings"
@back="handleBack"
@logout="handleLogout"
/>
<div v-else class="view-root profile-pending">
<div class="surface surface--tight">
<p class="view-lede">{{ profileState === 'error' ? 'Could not load your account.' : 'Loading your account…' }}</p>
<div class="button-row">
<button type="button" class="btn-secondary" @click="handleBack">Back</button>
</div>
</div>
</div>
</template>
<RestrictedAuth
v-else
:mode="authMode"
:remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@@ -11,6 +42,10 @@
<script setup>
import { onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue'
import HostProfileView from '@/components/HostProfileView.vue'
import { fetchJson, settings as paskiaSettings } from 'paskia'
import { getSettings } from '@/utils/settings'
import { updateThemeFromSession } from '@/utils/theme'
// Check if this is a remote auth URL: /auth/{token}
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
@@ -45,8 +80,32 @@ let authMode
if (window.location.pathname === '/auth/restricted/oidc') {
authMode = 'oidc'
} else {
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth/profile)
authMode = ['reauth', 'forbidden', 'profile'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
}
// Profile mode state: 'loading' | 'login' | 'ready' | 'error'
const profileState = ref('loading')
const profileCtx = ref(null)
const profileInfo = ref(null)
const profileSettings = ref(null)
async function loadProfile() {
try {
const [validateData, infoData, settingsData] = await Promise.all([
fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
fetchJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms }),
getSettings()
])
profileCtx.value = validateData.ctx
profileInfo.value = infoData
profileSettings.value = settingsData
updateThemeFromSession(validateData.ctx)
profileState.value = 'ready'
} catch (error) {
// No/expired session: run the login flow in place of the profile
profileState.value = error.status === 401 || error.status === 403 ? 'login' : 'error'
}
}
function postToParent(message) {
@@ -74,10 +133,18 @@ function handleBack() {
})
}
function handleLogout() {
postToParent({
type: 'auth-logout'
})
}
onMounted(() => {
// Check for remote auth token in URL
remoteAuthToken.value = extractRemoteToken()
if (authMode === 'profile') loadProfile()
postToParent({
type: 'auth-ready'
})
@@ -89,3 +156,15 @@ onMounted(() => {
})
})
</script>
<style scoped>
.view-root.profile-pending { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.profile-pending .surface {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
</style>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
<script>{let t=new URLSearchParams(location.hash.slice(1)).get('theme');if(t!=='light'&&t!=='dark')t=localStorage.getItem('paskia-theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>
+12 -3
View File
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
import { formatDate, originDisplayEntries } from '@/utils/helpers'
import AdminSatellites from './AdminSatellites.vue'
const props = defineProps({
info: Object,
@@ -14,7 +15,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'navigateOut'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'domainsChanged', 'navigateOut'])
// Template refs for navigation
const orgActionsRef = ref(null)
@@ -460,20 +461,28 @@ defineExpose({ focusFirstElement })
<tr v-for="domain in sortedDomains" :key="domain.rp_id">
<td class="perm-name-cell">
<div class="perm-title">
<a :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
<a v-if="!domain.remote" :href="'#domain:' + domain.rp_id" @click.prevent="$emit('openDomain', domain)">{{ domain.rp_name || domain.rp_id }}</a>
<span v-else>{{ domain.rp_name || domain.rp_id }}</span>
</div>
<div class="perm-id-info">
<span class="id-text">{{ domain.rp_id }}</span>
<span v-if="domain.remote" class="id-text" title="Satellite-served — its configuration is pushed by the uplink and managed in the Satellites panel">🛰 via uplink</span>
</div>
</td>
<td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }}</span></td>
<td class="center">
<button v-if="domain.rp_id !== currentRpId" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
<button v-if="domain.rp_id !== currentRpId && !domain.remote" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
</td>
</tr>
</tbody>
</table>
</div>
<AdminSatellites
v-if="isMasterAdmin"
:domains="domains || []"
@domains-changed="$emit('domainsChanged')"
/>
</template>
<style scoped>
+386
View File
@@ -0,0 +1,386 @@
<script setup>
// Satellites panel: both link roles of this instance.
//
// Upstream role ("Satellites" table): other paskia instances registered to
// serve this instance's domains. Registration is online: mint a link URL
// (RAM-only, single-use, 10 minutes), the satellite pastes it in its own
// Satellites panel or runs `paskia satellite <url>`. A link's scope (the
// domains it serves) is adjustable at any time — the new set is pushed
// over the live link, no new registration. Revoking a link drops the
// satellite's connection and its copy of the domains.
//
// Satellite role ("Uplinks" table): this instance serving other
// instances' domains. The only local configuration is the link itself —
// domains, origins and auth host arrive from the upstream and are
// read-only here.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import AdminDialog from './dialogs/AdminDialog.vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from 'paskia'
const props = defineProps({
domains: { type: Array, default: () => [] },
})
const emit = defineEmits(['domains-changed'])
const authStore = useAuthStore()
const links = ref([])
const uplinks = ref([])
async function load() {
try {
;[links.value, uplinks.value] = await Promise.all([
apiJson('/auth/api/admin/satellites/', { method: 'GET' }),
apiJson('/auth/api/admin/uplinks/', { method: 'GET' }),
])
} catch {
// Master admin only section; leave lists empty on failure
}
}
// While an uplink is not connected, poll gently so status goes live
// without a manual refresh (registration and reconnects included); after
// minting a link URL, poll for a minute to catch the satellite appearing.
let pollTimer = null
let mintWatchUntil = 0
function schedulePoll() {
clearTimeout(pollTimer)
const watching =
uplinks.value.some(u => !u.connected) ||
Date.now() < mintWatchUntil // a minted link's satellite may appear
if (watching) {
pollTimer = setTimeout(async () => {
await load()
schedulePoll()
}, 3000)
}
}
onMounted(async () => {
await load()
schedulePoll()
})
onBeforeUnmount(() => clearTimeout(pollTimer))
async function refresh() {
await load()
schedulePoll()
}
// Domains eligible for a link's scope: locally served, with an auth host
// (satellite users' account pages live there).
const eligibleDomains = computed(() =>
props.domains.filter(d => !d.remote && d.auth_host)
)
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
// --- Link dialog (create an offer / adjust scope) ---
const linkDialog = ref(null) // {id|null, selected:Set, minted:{url,expires_in}|null, busy, error}
function openLinkDialog(link) {
linkDialog.value = {
id: link?.id || null,
selected: new Set(link?.rp_ids || []),
minted: null,
busy: false,
error: '',
}
}
function toggleScope(rpId) {
// Replace the Set (in-place mutation is invisible to reactivity)
const s = new Set(linkDialog.value.selected)
if (s.has(rpId)) s.delete(rpId)
else s.add(rpId)
linkDialog.value.selected = s
}
async function submitLinkDialog() {
const d = linkDialog.value
if (!d || d.busy) return
if (d.minted) {
// The URL is shown — Done dismisses
linkDialog.value = null
return
}
d.busy = true
d.error = ''
try {
const rp_ids = [...d.selected]
if (d.id) {
await apiJson(`/auth/api/admin/satellites/${d.id}`, { method: 'PATCH', body: { rp_ids } })
authStore.showMessage('Link scope updated — pushed to the satellite.', 'success', 2500)
linkDialog.value = null
} else {
d.minted = await apiJson('/auth/api/admin/satellites/', { method: 'POST', body: { rp_ids } })
mintWatchUntil = Date.now() + 60_000 // watch for the satellite registering
}
await refresh()
} catch (e) {
d.error = e.message || 'Failed'
} finally {
d.busy = false
}
}
// --- Uplink dialog (register from a pasted link URL) ---
const uplinkDialog = ref(null) // {url, busy, error}
async function submitUplinkDialog() {
const d = uplinkDialog.value
if (!d || d.busy) return
d.busy = true
d.error = ''
try {
const result = await apiJson('/auth/api/admin/uplinks/', { method: 'POST', body: { url: d.url } })
uplinkDialog.value = null
const served = result.domains?.length ? result.domains.join(', ') : 'no domains yet'
authStore.showMessage(`Linked to ${result.url} — serving ${served}.`, 'success', 4000)
const rejected = Object.entries(result.rejected || {})
if (rejected.length) {
authStore.showMessage(
`Rejected by configuration: ${rejected.map(([r, why]) => `${r} (${why})`).join(', ')}`,
'error'
)
}
await refresh()
emit('domains-changed')
} catch (e) {
d.error = e.message || 'Registration failed'
} finally {
d.busy = false
}
}
// --- Confirm dialog (revoke / unlink) ---
const confirmDialog = ref(null) // {title, message, submitLabel, action, busy}
function askConfirm(title, message, submitLabel, action) {
confirmDialog.value = { title, message, submitLabel, action, busy: false }
}
async function submitConfirm() {
const d = confirmDialog.value
if (!d || d.busy) return
d.busy = true
try {
await d.action()
confirmDialog.value = null
await refresh()
emit('domains-changed')
} catch (e) {
authStore.showMessage(e.message || 'Failed', 'error')
confirmDialog.value = null
}
}
function revokeLink(link) {
askConfirm(
'Revoke satellite link',
`Revoke the link for ${link.label || link.id}? The satellite disconnects immediately and its copy of ${(link.rp_ids || []).join(', ') || 'the domains'} is dropped.`,
'Revoke',
async () => {
await apiJson(`/auth/api/admin/satellites/${link.id}`, { method: 'DELETE' })
authStore.showMessage('Satellite link revoked.', 'success', 2500)
}
)
}
function unlinkUplink(uplink) {
askConfirm(
'Unlink uplink',
`Unlink ${uplink.url}? This instance stops serving ${(uplink.domains || []).join(', ') || 'its domains'} — the domains and their local replica are removed.`,
'Unlink',
async () => {
await apiJson(`/auth/api/admin/uplinks/${uplink.id}`, { method: 'DELETE' })
authStore.showMessage('Uplink removed.', 'success', 2500)
}
)
}
function uplinkStatus(u) {
if (u.connected) return { icon: '●', text: 'connected', cls: 'status-ok' }
if (u.available) return { icon: '◐', text: 'offline — answering from cache', cls: 'status-warn' }
return { icon: '○', text: 'offline', cls: 'status-bad' }
}
</script>
<template>
<div class="satellites-section">
<div class="section-header">
<h2>Satellites</h2>
<p class="section-description">
A satellite is another paskia instance serving this instance's domains next to the apps that need
them — session checks answer locally in a millisecond from a RAM replica kept in sync over a live
link. Registration is online: mint a link URL, then paste it in the satellite's own Satellites
panel or run <code>paskia satellite &lt;url&gt;</code> on the satellite host.
</p>
</div>
<table v-if="links.length" class="org-table">
<thead>
<tr>
<th>Satellite</th>
<th>Serves domains</th>
<th class="center">Status</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-for="link in links" :key="link.id">
<td class="perm-name-cell">
<div class="perm-title">{{ link.label || 'satellite' }}</div>
<div class="perm-id-info"><span class="id-text">{{ link.id }}</span></div>
</td>
<td>
<span v-if="link.rp_ids.length">{{ link.rp_ids.join(', ') }}</span>
<span v-else class="muted">—</span>
<div v-if="Object.keys(link.rejected || {}).length" class="small error">
rejected there: {{ Object.entries(link.rejected).map(([r, why]) => `${r} (${why})`).join(', ') }}
</div>
</td>
<td class="center">
<span v-if="link.connected" class="status-ok" title="The satellite is connected now">●</span>
<span v-else class="muted" title="Not connected">○</span>
</td>
<td class="center">
<button type="button" class="icon-btn" @click="openLinkDialog(link)" aria-label="Adjust scope" title="Adjust which domains it serves">✏️</button>
<button type="button" class="icon-btn delete-icon" @click="revokeLink(link)" aria-label="Revoke link" title="Revoke — the satellite disconnects and unlinks">❌</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="small muted">No satellites registered.</p>
<div class="actions">
<button type="button" :disabled="!eligibleDomains.length" @click="openLinkDialog(null)">+ New satellite link</button>
<span v-if="!eligibleDomains.length" class="small muted">A domain with an auth host is required first.</span>
</div>
<h3 class="uplinks-title">Uplinks <span class="muted uplinks-hint">— this instance as the satellite</span></h3>
<table v-if="uplinks.length" class="org-table">
<thead>
<tr>
<th>Upstream</th>
<th>Serving domains</th>
<th class="center">Status</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-for="uplink in uplinks" :key="uplink.id">
<td class="perm-name-cell">
<div class="perm-title">{{ uplink.url }}</div>
<div class="perm-id-info"><span class="id-text">{{ uplink.id }}</span></div>
</td>
<td>
<span v-if="uplink.domains.length">{{ uplink.domains.join(', ') }}</span>
<span v-else class="muted">—</span>
</td>
<td class="center">
<span :class="uplinkStatus(uplink).cls" :title="uplinkStatus(uplink).text">{{ uplinkStatus(uplink).icon }}</span>
</td>
<td class="center">
<button type="button" class="icon-btn delete-icon" @click="unlinkUplink(uplink)" aria-label="Unlink" title="Unlink — stop serving its domains">❌</button>
</td>
</tr>
</tbody>
</table>
<div class="actions">
<button type="button" @click="uplinkDialog = { url: '', busy: false, error: '' }">+ Add uplink</button>
</div>
<AdminDialog
v-if="linkDialog"
:title="linkDialog.id ? 'Satellite link scope' : 'New satellite link'"
:busy="linkDialog.busy"
:error="linkDialog.error"
:submit-label="linkDialog.minted ? 'Done' : linkDialog.id ? 'Save' : 'Generate link URL'"
@submit="submitLinkDialog"
@close="linkDialog = null"
>
<template v-if="!linkDialog.minted">
<p class="small muted">
Domains this satellite serves. Adjustable later — the new set is pushed over the live link,
no new registration.
</p>
<div class="scope-list">
<label v-for="d in eligibleDomains" :key="d.rp_id" class="scope-row">
<input type="checkbox" :checked="linkDialog.selected.has(d.rp_id)" @change="toggleScope(d.rp_id)" />
<span>{{ d.rp_id }}</span>
</label>
</div>
</template>
<template v-else>
<p class="small muted">
The link URL — hand it to the satellite (its Satellites panel, or
<code>paskia satellite &lt;url&gt;</code>). Valid for {{ Math.round(linkDialog.minted.expires_in / 60) }} minutes,
single-use, shown once:
</p>
<pre class="wellknown-doc" title="Click to copy" tabindex="0" @click="copyText(linkDialog.minted.url, 'Link URL')" @keydown.enter.prevent="copyText(linkDialog.minted.url, 'Link URL')">{{ linkDialog.minted.url }}</pre>
</template>
</AdminDialog>
<AdminDialog
v-if="uplinkDialog"
title="Add uplink"
:busy="uplinkDialog.busy"
:error="uplinkDialog.error"
submit-label="Register"
:submit-disabled="!uplinkDialog.url.trim()"
@submit="submitUplinkDialog"
@close="uplinkDialog = null"
>
<label>Link URL
<input
v-model="uplinkDialog.url"
placeholder="wss://alpha.bravo.charlie.delta.echo@auth.example.com"
autocomplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
spellcheck="false"
/>
</label>
<p class="small muted">
Minted on the upstream (its Satellites panel → New satellite link). Registration runs now:
the domains it covers appear here and in the domains list, managed by the upstream.
</p>
</AdminDialog>
<AdminDialog
v-if="confirmDialog"
:title="confirmDialog.title"
:busy="confirmDialog.busy"
:submit-label="confirmDialog.submitLabel"
@submit="submitConfirm"
@close="confirmDialog = null"
>
<p class="small">{{ confirmDialog.message }}</p>
</AdminDialog>
</div>
</template>
<style scoped>
.satellites-section { margin-top: var(--space-2xl); display: flex; flex-direction: column; gap: var(--space-sm); }
.satellites-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-xs); }
.satellites-section .actions { display: flex; align-items: center; gap: var(--space-sm); }
.uplinks-title { margin: var(--space-lg) 0 var(--space-xs); font-size: 1.05rem; }
.uplinks-hint { font-weight: 400; font-size: 0.85rem; }
.perm-name-cell { display: flex; flex-direction: column; gap: 0.3rem; }
.perm-title { font-weight: 600; color: var(--color-heading); }
.perm-id-info { font-size: 0.8rem; color: var(--color-text-muted); }
.id-text { font-family: var(--font-mono, monospace); }
.org-table .center { width: 4rem; min-width: 4rem; text-align: center; }
.status-ok { color: var(--color-success, #16a34a); }
.status-warn { color: var(--color-warning, #d97706); }
.status-bad { color: var(--color-text-muted); }
.scope-list { display: flex; flex-direction: column; gap: 0.3rem; }
.scope-row { display: flex; flex-direction: row; align-items: center; gap: var(--space-xs); }
.scope-row input { width: auto; }
.wellknown-doc { margin: 0; padding: var(--space-xs) var(--space-sm); font-size: 0.8rem; background: var(--color-bg-soft, rgba(127,127,127,0.08)); border-radius: 4px; white-space: pre-wrap; word-break: break-all; cursor: pointer; }
.error { color: var(--color-error); }
</style>
+151 -78
View File
@@ -1,92 +1,116 @@
<template>
<section class="view-root view-root--wide host-view" data-view="host-profile">
<header class="view-header">
<h1>{{ headingTitle }}</h1>
<p class="view-lede">{{ subheading }}</p>
</header>
<div class="view-root host-profile" data-view="host-profile">
<div class="surface surface--tight">
<!-- Heading/lede belong to the standalone page; in the dialog the host
page already provides the surrounding context. -->
<header v-if="!inIframe" class="view-header center">
<h1>{{ headingTitle }}</h1>
<p class="view-lede">{{ subheading }}</p>
</header>
<section class="section-block" ref="userInfoSection">
<div class="section-body">
<UserBasicInfo
v-if="ctx"
:name="ctx.user.display_name"
:avatar-url="authStore.userInfo.user.avatar_url"
:visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:email="ctx.user.email"
:telephone="ctx.user.telephone"
:org-display-name="orgDisplayName"
:role-name="roleDisplayName"
:can-edit="false"
/>
<p v-else class="empty-state">
{{ initializing ? 'Loading your account…' : 'No active session found.' }}
</p>
</div>
</section>
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
<button
type="button"
class="btn-secondary"
@click="goBack"
>
Back
</button>
<button
type="button"
class="btn-danger"
:disabled="authStore.isLoading"
@click="logout"
>
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
</button>
<button
v-if="authSiteUrl"
type="button"
class="btn-primary"
:disabled="authStore.isLoading"
@click="goToAuthSite"
>
Full Profile
</button>
<section class="section-block">
<div class="section-body">
<UserBasicInfo
v-if="sessionCtx && info"
:name="sessionCtx.user.display_name"
:avatar-url="info.user.avatar_url"
:visits="info.user.visits"
:created-at="info.user.created_at"
:last-seen="info.user.last_seen"
:email="sessionCtx.user.email"
:telephone="sessionCtx.user.telephone"
:org-display-name="orgDisplayName"
:role-name="roleDisplayName"
:can-edit="false"
/>
<p v-else class="empty-state">
{{ loading ? 'Loading your account…' : 'No active session found.' }}
</p>
</div>
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
</div>
</section>
</section>
</section>
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
<button
type="button"
class="btn-secondary"
@click="$emit('back')"
>
Back
</button>
<button
v-if="sessionCtx"
type="button"
class="btn-danger"
:disabled="busy"
@click="logout"
>
{{ busy ? 'Signing out…' : 'Logout' }}
</button>
<button
type="button"
class="btn-primary"
:disabled="busy"
@click="goToAuthSite"
>
Full Profile
</button>
</div>
<p v-if="!inIframe" class="note"><strong>Logout</strong> from {{ currentHost }}, or view your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth'
import { goBack } from '@/utils/helpers'
import { getSettings } from '@/utils/settings'
import { fetchJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
defineProps({
initializing: {
type: Boolean,
default: false
// Data may be provided by the parent (full-page /auth/ app already loaded it
// into the store); otherwise the component fetches it itself (restricted iframe).
const props = defineProps({
ctx: {
type: Object,
default: null
},
userInfo: {
type: Object,
default: null
},
settings: {
type: Object,
default: null
}
})
const authStore = useAuthStore()
const emit = defineEmits(['back', 'logout'])
const inIframe = window.parent !== window
const currentHost = window.location.host
const fetchedCtx = ref(null)
const fetchedInfo = ref(null)
const fetchedSettings = ref(null)
const loading = ref(!(props.ctx && props.userInfo))
const busy = ref(false)
// Template refs for navigation
const userInfoSection = ref(null)
const buttonRow = ref(null)
const ctx = computed(() => authStore.userInfo || null)
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '')
const sessionCtx = computed(() => props.ctx || fetchedCtx.value)
const info = computed(() => props.userInfo || fetchedInfo.value)
const settingsData = computed(() => props.settings || fetchedSettings.value)
const orgDisplayName = computed(() => sessionCtx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => sessionCtx.value?.role?.display_name ?? '')
const headingTitle = computed(() => {
const service = authStore.settings?.rp_name
const service = settingsData.value?.rp_name
return service ? `${service} account` : 'Account overview'
})
@@ -94,11 +118,12 @@ const subheading = computed(() => {
return `You're signed in to ${currentHost}.`
})
const authSiteHost = computed(() => authStore.settings?.auth_host || '')
const authSiteHost = computed(() => settingsData.value?.auth_host || '')
const authSiteUrl = computed(() => {
const host = authSiteHost.value
if (!host) return ''
let path = authStore.settings?.ui_base_path ?? '/auth/'
// Fall back to the current host when no separate auth host is configured;
// the full profile is at ui_base_path either way.
const host = authSiteHost.value || currentHost
let path = settingsData.value?.ui_base_path ?? '/auth/'
if (!path.startsWith('/')) path = `/${path}`
if (!path.endsWith('/')) path = `${path}/`
const protocol = window.location.protocol || 'https:'
@@ -107,11 +132,27 @@ const authSiteUrl = computed(() => {
const goToAuthSite = () => {
if (!authSiteUrl.value) return
window.location.href = authSiteUrl.value
// Inside an iframe, open the full profile in a new window and close the
// frame (auth-back) so the host page regains focus.
if (inIframe) {
window.open(authSiteUrl.value, '_blank')
emit('back')
} else {
window.location.href = authSiteUrl.value
}
}
const logout = async () => {
await authStore.logout()
if (busy.value) return
busy.value = true
try {
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
} catch (error) {
console.error('Logout error:', error)
}
// The parent decides how to react: the full-page app reloads, the iframe
// host receives auth-logout and closes the frame.
emit('logout')
}
// Keyboard navigation for button row
@@ -124,7 +165,39 @@ const handleButtonRowKeydown = (event) => {
if (direction === 'left' || direction === 'right') {
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
}
// Up does nothing (no elements above to navigate to)
// Down does nothing (no elements below to navigate to)
}
onMounted(async () => {
if (!props.settings) {
getSettings().then((data) => { fetchedSettings.value = data })
}
if (props.ctx && props.userInfo) return
try {
const [validateData, infoData] = await Promise.all([
fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
fetchJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
])
fetchedCtx.value = validateData.ctx
fetchedInfo.value = infoData
updateThemeFromSession(validateData.ctx)
} catch (error) {
if (error.status !== 401 && error.status !== 403) {
console.error('Failed to load account summary:', error)
}
} finally {
loading.value = false
}
})
</script>
<style scoped>
.view-root.host-profile { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
</style>
+12 -1
View File
@@ -38,9 +38,20 @@ export function initThemeFromCache() {
applyTheme(getCachedTheme())
}
/** Theme default from the URL hash (restricted iframe/forward pages only) */
function getHashTheme() {
const theme = new URLSearchParams(window.location.hash.slice(1)).get('theme')
return theme === 'light' || theme === 'dark' ? theme : ''
}
/** Update theme from session context (call after login/session load) */
export function updateThemeFromSession(ctx, animate = false) {
const theme = ctx?.user?.theme || ''
// Always keep the cache in sync with the profile: empty override clears it
// so stale values never mask future server-provided themes.
setCachedTheme(theme)
applyTheme(theme, document.documentElement, animate)
// Without a profile override, stay consistent with the initial paint: a
// theme parameter on the URL (e.g. host page color scheme injected by
// paskia-js) remains in effect before the browser/desktop default.
applyTheme(theme || getHashTheme(), document.documentElement, animate)
}
+5 -5
View File
@@ -8,11 +8,11 @@
* - Disables Vite's screen clearing on startup
*
* Options:
* paths - Array of paths to proxy (default: ["/api"])
* paths - Array of paths to proxy (default: ['/api'])
*/
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
export default function fastapiVue({ paths = ['/api'] } = {}) {
const backendUrl = process.env.PASKIA_BACKEND_URL || 'http://localhost:4402'
// Build proxy configuration for each path
const proxy = {}
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
}
return {
name: "vite-plugin-fastapi-paskia",
name: 'vite-plugin-fastapi-paskia',
config: () => ({
clearScreen: false,
server: { proxy },
build: {
outDir: "../paskia/frontend-build",
outDir: '../paskia/frontend-build',
emptyOutDir: true,
},
}),
+8
View File
@@ -64,6 +64,14 @@ export default defineConfig(({ command }) => ({
})
}
},
{
name: 'serve-paskia-js',
configureServer(server) {
// Serve the locally built paskia-js module for the examples page
const serve = sirv(resolve(__dirname, '../paskia-js'), { dev: true })
server.middlewares.use('/paskia-js', serve)
}
},
{
name: 'serve-examples',
configureServer(server) {
+84 -78
View File
@@ -1,14 +1,14 @@
# Paskia
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
# Paskia
JavaScript utilities for integrating the [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) into web apps.
## Installation
### NPM
### npm
No framework dependencies. Works with any framework (Vue, React, Svelte, etc.) or vanilla JS. Typescript typing included.
No framework dependencies. Works with Vue, React, Svelte, vanilla JavaScript and other frontend stacks. TypeScript types are included.
```sh
npm install paskia
@@ -20,7 +20,7 @@ import { ... } from 'paskia'
### Plain JavaScript
Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) first and host yourself. No Node needed.
Import directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) and host it yourself. No Node.js is required.
```html
<script type="module">
@@ -28,91 +28,102 @@ Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm
</script>
```
## Features
## Authentication
### Session Validation
### API requests
Refresh session and track its validity with automatic polling. Pauses on lack of user activity to avoid useless traffic and to allow session expiry even when the page is left open but idle. This monitors that the same account stays logged in but doesn't do any permission checks.
`apiFetch` wraps `fetch` with Paskia authentication handling, while `apiJson` adds automatic JSON request/response handling. Both support request timeouts. For the same JSON and timeout handling without prompting the user for authentication, use `fetchJson`.
```js
import { apiJson, apiFetch } from 'paskia'
const data = await apiJson('/api/endpoint', {
method: 'POST',
body: { key: 'value' }
})
const response = await apiFetch('/api/endpoint')
```
With `apiJson`, a provided `body` is JSON-encoded with the appropriate content type and the response is parsed as JSON.
When the server requests authentication, the API call pauses while the appropriate Paskia dialog is shown and retries after successful authentication.
> Paskia uses `401` and `403` responses to trigger the appropriate **login**, **reauthentication** or **access denied** flow. The backend supplies the authentication URL and context; see the main Paskia documentation for the full response protocol.
### Account and Profile
`profile()` provides a single dialog for an application's login/profile button that allows the user to sign in, view who they are and sign out without ever leaving the page.
```js
import { profile } from 'paskia'
const result = await profile()
if (result !== 'back') // Refresh application state
```
When signed out, it presents the login flow and returns `'login'` on success. When signed in, it shows the profile and returns `'logout'` after logout. `'back'` is returned when the dialog is closed without an expected session change.
Authentication and profile dialogs follow the user's theme override when set in profile, otherwise the host page's light/dark `color-scheme` to remain in the application's color scheme, then the browser/OS preference.
### Lower-level Authentication
`apiFetch` and `apiJson` call `showAuthIframe()` internally. Applications using plain `fetch` or `fetchJson` can call it directly with an authentication URL returned by the backend:
```js
import { showAuthIframe } from 'paskia'
await showAuthIframe(data.auth.iframe)
```
## Session Validation
`SessionValidator` periodically checks that the active Paskia session is still valid and still belongs to the user your application currently has loaded. Validation also refreshes the session to avoid expiry during use.
```js
import { SessionValidator } from 'paskia'
const validator = new SessionValidator(
() => currentUser?.uuid, // getter for current user ID that we track
(error) => handleSessionLost(error) // callback when session is lost
() => currentUser?.uuid, // User ID currently known by your app
error => handleSessionLost(error)
)
validator.start() // call at your app startup/login
validator.stop() // stop the system (optional)
validator.start()
validator.stop()
```
### API Fetch Utilities
The first callback is read on each check, so a logout, expired session or switch to another account invalidates the session your app is currently using. Polling pauses while the user is inactive, avoiding unnecessary traffic and allowing idle sessions to expire.
Enhanced fetch functions with automatic error handling and authentication retry:
## Timeout Settings
```js
import { apiJson, apiFetch } from 'paskia'
// JSON API calls with automatic auth handling
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
// Raw fetch with auth handling
const response = await apiFetch('/api/endpoint')
```
When a 401/403 response includes an auth iframe URL, the request automatically pauses, displays the authentication UI, and retries upon success. In case this is not needed, use standard `fetch` or our `fetchJson`.
The JSON variants set headers automatically, with body and response in JSON.
### Timeout Settings
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
Paskia exports mutable defaults for network and session timers:
```js
import { settings } from 'paskia'
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
settings.fetch_ms = 10000
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
settings.auth_ms = 1000
// SessionValidator polling and idle timers
settings.poll_ms = 60000
settings.idle_ms = 300000
settings.fetch_ms = 10000 // apiFetch, apiJson and fetchJson timeout
settings.auth_ms = 1000 // Session validation request timeout
settings.poll_ms = 60000 // Session validation interval
settings.idle_ms = 300000 // Inactivity before validation pauses
```
You can still override timeout per request:
Request timeout can also be overridden per call:
```js
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
await apiJson('/api/upload', {
method: 'POST',
body: data,
timeout: 30000
})
```
### Authentication Overlay
## Shared Blur Backdrop
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
A shared backdrop provides consistent UX across your application, avoiding different things stacking with their own backdrops and dialogs in unexpected manner.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
Paskia dialogs use a shared blurred backdrop at z-index `1099` and the authentication iframe at `9999`. Application dialogs can use `1100`–`9998` to appear between them.
```js
import { showAuthIframe, AuthCancelledError } from 'paskia'
const response = await fetch('/api/protected')
if (response.status === 401 || response.status === 403) {
const data = await response.json()
if (data.auth?.iframe) {
await showAuthIframe(data.auth.iframe) // Raises AuthCancelledError if the user cancels
}
}
```
This resolves after the user authenticates (possibly with another account than previously), and you should usually retry the original API request. Note that successful authentication doesn't guarantee that the user still has rights to what originally failed.
### Shared Blur Backdrop
The authentication dialog displays with a blur backdrop (z-index 1099). The auth iframe uses z-index 9999. Your app dialogs should use z-index 1100–9998 to appear above the backdrop but below authentication.
The backdrop is also reusable/refcounted, so you can keep consistent visuals for your own dialogs:
The same refcounted backdrop can be used by application UI:
```js
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
@@ -125,31 +136,26 @@ try {
}
```
The backdrop only disappears after all holders have released it.
It disappears after all holders release it, also avoiding awkward fade/appear animations when changing between multiple dialogs.
## Error Handling
### AuthCancelledError (apiFetch, apiJson, showAuthIframe)
### `AuthCancelledError`
If the user clicks Back in the authentication dialog, refusing to authenticate, `AuthCancelledError` is risen (as a response to postMessage from the iframe). The dialog closes as expected and it is up to the app how to continue from there.
`apiFetch`, `apiJson` and `showAuthIframe` raise `AuthCancelledError` when the user cancels required authentication with Back or Escape. This means the user does not wish to authenticate, and should not be asked again.
- Do nothing if the app can continue despite the failed operation (no UI notification needed)
- Display a simple Access Denied page with suggestion/button to reload the page to try again
Continue without the failed operation when possible, or show an appropriate terminal view when authentication is required to continue.
Do not retry automatically.
### UI feedback
A set of small utilities are available for determining whether the user needs a notification and to format the error message.
When the error is a direct result of a user action, we don't want to show an additional message for that, while in other situations we should. Helpers determine whether an error needs user notification and provide a suitable message:
```js
import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia'
try {
await apiJson('/api/action')
} catch (e) {
if (shouldShowErrorToast(e)) {
your.message.display(getUserFriendlyErrorMessage(e))
} catch (error) {
if (shouldShowErrorToast(error)) {
your.message.display(getUserFriendlyErrorMessage(error))
}
}
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paskia",
"version": "1.4.0",
"version": "2.1.0",
"description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko",
"license": "Unlicense",
+1
View File
@@ -20,6 +20,7 @@ export {
isAuthIframeOpen,
hideAuthIframe,
showAuthIframe,
profile,
} from './overlay'
export { SessionValidator } from './validate'
+79 -5
View File
@@ -32,12 +32,27 @@ body.paskia-backdrop {
color-scheme: auto;
background: transparent;
}
#${AUTH_IFRAME_ID}.paskia-dialog {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(36rem, 100%);
height: min(42rem, 100%);
}
`
type DialogResult = 'login' | 'logout' | 'back'
type DialogKind = 'auth' | 'profile'
let authIframe: HTMLIFrameElement | null = null
let authPromise: Promise<void> | null = null
let authResolve: (() => void) | null = null
let authPromise: Promise<DialogResult | undefined> | null = null
let authResolve: ((result?: DialogResult) => void) | null = null
let authReject: ((error: Error) => void) | null = null
// Auth flows reject AuthCancelledError on auth-back (callers rely on it to
// abort request retries) and resolve void on auth-success. The profile dialog
// never rejects: auth-back resolves 'back', and auth-success (the user logged
// in while the profile dialog was open) resolves 'login'.
let dialogKind: DialogKind = 'auth'
let messageListenerInstalled = false
let backdropHolders = 0
@@ -89,7 +104,7 @@ function handleAuthMessage(event: MessageEvent): void {
case 'auth-success':
hideAuthIframe()
if (authResolve) {
authResolve()
authResolve(dialogKind === 'profile' ? 'login' : undefined)
authPromise = null
authResolve = null
authReject = null
@@ -98,8 +113,20 @@ function handleAuthMessage(event: MessageEvent): void {
case 'auth-back':
hideAuthIframe()
if (authReject) {
if (dialogKind === 'auth' && authReject) {
authReject(new AuthCancelledError())
} else if (authResolve) {
authResolve('back')
}
authPromise = null
authResolve = null
authReject = null
break
case 'auth-logout':
hideAuthIframe()
if (authResolve) {
authResolve('logout')
authPromise = null
authResolve = null
authReject = null
@@ -116,12 +143,15 @@ function ensureMessageListener(): void {
}
}
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
function openIframe(iframeUrl: string, title: string, kind: DialogKind): Promise<DialogResult | undefined> {
injectStyles()
ensureMessageListener()
if (authPromise) return authPromise
dialogKind = kind
iframeUrl = withAppTheme(iframeUrl)
if (document.getElementById(AUTH_IFRAME_ID)) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
@@ -140,6 +170,7 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro
authIframe = document.createElement('iframe')
authIframe.id = AUTH_IFRAME_ID
if (kind === 'profile') authIframe.classList.add('paskia-dialog')
authIframe.title = title
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
@@ -147,6 +178,49 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro
return authPromise
}
// Detect the host page's own color scheme (CSS color-scheme on body) as an
// implicit app-level default. Only an unambiguous 'light' or 'dark' counts;
// 'normal', 'light dark' etc. mean the page adapts, so no override is needed.
function detectColorScheme(): string {
if (typeof window === 'undefined' || !document.body) return ''
const scheme = getComputedStyle(document.body).colorScheme
return scheme === 'light' || scheme === 'dark' ? scheme : ''
}
// Apply the host page's own color scheme to the iframe URL hash — only when
// the URL has no theme parameter yet (a server-provided user theme override
// is authoritative). The restricted UI's precedence is: URL parameter (user
// override from the server, else host color scheme) > cached profile theme
// (localStorage) > browser/desktop default.
function withAppTheme(iframeUrl: string): string {
const theme = detectColorScheme()
if (!theme) return iframeUrl
const hashIndex = iframeUrl.indexOf('#')
const base = hashIndex === -1 ? iframeUrl : iframeUrl.slice(0, hashIndex)
const params = new URLSearchParams(hashIndex === -1 ? '' : iframeUrl.slice(hashIndex + 1))
if (params.has('theme')) return iframeUrl
params.set('theme', theme)
return `${base}#${params}`
}
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
return openIframe(iframeUrl, title, 'auth').then(() => undefined)
}
/**
* Show the minimal profile of the logged-in user in a compact dialog iframe.
*
* Unlike the auth flows, this always resolves — 'login' when the user was
* signed out and completed the login flow inside the frame, 'logout' when
* they signed out inside the frame, 'back' when they closed it otherwise.
* The caller decides from context how to react to each (e.g. whether to
* start a new login attempt with showAuthIframe).
*/
export function profile(): Promise<DialogResult> {
return openIframe('/auth/restricted/iframe#mode=profile', 'Profile', 'profile')
.then((result) => result ?? 'back')
}
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
injectStyles()
const existing = document.getElementById(AUTH_IFRAME_ID)
+104 -29
View File
@@ -5,10 +5,11 @@ import os
import sys
from pathlib import Path
import msgspec
from fastapi_vue import server
from fastapi_vue import env, server, teleport
from fastapi_vue.logging import setup_logging
from kanta import Kanta
import paskia.db.operations as ops_db
from paskia.db import legacy
from paskia.db.bootstrap import bootstrap, log_reset_link
from paskia.db.paths import db_file_path
@@ -17,13 +18,18 @@ from paskia.domains import build as build_registry
from paskia.domains import configure as configure_domains
from paskia.domains import validate_config
from paskia.util import hostutil, startupbox
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.runtime import ServeConfig
from paskia.util.runtime import serve_config
# Keep the literal value here: fastapi-vue-setup reads DEFAULT_PORT from
# this module on upgrades. The app-side shared copy is paskia.util.constants.
DEFAULT_PORT = 4401
os.environ["FASTAPI_VUE"] = "PASKIA"
EPILOG = """\
Examples:
paskia init example.com "Example Corporation"
paskia migrate example.com
paskia satellite wss://key@auth.example.com
paskia --listen 4402 --save
paskia
"""
@@ -186,16 +192,65 @@ def cmd_migrate(args: argparse.Namespace) -> None:
print(f"✅ {action} {db_file_path()} (domains: {', '.join(rp_ids)})")
def _save_listen(db_path: Path, listen: list[str] | None) -> None:
"""Persist the listen endpoints to the stored configuration."""
kanta = Kanta(str(db_path), DB())
def cmd_satellite(args: argparse.Namespace) -> None:
"""Register this instance as a satellite using an upstream's link URL.
async def _write() -> None:
async with kanta:
with kanta.transaction("serve:save_listen"):
kanta.data.config.listen = listen
The one setup step for a satellite-only installation: with no database
yet, one is created first (default localhost domain and admin
bootstrap, like 'paskia init'). The registration handshake runs right
here — the URL's initial key is spent, the permanent link credential
and the pushed domains are stored. Afterwards 'paskia' serves the
linked domains.
"""
from paskia import satellite # noqa: PLC0415 (heavy deps: fastapi, websockets)
asyncio.run(_write())
db_path = db_file_path()
fresh = not db_path.exists()
# A Kanta of our own, bound to the global db object so db operations
# (register_uplink's commits) write through it — like lifecycle's, but
# constructed here so the path follows the current directory.
kanta = Kanta(str(db_path), ops_db._db)
ops_db._db._store = kanta
result = {}
if fresh:
# Satellite-only bootstrap: default config (localhost domain) and
# the standard admin seed.
@kanta.bootstrap
def _bootstrap(data: DB) -> None:
result["passphrase"] = bootstrap(data, config=Config())
async def _register():
try:
await kanta.open()
except Exception as e:
raise SystemExit(
f"Could not open {db_path} (is a server running?): {e}"
) from e
try:
return await satellite.register_uplink(args.url)
finally:
await kanta.close()
try:
info = asyncio.run(_register())
except (ValueError, RuntimeError) as e:
raise SystemExit(f"Registration failed: {e}") from e
domains_list = (
", ".join(info["domains"])
or "(none yet — the upstream can scope the link later)"
)
print(f"🛰 Linked to {info['url']} — serving: {domains_list}")
for rp_id, reason in (info.get("rejected") or {}).items():
print(f"⚠️ Domain {rp_id} rejected: {reason}")
if fresh:
print("Created the database. Serve with: paskia")
configure_domains(listen=None)
registry = build_registry(_load_stored_config(db_path))
log_reset_link(
registry.get("localhost").reset_link_url(result["passphrase"]),
"Admin registration (for the local admin interface):",
)
def cmd_serve(args: argparse.Namespace) -> None:
@@ -210,14 +265,20 @@ def cmd_serve(args: argparse.Namespace) -> None:
)
raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
if args.save and args.listen is not None:
# '--listen ""' clears the stored endpoints (back to the default)
_save_listen(db_path, _split_multi(args.listen) or None)
config = _load_stored_config(db_path)
listen = _split_multi(args.listen) or config.listen
configure_domains(listen=listen)
# Effective serve parameters, teleported to the server process(es); the
# app persists the listen endpoints to the database when save is set.
cfg = serve_config()
cfg.save = bool(args.save and args.listen is not None)
if cfg.save:
# '--listen ""' clears the stored endpoints (back to the default)
cfg.listen = _split_multi(args.listen) or None
else:
cfg.listen = _split_multi(args.listen) or config.listen
teleport() # Serialize bound config before spawning workers
configure_domains(listen=cfg.listen)
try:
registry = build_registry(config)
except ValueError as e:
@@ -225,29 +286,26 @@ def cmd_serve(args: argparse.Namespace) -> None:
# Sanitization warnings (serving is best-effort; fixing the stored config
# is the admin's job via the admin interface) are logged by build().
# Pass process-global serve parameters to the server process(es)
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(
ServeConfig(listen=listen)
).decode()
startupbox.print_startup_config(registry, listen=listen)
startupbox.print_startup_config(
registry, listen=cfg.listen, default_port=DEFAULT_PORT
)
# Run the server (spawns processes in dev mode)
# tracerite, access logging and log config are handled by fastapi_vue.server;
# we print our own startup config box, so disable the built-in one.
server.run(
"paskia.fastapi.mainapp:app",
listen=listen,
listen=cfg.listen,
default_port=DEFAULT_PORT,
server_header=False,
startup_box=None,
reload=Path(__file__).parent if DEVMODE else False,
reload=Path(__file__).parent if env.dev else False,
)
def main():
# Configure logging to remove the "ERROR:root:" prefix
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
# Full logging setup (tracerite, formatting) before any CLI output
setup_logging()
parser = argparse.ArgumentParser(
prog="paskia",
@@ -260,7 +318,7 @@ def main():
"--save",
action="store_true",
help="Save --listen to the database for future runs. "
"Use --listen \"\" to clear the stored endpoints.",
'Use --listen "" to clear the stored endpoints.',
)
init_parser = argparse.ArgumentParser(
@@ -302,11 +360,28 @@ def main():
"in the current directory is selected automatically.",
)
satellite_parser = argparse.ArgumentParser(
prog="paskia satellite",
description="Register this instance as a satellite of an upstream "
"paskia instance, using the link URL minted in the upstream's admin "
"interface (Satellites panel). The registration handshake runs "
"immediately; afterwards 'paskia' serves the linked domains. With no "
"database yet, one is created first — this is the whole setup of a "
"satellite-only installation.",
)
satellite_parser.add_argument(
"url",
help="The link URL (wss://<key>@<auth-host>) from the upstream's "
"Satellites panel. Single-use; valid for 10 minutes from minting.",
)
argv = sys.argv[1:]
if argv and argv[0] == "init":
cmd_init(init_parser.parse_args(argv[1:]))
elif argv and argv[0] == "migrate":
cmd_migrate(migrate_parser.parse_args(argv[1:]))
elif argv and argv[0] == "satellite":
cmd_satellite(satellite_parser.parse_args(argv[1:]))
else:
cmd_serve(parser.parse_args(argv))
+9 -2
View File
@@ -24,8 +24,15 @@ EXPIRES = SESSION_LIFETIME
def session_ctx(auth: str, host: str | None = None):
"""Get session context with normalized host."""
return db.data().session_ctx(auth, hostutil.normalize_host(host))
"""Get session context with normalized host.
The store is dispatched by host: remote domains read their replica.
"""
from paskia import satellite # noqa: PLC0415 (import cycle)
return satellite.store_for_host(host).session_ctx(
auth, hostutil.normalize_host(host)
)
def expires() -> datetime:
+4 -1
View File
@@ -67,7 +67,10 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials on any configured domain
admin_user = admin_users[0]
reg = domains.registry()
configured = sorted(d.rp_id for d in reg.domains)
# Remote domains hold their credentials on the remote instance
configured = sorted(d.rp_id for d in reg.domains if d.remote is None)
if not configured:
return False
if not any(admin_user.credential_ids_for(rp_id) for rp_id in configured):
# Admin exists but has no credential on any domain
+18
View File
@@ -23,6 +23,7 @@ from paskia.db.bootstrap import bootstrap
from paskia.db.operations import (
add_permission_to_org,
add_permission_to_role,
apply_uplink_domains,
create_credential,
create_credential_session,
create_domain,
@@ -31,6 +32,8 @@ from paskia.db.operations import (
create_permission,
create_reset_token,
create_role,
create_satellite_link,
create_uplink,
create_user,
delete_credential,
delete_domain,
@@ -38,8 +41,10 @@ from paskia.db.operations import (
delete_org,
delete_permission,
delete_role,
delete_satellite_link,
delete_session,
delete_sessions_for_user,
delete_uplink,
delete_user,
is_username_taken,
login,
@@ -47,6 +52,7 @@ from paskia.db.operations import (
remove_permission_from_org,
remove_permission_from_role,
reset_oid_client_secret,
set_satellite_link_scope,
update_credential_sign_count,
update_domain,
update_oid_client,
@@ -67,10 +73,13 @@ from paskia.db.structs import (
DomainConfig,
Org,
Permission,
RemoteConfig,
ResetToken,
Role,
SatelliteLink,
Session,
SessionContext,
Uplink,
User,
)
@@ -90,10 +99,13 @@ __all__ = [
"Org",
"Permission",
"DomainConfig",
"RemoteConfig",
"ResetToken",
"Role",
"SatelliteLink",
"Session",
"SessionContext",
"Uplink",
"User",
# Instance
"data",
@@ -101,6 +113,7 @@ __all__ = [
# Write ops
"add_permission_to_org",
"add_permission_to_role",
"apply_uplink_domains",
"bootstrap",
"create_credential",
"create_credential_session",
@@ -109,19 +122,24 @@ __all__ = [
"create_domain",
"create_reset_token",
"create_role",
"create_satellite_link",
"create_uplink",
"create_user",
"delete_credential",
"delete_org",
"delete_permission",
"delete_domain",
"delete_role",
"delete_satellite_link",
"delete_session",
"delete_sessions_for_user",
"delete_uplink",
"delete_user",
"login",
"oidc_login",
"remove_permission_from_org",
"remove_permission_from_role",
"set_satellite_link_scope",
"update_credential_sign_count",
"update_org_name",
"update_permission",
+3 -2
View File
@@ -128,9 +128,10 @@ def _legacy_to_db(old: LegacyDB) -> DB:
origins[origin_key(origin)] = True
if old.config.auth_host:
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
if not origins:
if not old.config.origins:
# Legacy semantics: no origins configured = the whole rp-id domain
# allowed. The new format requires explicit entries.
# allowed, regardless of a dedicated auth host. The new format
# requires explicit entries.
origins[f"**.{rp_id}"] = True
new_config = Config(
+5 -4
View File
@@ -86,8 +86,9 @@ def _resolve_uuid_label(
return None
# The OIDC signing key is stored at oidc.key.
_OIDC_KEY_PATH = re.compile(r"^oidc\.key$")
# The OIDC signing key is stored at oidc.key; satellite link key material
# at uplinks/satellite_links.<id>.key.
_SECRET_KEY_PATH = re.compile(r"^(oidc\.key|(uplinks|satellite_links)\.[^.]+\.key)$")
@kanta.logfmt
@@ -98,10 +99,10 @@ def format_log_uuid(
current: Annotated[dict, "post"] | None = None,
) -> Optional[str]: # noqa: UP045
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
# Censor sensitive OIDC key material regardless of value type, but only
# Censor sensitive key material regardless of value type, but only
# when formatting the value: path components are passed with the component
# itself as value and must stay visible ("oidc.key = <hidden>").
if _OIDC_KEY_PATH.fullmatch(path) and value != "key":
if _SECRET_KEY_PATH.fullmatch(path) and value != "key":
return "<hidden>"
if not isinstance(value, str):
+113 -3
View File
@@ -13,7 +13,7 @@ from uuid import UUID
import uuid7
from paskia import oidc_notify
from paskia import oidc_notify, syncfeed
from paskia.config import SESSION_LIFETIME
from paskia.db.structs import (
DB,
@@ -25,8 +25,10 @@ from paskia.db.structs import (
Permission,
ResetToken,
Role,
SatelliteLink,
Session,
SessionContext,
Uplink,
User,
)
from paskia.util.crypto import hash_secret
@@ -103,6 +105,7 @@ def update_permission(
_db.permissions[uuid].scope = scope
_db.permissions[uuid].display_name = display_name
_db.permissions[uuid].domain = domain
syncfeed.emit("permissions", str(uuid), _db.permissions[uuid])
def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
@@ -155,6 +158,7 @@ def update_org_name(
raise ValueError(f"Organization {uuid} not found")
with _transaction("admin:update_org_name", ctx):
_db.orgs[uuid].display_name = display_name
syncfeed.emit("orgs", str(uuid), _db.orgs[uuid])
def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
@@ -180,6 +184,9 @@ def add_permission_to_org(
with _transaction("admin:add_permission_to_org", ctx):
_db.permissions[permission_uuid].orgs[org_uuid] = True
syncfeed.emit(
"permissions", str(permission_uuid), _db.permissions[permission_uuid]
)
def remove_permission_from_org(
@@ -197,6 +204,9 @@ def remove_permission_from_org(
with _transaction("admin:remove_permission_from_org", ctx):
_db.permissions[permission_uuid].orgs.pop(org_uuid, None)
syncfeed.emit(
"permissions", str(permission_uuid), _db.permissions[permission_uuid]
)
def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
@@ -220,6 +230,7 @@ def update_role_name(
raise ValueError(f"Role {uuid} not found")
with _transaction("admin:update_role_name", ctx):
_db.roles[uuid].display_name = display_name
syncfeed.emit("roles", str(uuid), _db.roles[uuid])
def add_permission_to_role(
@@ -235,6 +246,7 @@ def add_permission_to_role(
raise ValueError(f"Permission {permission_uuid} not found")
with _transaction("admin:add_permission_to_role", ctx):
_db.roles[role_uuid].permissions[permission_uuid] = True
syncfeed.emit("roles", str(role_uuid), _db.roles[role_uuid])
def remove_permission_from_role(
@@ -248,6 +260,7 @@ def remove_permission_from_role(
raise ValueError(f"Role {role_uuid} not found")
with _transaction("admin:remove_permission_from_role", ctx):
_db.roles[role_uuid].permissions.pop(permission_uuid, None)
syncfeed.emit("roles", str(role_uuid), _db.roles[role_uuid])
def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
@@ -302,6 +315,7 @@ def update_user_display_name(
slug = slugify_name(display_name)
if slug and not is_username_taken(slug, exclude_uuid=uuid):
user.preferred_username = slug
syncfeed.emit("users", str(uuid), user)
def update_user_info(
@@ -380,6 +394,7 @@ def update_user_info(
user.preferred_username = preferred_username
if telephone is not _UNSET:
user.telephone = telephone
syncfeed.emit("users", str(uuid), user)
def update_user_role(
@@ -395,6 +410,7 @@ def update_user_role(
raise ValueError(f"Role {role_uuid} not found")
with _transaction("admin:update_user_role", ctx):
_db.users[uuid].role_uuid = role_uuid
syncfeed.emit("users", str(uuid), _db.users[uuid])
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
@@ -429,6 +445,7 @@ def update_credential_sign_count(
_db.credentials[uuid].sign_count = sign_count
if last_used:
_db.credentials[uuid].last_used = last_used
syncfeed.emit("credentials", str(uuid), _db.credentials[uuid])
def delete_credential(
@@ -476,6 +493,7 @@ def update_session(
s.validated = validated
if issuer is not None:
s.issuer = issuer
syncfeed.emit("sessions", key, s)
def delete_session(
@@ -546,6 +564,79 @@ def create_reset_token(
return passphrase
def create_satellite_link(
link_id: str,
rp_ids: list[str],
key: bytes,
label: str = "",
*,
ctx: SessionContext | None = None,
) -> None:
"""Store a freshly registered satellite link under its handshake id.
Called by the registration handshake once the satellite has confirmed
(its ``ready`` frame): the permanent credential ``key`` exists only
from this point.
"""
with _transaction("satellite:register", ctx):
_db.satellite_links[link_id] = SatelliteLink(
rp_ids=list(rp_ids), key=key, label=label, created=datetime.now(UTC)
)
def set_satellite_link_scope(
link_id: str, rp_ids: list[str], *, ctx: SessionContext | None = None
) -> None:
"""Adjust a link's scope; the new domain set is pushed over the live link."""
link = _db.satellite_links.get(link_id)
if link is None:
raise ValueError("Satellite link not found")
with _transaction("admin:satellite_link_scope", ctx):
link.rp_ids = list(rp_ids)
def delete_satellite_link(link_id: str, *, ctx: SessionContext | None = None) -> None:
"""Revoke a satellite link; its live connections are dropped by the caller."""
if link_id not in _db.satellite_links:
raise ValueError("Satellite link not found")
with _transaction("admin:delete_satellite_link", ctx):
del _db.satellite_links[link_id]
def create_uplink(
uplink_id: str, uplink: Uplink, domains: dict[str, DomainConfig]
) -> None:
"""Registration commit on the satellite: store the uplink and the
domain set the upstream pushed (already validated by the caller)."""
with _transaction("satellite:register"):
_db.uplinks[uplink_id] = uplink
uplink.uplink_id = uplink_id
_db.config.domains.update(domains)
def apply_uplink_domains(uplink_id: str, domains: dict[str, DomainConfig]) -> None:
"""Replace the domain set of an uplink with the upstream's push.
``domains`` is the uplink's full accepted set (additions, updates and
removals relative to what the config currently holds for the link);
validation happens in the caller (satellite.apply_uplink_domains).
"""
with _transaction("satellite:domains"):
for rp_id, domain in list(_db.config.domains.items()):
if domain.remote is not None and domain.remote.link == uplink_id:
del _db.config.domains[rp_id]
_db.config.domains.update(domains)
def delete_uplink(uplink_id: str) -> None:
"""Unlink an uplink: drop it and every domain it serves."""
with _transaction("satellite:unlink"):
_db.uplinks.pop(uplink_id, None)
for rp_id, domain in list(_db.config.domains.items()):
if domain.remote is not None and domain.remote.link == uplink_id:
del _db.config.domains[rp_id]
# -------------------------------------------------------------------------
# Composite operations (used by app code)
# -------------------------------------------------------------------------
@@ -598,6 +689,9 @@ def login(
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
syncfeed.emit(
"credentials", str(credential_uuid), _db.credentials[credential_uuid]
)
return token
@@ -625,6 +719,9 @@ def oidc_login(
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
syncfeed.emit(
"credentials", str(credential_uuid), _db.credentials[credential_uuid]
)
def create_credential_session(
@@ -720,20 +817,33 @@ def update_domain(
The rp-id itself is immutable: credentials are stamped with it, so
changing it would orphan them — delete and recreate the domain instead.
The caller must validate the resulting combined configuration.
The caller must validate the resulting combined configuration. Remote
(satellite-served) domains are uplink-managed and never pass through
here.
"""
domain = _db.config.domains.get(rp_id)
if domain is None:
raise ValueError(f"Domain {rp_id} not found")
if domain.remote is not None:
raise ValueError(
f"Domain '{rp_id}' is served from an uplink — managed there, "
"not by local edits"
)
with _transaction("admin:update_domain", ctx):
domain.rp_name = rp_name
domain.origins = origins
def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
"""Delete a domain. Refused for the last domain or while credentials remain."""
"""Delete a domain. Refused for the last domain, while credentials
remain, or for remote domains (those are uplink-managed — unlink or
have the upstream narrow the link's scope)."""
if rp_id not in _db.config.domains:
raise ValueError(f"Domain {rp_id} not found")
if _db.config.domains[rp_id].remote is not None:
raise ValueError(
f"Domain '{rp_id}' is served from an uplink — unlink it instead"
)
if len(_db.config.domains) <= 1:
raise ValueError("Cannot delete the last remaining domain")
if any(c.rp_id == rp_id for c in _db.credentials.values()):
+106 -11
View File
@@ -9,7 +9,7 @@ from uuid import UUID
import msgspec
import uuid7
from paskia import db
from paskia import db, syncfeed
from paskia.util import passphrase as passphrase_util
from paskia.util.crypto import hash_secret
@@ -51,6 +51,7 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
def store(self) -> None:
"""Store this permission in the database. Must be called inside a transaction."""
db.data().permissions[self.uuid] = self
syncfeed.emit("permissions", str(self.uuid), self)
def delete(self) -> None:
"""Delete this permission and remove it from all roles.
@@ -59,8 +60,10 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
"""
_data = db.data()
for role in _data.roles.values():
role.permissions.pop(self.uuid, None)
if role.permissions.pop(self.uuid, None) is not None:
syncfeed.emit("roles", str(role.uuid), role)
del _data.permissions[self.uuid]
syncfeed.emit("permissions", str(self.uuid), None)
@classmethod
def create(
@@ -103,6 +106,7 @@ class Org(msgspec.Struct, dict=True):
def store(self) -> None:
"""Store this organization in the database. Must be called inside a transaction."""
db.data().orgs[self.uuid] = self
syncfeed.emit("orgs", str(self.uuid), self)
def delete(self) -> None:
"""Delete this org and cascade to roles, users. Remove from permissions.
@@ -111,12 +115,16 @@ class Org(msgspec.Struct, dict=True):
"""
_data = db.data()
for p in _data.permissions.values():
p.orgs.pop(self.uuid, None)
if p.orgs.pop(self.uuid, None) is not None:
syncfeed.emit("permissions", str(p.uuid), p)
for role in self.roles:
for user in role.users:
del _data.users[user.uuid]
syncfeed.emit("users", str(user.uuid), None)
del _data.roles[role.uuid]
syncfeed.emit("roles", str(role.uuid), None)
del _data.orgs[self.uuid]
syncfeed.emit("orgs", str(self.uuid), None)
@classmethod
def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
@@ -170,10 +178,12 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
def store(self) -> None:
"""Store this role in the database. Must be called inside a transaction."""
db.data().roles[self.uuid] = self
syncfeed.emit("roles", str(self.uuid), self)
def delete(self) -> None:
"""Delete this role from the database. Must be called inside a transaction."""
del db.data().roles[self.uuid]
syncfeed.emit("roles", str(self.uuid), None)
@classmethod
def create(
@@ -254,6 +264,7 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
def store(self) -> None:
"""Store this user in the database. Must be called inside a transaction."""
db.data().users[self.uuid] = self
syncfeed.emit("users", str(self.uuid), self)
def delete(self) -> None:
"""Delete this user and cascade to credentials, sessions, reset tokens.
@@ -263,11 +274,14 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
_data = db.data()
for cred in self.credentials:
del _data.credentials[cred.uuid]
syncfeed.emit("credentials", str(cred.uuid), None)
for sess in self.sessions:
del _data.sessions[sess.key]
syncfeed.emit("sessions", sess.key, None)
for token in self.reset_tokens:
del _data.reset_tokens[token.key]
del _data.users[self.uuid]
syncfeed.emit("users", str(self.uuid), None)
@classmethod
def create(
@@ -331,6 +345,7 @@ class Credential(msgspec.Struct, dict=True):
def store(self) -> None:
"""Store this credential in the database. Must be called inside a transaction."""
db.data().credentials[self.uuid] = self
syncfeed.emit("credentials", str(self.uuid), self)
def delete(self) -> None:
"""Delete this credential and all its sessions.
@@ -340,7 +355,9 @@ class Credential(msgspec.Struct, dict=True):
_data = db.data()
for sess in self.sessions:
del _data.sessions[sess.key]
syncfeed.emit("sessions", sess.key, None)
del _data.credentials[self.uuid]
syncfeed.emit("credentials", str(self.uuid), None)
@classmethod
def create(
@@ -411,17 +428,19 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
def store(self, last_seen: datetime) -> None:
"""Store this session in the database and record a visit.
Updates user.last_seen and user.visits. Must be called inside
a database transaction.
Updates user.last_seen and user.visits (instance-local stats, not
replicated). Must be called inside a database transaction.
"""
_data = db.data()
_data.sessions[self.key] = self
_data.users[self.user_uuid].last_seen = last_seen
_data.users[self.user_uuid].visits += 1
syncfeed.emit("sessions", self.key, self)
def delete(self) -> None:
"""Delete this session from the database. Must be called inside a transaction."""
del db.data().sessions[self.key]
syncfeed.emit("sessions", self.key, None)
@classmethod
def create(
@@ -546,6 +565,52 @@ class ResetToken(msgspec.Struct, dict=True):
return token, passphrase
class SatelliteLink(msgspec.Struct, dict=True, omit_defaults=True):
"""A registered satellite instance — the upstream side of a link.
Created by the online registration handshake (fastapi/sync.py): the
initial key that bootstrapped the link was RAM-only and short-lived;
this permanent credential is created during registration. ``key`` is
the cryptographic material for talking to the peer (placeholder: random
parts exchanged at registration; the final version derives channel
encryption from the handshake instead of sending key material).
``rp_ids`` is the link's scope: the domains the satellite serves.
Adjustable at any time and pushed over the live link — no new
cryptographic setup is needed to change it. The sync feed carries only
in-scope sessions (each with its identity context) and credentials;
the user graph never replicates. Instance-local: never replicated.
"""
created: datetime
rp_ids: list[str] = [] # the link's scope — adjust freely, pushed live
key: bytes = b"" # shared cryptographic material from the handshake
label: str = "" # the satellite's self-reported name (informational)
class Uplink(msgspec.Struct, dict=True, omit_defaults=True):
"""The satellite side of a link: the upstream this instance follows.
Created by the same registration handshake; ``key`` is the shared
cryptographic material. ``url`` is the upstream's auth host base
(http(s)); the sync channel connects to its ``/auth/api/sync/ws``.
The domains this uplink serves live in ``config.domains`` marked with
``RemoteConfig(link=<uplink id>)`` — pushed and maintained by the
upstream, never edited locally. ``uplink_id`` is the dict key, set on
load.
"""
url: str # upstream auth host base, e.g. "https://auth.example.com"
created: datetime
key: bytes = b"" # shared cryptographic material from the handshake
cache_ttl: int = 60 # staleness bound (seconds) while the channel is down
refresh_interval: int = 300 # full re-sync cadence (seconds)
def __post_init__(self):
if not hasattr(self, "uplink_id"):
self.uplink_id: str = ""
# -------------------------------------------------------------------------
# OIDC Provider structures
# -------------------------------------------------------------------------
@@ -622,6 +687,21 @@ class OriginEntry(msgspec.Struct, omit_defaults=True):
auth_host: bool = False # This site hosts the account/admin interface
class RemoteConfig(msgspec.Struct, omit_defaults=True):
"""Marks a domain as satellite-served via an uplink (a ``db.uplinks``
dict key).
The satellite keeps a RAM-only read replica fed by the uplink and
answers session-dependent reads locally; mutations are forwarded. The
domain's rp-name, origins and auth host are pushed by the upstream and
applied verbatim — the satellite only validates them against its local
domains. Local edits are rejected; removing the domain means unlinking
the uplink or the upstream narrowing the link's scope.
"""
link: str = ""
class DomainConfig(msgspec.Struct, omit_defaults=True):
"""Configuration for one domain (one WebAuthn rp-id).
@@ -641,6 +721,7 @@ class DomainConfig(msgspec.Struct, omit_defaults=True):
rp_name: str | None = None
origins: dict[str, bool | OriginEntry] = {}
remote: RemoteConfig | None = None
class Config(msgspec.Struct, omit_defaults=True):
@@ -674,6 +755,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {}
reset_tokens: dict[str, ResetToken] = {}
satellite_links: dict[str, SatelliteLink] = {} # upstream side of links
uplinks: dict[str, Uplink] = {} # satellite side of links
# OIDC provider data: one instance-global provider (single signing key
# and client set); each request Host acts as an issuer alias.
oidc: OIDC = msgspec.field(default_factory=OIDC)
@@ -696,6 +779,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
session.key = key
for key, token in self.reset_tokens.items():
token.key = key
for uplink_id, uplink in self.uplinks.items():
uplink.uplink_id = uplink_id
# OIDC
for uuid, client in self.oidc.clients.items():
client.uuid = uuid
@@ -713,13 +798,19 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
Returns:
SessionContext if valid, None if session not found, expired, or host mismatch
"""
key = hash_secret("cookie", session_secret)
try:
s = self.sessions[key]
except KeyError:
return None
return self.session_ctx_for(s, host)
def session_ctx_for(self, s: Session, host: str | None) -> SessionContext | None:
"""Full session context for a session object: host binding, then the
user/role/org/credential join with effective permissions. Sync
replicas override this to source the identity from the per-session
contexts the master pushes instead of local user tables.
"""
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
if s.client_uuid is not None:
return None
@@ -728,17 +819,21 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
if s.host != host:
return None
# Look up via this instance's own tables: a DB must be
# self-contained so that read replicas work unchanged.
try:
user = s.user
role = user.role
org = role.org
credential = s.credential
user = self.users[s.user_uuid]
role = self.roles[user.role_uuid]
org = self.orgs[role.org_uuid]
credential = self.credentials[s.credential_uuid]
except KeyError:
return None
# Effective permissions: role's permissions that the org can grant,
# filtered by domain restriction
org_perm_uuids = {p.uuid for p in org.permissions}
org_perm_uuids = {
p.uuid for p in self.permissions.values() if org.uuid in p.orgs
}
effective_perms = []
for perm_uuid in role.permission_set:
+30 -2
View File
@@ -20,7 +20,7 @@ import os
from fastapi_vue.hostutil import parse_endpoints
from paskia.db.structs import Config, DomainConfig, OriginEntry
from paskia.db.structs import Config, DomainConfig, OriginEntry, RemoteConfig
from paskia.sansio import Passkey
from paskia.util import hostutil
from paskia.util.constants import DEFAULT_PORT
@@ -101,6 +101,11 @@ class Domain:
def rp_name(self) -> str:
return self.passkey.rp_name
@property
def remote(self) -> RemoteConfig | None:
"""Upstream config when this domain is served as a satellite."""
return self.config.remote
@property
def own_auth_host(self) -> str | None:
"""This domain's own auth host as host[:port], if configured."""
@@ -273,6 +278,11 @@ def validate_config(
f"Domain '{rp_id}' has {related_count} related origins "
f"(maximum {related_origin_cap})"
)
if domain.remote is not None and domain_auth_host is None:
raise ValueError(
f"Domain '{rp_id}' is remote — it must mark an auth host "
"(profile, admin and sign-in pages live there)"
)
rp_ids = set(config.domains)
for hn, owner in auth_hosts.items():
@@ -363,6 +373,22 @@ def sanitize_config(
auth_seen = True
origins[key] = props
if domain.remote is not None:
if not domain.remote.link:
# Never valid (a v1 remote config decodes to this) — degrade
# to a plain local domain rather than failing to serve.
warn(f"Domain '{rp_id}': remote without an uplink — remote dropped")
remote = None
else:
remote = domain.remote
if not auth_seen:
warn(
f"Domain '{rp_id}': remote domain without an auth host — "
"profile, admin and sign-in pages have nowhere to live"
)
else:
remote = None
related = sorted(k for k in origins if is_related_key(rp_id, k))
if len(related) > related_origin_cap:
warn(
@@ -372,7 +398,9 @@ def sanitize_config(
for key in related[related_origin_cap:]:
del origins[key]
domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins)
domains[rp_id] = DomainConfig(
rp_name=domain.rp_name, origins=origins, remote=remote
)
if not domains:
raise ValueError("No servable domain in the stored configuration")
+3
View File
@@ -10,6 +10,7 @@ from paskia.fastapi.admin import (
orgs,
permissions,
roles,
satellites,
users,
)
from paskia.fastapi.admin.errors import install_error_handlers
@@ -39,6 +40,8 @@ app.mount("/roles", roles.app)
app.mount("/users", users.app)
app.mount("/permissions", permissions.app)
app.mount("/domains", domains.app)
app.mount("/satellites", satellites.links_app)
app.mount("/uplinks", satellites.uplinks_app)
def master_admin(ctx) -> bool:
+29 -4
View File
@@ -7,13 +7,17 @@ origins on unrelated domains (WebAuthn Related Origin Requests). All
changes are validated cross-domain before being persisted, and the runtime
domain registry is rebuilt after each change so it takes effect
immediately.
Remote (satellite-served) domains are uplink-managed — the upstream pushes
their configuration; they cannot be created, edited or deleted here. Domain
changes are pushed live to the satellite links serving them.
"""
from fastapi import Body, FastAPI, Request
from paskia import db, domains
from paskia.db.structs import Config, DomainConfig, OriginEntry
from paskia.fastapi import authz
from paskia.fastapi import authz, sync
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
@@ -34,6 +38,7 @@ def _domain_to_api(domain: domains.Domain) -> ApiDomain:
site_url=domain.site_url,
auth_site_url=domain.auth_site_url,
auth_host=domain.own_auth_host,
remote=domain.config.remote,
)
@@ -61,6 +66,22 @@ def _rebuild_registry() -> None:
domains.init_registry(db.data().config)
def _check_not_remote(payload: dict, rp_id: str | None = None) -> None:
"""Remote domains are uplink-managed: created by registering an uplink
on the satellite, edited and removed by the upstream's push."""
if payload.get("remote") is not None:
raise ValueError(
"Remote domains are managed by satellite uplinks — register the "
"link on the satellite instead (its satellites panel, or the "
"'paskia satellite' command)"
)
if rp_id is not None and db.data().config.domains[rp_id].remote is not None:
raise ValueError(
f"Domain '{rp_id}' is served from an uplink — managed there, "
"not by local edits"
)
def _check_not_locking_self_out(
request: Request,
rp_id: str,
@@ -120,6 +141,7 @@ async def admin_create_domain(
rp_id = (payload.get("rp_id") or "").strip().lower()
if not rp_id:
raise ValueError("rp_id is required")
_check_not_remote(payload)
new = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")),
@@ -144,7 +166,7 @@ async def admin_update_domain(
auth=AUTH_COOKIE,
):
"""Update a domain's rp_name, origins and related origins (replaced
wholesale).
wholesale); links serving the domain are pushed the new configuration.
The rp-id itself is immutable: credentials are stamped with it.
"""
@@ -155,7 +177,7 @@ async def admin_update_domain(
config = db.data().config
if rp_id not in config.domains:
raise ValueError(f"Domain {rp_id} not found")
_check_not_remote(payload, rp_id)
updated = DomainConfig(
rp_name=(payload.get("rp_name") or "").strip() or None,
origins=_normalize_origins_map(payload.get("origins")),
@@ -174,6 +196,7 @@ async def admin_update_domain(
ctx=ctx,
)
_rebuild_registry()
await sync.refresh_links_serving(rp_id)
return {"status": "ok"}
@@ -183,7 +206,8 @@ async def admin_delete_domain(
request: Request,
auth=AUTH_COOKIE,
):
"""Delete a domain (refused for the last domain or while credentials remain)."""
"""Delete a domain (refused for the last domain, remote domains, or
while credentials remain); links serving it lose it from their scope."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
@@ -195,4 +219,5 @@ async def admin_delete_domain(
)
db.delete_domain(rp_id, ctx=ctx)
_rebuild_registry()
await sync.drop_domain_from_links(rp_id, ctx=ctx)
return {"status": "ok"}
+195
View File
@@ -0,0 +1,195 @@
"""Satellite link management API — master admin only, both roles.
**Upstream role** (``/satellites``): mint registration offers (RAM-only,
short-lived, single-use initial keys shown once inside a wss link URL),
list registered links with their live state, adjust a link's scope
(pushed over the live link — registration is once-only) and revoke links
(drops the satellite's connections).
**Satellite role** (``/uplinks``): register an uplink from a pasted link
URL (the online handshake runs synchronously here — the same path the
``paskia satellite`` CLI uses), list uplinks with their live replica
state, and unlink (drops the uplink and every domain it serves).
"""
from fastapi import Body, FastAPI, Request
from paskia import db, domains, satellite
from paskia.fastapi import authz, sync
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
links_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
uplinks_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(links_app)
install_error_handlers(uplinks_app)
def _check_scope(rp_ids) -> list[str]:
"""Validate a link scope: existing authoritative domains with an auth
host (satellite users' account pages live there). Empty is allowed —
register first, scope later."""
clean: list[str] = []
for raw in rp_ids or []:
rp_id = str(raw).strip().lower()
if not rp_id or rp_id in clean:
continue
domain = db.data().config.domains.get(rp_id)
if domain is None:
raise ValueError(f"Domain {rp_id} not found")
if domain.remote is not None:
raise ValueError(
f"Domain '{rp_id}' is itself satellite-served — "
"satellites cannot be nested"
)
if domains.auth_host_url(domain) is None:
raise ValueError(
f"Domain '{rp_id}' must mark an auth host before satellites "
"can serve it"
)
clean.append(rp_id)
return clean
def _base_url(request: Request, rp_ids: list[str]) -> str:
"""The instance URL the link URL points at: the first scoped domain's
auth host, else the host the admin is using. Any host of this instance
serves the sync endpoint — it is not host-dispatched."""
for rp_id in rp_ids:
domain = db.data().config.domains[rp_id]
if url := domains.auth_host_url(domain):
return url
return request.state.domain.site_url
# -------------------------------------------------------------------------
# Upstream role: links to satellites
# -------------------------------------------------------------------------
@links_app.get("/")
async def admin_list_links(request: Request, auth=AUTH_COOKIE):
"""List registered satellite links with their live connection state."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
status = sync.links_status()
return MsgspecResponse(
[
{
"id": link_id,
"label": link.label,
"rp_ids": sorted(link.rp_ids),
"created": link.created,
"connected": link_id in status,
"rejected": status.get(link_id, {}).get("rejected") or {},
}
for link_id, link in db.data().satellite_links.items()
]
)
@links_app.post("/")
async def admin_create_offer(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
"""Mint a registration offer; the link URL is shown once and expires."""
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
rp_ids = _check_scope(payload.get("rp_ids"))
return {
"url": sync.create_offer(rp_ids, _base_url(request, rp_ids)),
"expires_in": sync.OFFER_TTL,
}
@links_app.patch("/{link_id}")
async def admin_update_link_scope(
link_id: str, request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
"""Adjust a link's scope; the new domain set is pushed over the live
link (no new registration)."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
rp_ids = _check_scope(payload.get("rp_ids"))
db.set_satellite_link_scope(link_id, rp_ids, ctx=ctx)
await sync.push_link_domains(link_id)
return {"status": "ok"}
@links_app.delete("/{link_id}")
async def admin_delete_link(link_id: str, request: Request, auth=AUTH_COOKIE):
"""Revoke a satellite link; its live connections are dropped, and the
satellite unlinks its side on the policy close."""
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
db.delete_satellite_link(link_id, ctx=ctx)
await sync.close_link(link_id)
return {"status": "ok"}
# -------------------------------------------------------------------------
# Satellite role: uplinks
# -------------------------------------------------------------------------
def _uplink_domains_of(uplink_id: str) -> list[str]:
return sorted(
rp_id
for rp_id, d in db.data().config.domains.items()
if d.remote is not None and d.remote.link == uplink_id
)
@uplinks_app.get("/")
async def admin_list_uplinks(request: Request, auth=AUTH_COOKIE):
"""List uplinks with the domains they serve and live replica state."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
replicas = satellite.manager.replicas
return MsgspecResponse(
[
{
"id": uplink_id,
"url": uplink.url,
"created": uplink.created,
"domains": _uplink_domains_of(uplink_id),
"connected": bool(
(replica := replicas.get(uplink_id)) and replica.connected
),
"available": bool(
(replica := replicas.get(uplink_id)) and replica.available()
),
}
for uplink_id, uplink in db.data().uplinks.items()
]
)
@uplinks_app.post("/")
async def admin_register_uplink(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
"""Register an uplink from a pasted link URL — the full handshake runs
here, so failures (expired URL, unreachable upstream, rejected domains)
come back as the response."""
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
try:
return await satellite.register_uplink(str(payload.get("url") or ""))
except RuntimeError as e:
raise ValueError(str(e)) from e
@uplinks_app.delete("/{uplink_id}")
async def admin_unlink_uplink(uplink_id: str, request: Request, auth=AUTH_COOKIE):
"""Unlink an uplink: drop it and every domain it serves."""
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
if uplink_id not in db.data().uplinks:
raise ValueError("Uplink not found")
await satellite.manager.unlink(uplink_id)
return {"status": "ok"}
+30 -9
View File
@@ -14,7 +14,7 @@ from fastapi import (
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia import authcode, db, satellite
from paskia._version import __version__
from paskia.authsession import EXPIRES, get_reset, session_ctx
from paskia.domains import current_domain
@@ -122,8 +122,9 @@ async def validate_token(
if auth and renew:
consumed = datetime.now(UTC) - ctx.session.validated
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session(
satellite.refresh_session(
ctx.session.key,
request.headers.get("host"),
ip=get_client_ip(request),
user_agent=request.headers.get("user-agent"),
validated=datetime.now(UTC),
@@ -162,16 +163,20 @@ async def check_user(
No session cookie is read or written. Caller authentication is not required.
"""
data = db.data()
if (proxied := await satellite.forward_request(request)) is not None:
# User lookups without a session need the user graph, which only
# the remote holds.
return proxied
host = hostutil.normalize_host(request.headers.get("host"))
data = satellite.store_for_host(host)
try:
u = data.users[user_uuid]
role = u.role
org = role.org
role = data.roles[u.role_uuid]
org = data.orgs[role.org_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
host = hostutil.normalize_host(request.headers.get("host"))
org_perm_uuids = {p.uuid for p in org.permissions}
org_perm_uuids = {p.uuid for p in data.permissions.values() if org.uuid in p.orgs}
effective_perms = []
for perm_uuid in role.permission_set:
@@ -212,7 +217,7 @@ def _remote_headers(ctx) -> dict[str, str]:
"Remote-Session-Expires": (
(ctx.session.validated + EXPIRES).isoformat().replace("+00:00", "Z")
),
"Remote-Credential": str(ctx.session.credential),
"Remote-Credential": str(ctx.credential.uuid),
}
@@ -311,6 +316,7 @@ async def get_settings():
auth_site_url=domain.auth_site_url,
session_cookie=AUTH_COOKIE_NAME,
version=__version__,
remote=domain.remote is not None,
),
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
)
@@ -323,6 +329,10 @@ async def api_user_info(
auth=AUTH_COOKIE,
):
"""Get full user profile including credentials and sessions."""
if (proxied := await satellite.forward_request(request)) is not None:
# The replica holds per-session contexts only, not the user graph —
# profile data is answered by the remote.
return proxied
if auth is None:
raise authz.AuthException(
status_code=401,
@@ -351,8 +361,10 @@ async def api_user_info(
@app.get("/token-info")
async def token_info(credentials=Depends(bearer_auth)):
async def token_info(request: Request, credentials=Depends(bearer_auth)):
"""Get reset/device-add token info. Pass token via Bearer header."""
if (proxied := await satellite.forward_request(request)) is not None:
return proxied
if not credentials or not credentials.credentials:
raise HTTPException(401, "Bearer token required")
token = credentials.credentials
@@ -375,6 +387,10 @@ async def token_info(credentials=Depends(bearer_auth)):
@app.post("/logout")
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if (proxied := await satellite.forward_request(request)) is not None:
if auth and proxied.status_code == 200:
satellite.evict_session(auth, request.headers.get("host"))
return proxied
if not auth:
return {"message": "Already logged out"}
host = request.headers.get("host")
@@ -399,6 +415,11 @@ async def api_set_session(
if not auth or not auth.credentials:
raise HTTPException(400, "Bearer token required")
if (proxied := await satellite.forward_request(request)) is not None:
# The exchange code lives in the remote's RAM; redeem it there. The
# session itself reaches the replica via the sync channel.
return proxied
host = hostutil.normalize_host(request.headers.get("host", ""))
if not host:
raise HTTPException(400, "Host header required")
+5
View File
@@ -63,6 +63,11 @@ class DispatchMiddleware:
host = _header(scope, "host")
host_domain = registry.resolve(host)
if host_domain is None:
# The sync endpoint is server-to-server and token-gated: the
# satellite may reach us via an address outside our domains.
if scope.get("path") == "/auth/api/sync/ws" and registry.domains:
await self._dispatch(scope, receive, send, registry.domains[0])
return
await send({"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION})
return
+17 -13
View File
@@ -5,13 +5,13 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from kanta.logging import configure_logging as configure_kanta_logging
from fastapi_vue import env
from paskia import authcode, db, domains, remoteauth
from paskia import authcode, db, domains, remoteauth, satellite
from paskia.bootstrap import bootstrap_if_needed
from paskia.db.background import start_background, stop_background
from paskia.db.lifecycle import kanta
from paskia.fastapi import admin, api, auth_host, oid, ws
from paskia.fastapi import admin, api, auth_host, oid, sync, ws
from paskia.fastapi.admin.adminapp import adminapp
from paskia.fastapi.dispatch import DispatchMiddleware
@@ -19,12 +19,8 @@ from paskia.fastapi.dispatch import DispatchMiddleware
from paskia.fastapi.front import frontend
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import passphrase, vitedev
from paskia.util.constants import DEVMODE
from paskia.util.runtime import serve_config
# Configure custom logging
configure_kanta_logging()
# Path to examples/index.html when running from source tree
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
@@ -33,22 +29,28 @@ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
async def lifespan(app: FastAPI): # pragma: no cover - startup path
"""Application lifespan: open the combined database and build the domain registry.
Process-global serve parameters (listen endpoints) are passed via the
PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so that
uvicorn reload / multiprocess workers derive site URLs the same way.
Domain configuration is read from the database.
Process-global serve parameters (listen endpoints, save flag) are passed
via the PASKIA_CONFIG JSON env variable (set by the CLI entrypoint) so
that uvicorn reload / multiprocess workers derive site URLs the same
way. With the save flag set, the listen endpoints are persisted here —
the CLI never opens the database read-write. Domain configuration is
read from the database.
"""
cfg = serve_config()
domains.configure(listen=cfg.listen if cfg else None)
domains.configure(listen=cfg.listen)
await asyncio.to_thread(
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
)
async with kanta:
if cfg.save:
with kanta.transaction("serve:save_listen"):
db.data().config.listen = cfg.listen
try:
domains.init_registry(db.data().config)
await remoteauth.init()
await authcode.start()
await satellite.manager.start()
except ValueError as e:
logging.error(f"⚠️ {e}")
# Re-raise to fail fast
@@ -59,6 +61,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
await start_background()
yield
await stop_background()
await satellite.manager.stop()
await authcode.stop()
@@ -68,7 +71,7 @@ app = FastAPI(
docs_url=None,
redoc_url=None,
openapi_url=None,
debug=DEVMODE,
debug=env.dev,
)
# WebSocket and HTTP access logging is handled by fastapi_vue's ASGI middleware;
@@ -82,6 +85,7 @@ app.middleware("http")(auth_host.redirect_middleware)
app.add_middleware(DispatchMiddleware)
app.mount("/auth/api/admin/", admin.app)
app.mount("/auth/api/sync", sync.app)
app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app)
app.mount("/auth/oidc/", oid.app)
+9 -1
View File
@@ -20,7 +20,7 @@ from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import authcode, db
from paskia import authcode, db, satellite
from paskia.db.structs import OIDC, Session
from paskia.util import avatar, oidjwt
from paskia.util.crypto import hash_secret
@@ -30,6 +30,14 @@ _logger = logging.getLogger(__name__)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http")
async def proxy_remote_domain(request: Request, call_next):
"""OIDC key material and sessions stay on the remote; proxy everything."""
if (proxied := await satellite.forward_request(request)) is not None:
return proxied
return await call_next(request)
def _provider() -> OIDC:
"""Return the instance-global OIDC provider state."""
return db.data().oidc
+3 -4
View File
@@ -15,6 +15,7 @@ from uuid import UUID
import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from uarite import uaparse
from paskia import authcode, db, remoteauth
from paskia.authcode import CookieCode
@@ -23,7 +24,7 @@ from paskia.domains import current_domain, registry
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.util import pow, useragent
from paskia.util import pow
# Create a FastAPI subapp for remote auth WebSocket endpoints
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -458,9 +459,7 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
if requesting_domain
else request.rp_id
),
"user_agent_pretty": useragent.compact_user_agent(
request.user_agent
),
"user_agent_pretty": uaparse(request.user_agent).pretty,
"client_ip": request.ip,
"action": request.action,
"pow": {
+340
View File
@@ -0,0 +1,340 @@
"""Sync WebSocket endpoint: link handshake, snapshots and live events.
One endpoint serves both registration and established links — the first
binary frame decides. Registration is a 3-way dialogue:
satellite → upstream hello {link: null, key: <initial key>, label}
upstream → satellite welcome {link, key: <server part>, domains: [...]}
satellite → upstream ready {key: <client part>}
The initial key comes from a registration offer minted in the admin UI:
held in RAM only, short-lived and single-use. The permanent link
credential ``key`` is created during the handshake from parts both sides
contribute; both sides then store the link in their databases.
(Placeholder cryptography: the parts are exchanged as-is — the final
version derives the channel key from the handshake without sending key
material.)
An established link runs the same dialogue with hello {link, key} proving
the stored key; the welcome re-pushes the link's domain set (the scope may
have changed while offline), the ready is a bare ack. A link's scope is
adjustable at any time and pushed live as a ``domains`` frame — no new
cryptographic setup.
After the handshake the channel switches to plain JSON frames (to be
transparently encrypted in the final version): snapshot chunks per table,
`ready`, then live upsert/delete events within the link's scope; the
satellite sends session_refresh write-backs and domains_ack reports.
Reconnects always restart from a snapshot. Beyond the link table all
state is RAM-only (syncfeed).
"""
import asyncio
import contextlib
import logging
import secrets
import time
from dataclasses import dataclass, field
from datetime import datetime
from urllib.parse import urlsplit
import msgspec
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import db, syncfeed
from paskia.util.passphrase import generate as generate_passphrase
_logger = logging.getLogger(__name__)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
OFFER_TTL = 600 # registration offers live 10 minutes, RAM-only
_HANDSHAKE_TIMEOUT = 15 # seconds per dialogue frame before a plain close
@dataclass
class _Offer:
rp_ids: list[str]
created: float # monotonic
@dataclass
class _Connection:
"""One live link connection: its WebSocket, link id, live feed scope
(mutated in place on scope changes) and the satellite's latest
domains_ack rejections."""
ws: WebSocket
link_id: str
scope: set[str]
rejected: dict[str, str] = field(default_factory=dict)
_offers: dict[str, _Offer] = {}
_connections: dict[asyncio.Queue, _Connection] = {}
def create_offer(rp_ids: list[str], base_url: str) -> str:
"""Mint a RAM-only registration offer; returns the wss link URL.
The URL is meant only for a satellite to connect to: a wss:// scheme
keeps browsers from opening it. The initial key travels as userinfo and
is never stored — losing the URL just means minting a new one.
"""
key = generate_passphrase()
now = time.monotonic()
for k, offer in list(_offers.items()): # lazy expiry sweep
if now - offer.created > OFFER_TTL:
_offers.pop(k, None)
_offers[key] = _Offer(rp_ids=list(rp_ids), created=now)
parts = urlsplit(base_url)
scheme = "wss" if parts.scheme == "https" else "ws"
return f"{scheme}://{key}@{parts.netloc}"
def _pop_offer(key: str) -> _Offer | None:
offer = _offers.pop(key, None) # single-use
if offer is None or time.monotonic() - offer.created > OFFER_TTL:
return None
return offer
def links_status() -> dict[str, dict]:
"""Live state per link id for the admin API: connected flag and the
satellite's latest domain rejections."""
status: dict[str, dict] = {}
for conn in _connections.values():
entry = status.setdefault(conn.link_id, {"connected": True, "rejected": {}})
entry["rejected"].update(conn.rejected)
return status
async def close_link(link_id: str) -> None:
"""Drop a link's live connections (revocation). The satellite treats
the policy close as terminal and unlinks its side."""
for conn in list(_connections.values()):
if conn.link_id == link_id:
with contextlib.suppress(Exception):
await conn.ws.close(code=1008)
def domain_infos(rp_ids) -> list[dict]:
"""The pushed configuration of each in-scope domain: rp-id, display
name and the origins table verbatim — the upstream is authoritative."""
infos = []
for rp_id in rp_ids:
domain = db.data().config.domains.get(rp_id)
if domain is None or domain.remote is not None:
continue # deleted, or itself satellite-served (no nesting)
infos.append(
{
"rp_id": rp_id,
"rp_name": domain.rp_name or "",
"origins": msgspec.to_builtins(domain.origins),
}
)
return infos
async def push_link_domains(link_id: str) -> None:
"""Send the link's current domain set to its live connections and
update their feed scope (after scope edits or origin changes)."""
link = db.data().satellite_links.get(link_id)
if link is None:
return
message = {"type": "domains", "domains": domain_infos(link.rp_ids)}
for conn in list(_connections.values()):
if conn.link_id != link_id:
continue
conn.scope.clear()
conn.scope.update(link.rp_ids)
with contextlib.suppress(Exception):
await _send(conn.ws, message)
async def refresh_links_serving(rp_id: str) -> None:
"""A domain changed upstream: push its updated info to links serving it."""
for link_id, link in db.data().satellite_links.items():
if rp_id in link.rp_ids:
await push_link_domains(link_id)
async def drop_domain_from_links(rp_id: str, *, ctx=None) -> None:
"""A domain was deleted upstream: shrink every link's scope and push."""
for link_id, link in db.data().satellite_links.items():
if rp_id in link.rp_ids:
db.set_satellite_link_scope(
link_id, [r for r in link.rp_ids if r != rp_id], ctx=ctx
)
await push_link_domains(link_id)
async def _send(ws: WebSocket, message: dict) -> None:
await ws.send_bytes(syncfeed.encode(message))
def _snapshot_items(table: str, scope: set[str]) -> list:
"""Snapshot rows for a table, filtered to the link's rp-id scope;
sessions carry their ApiSessionContext as ``ctx``."""
items = []
for key, obj in getattr(db.data(), table).items():
if obj.rp_id not in scope:
continue
fields = msgspec.to_builtins(obj)
if table == "sessions":
fields["ctx"] = syncfeed.session_ctx_dict(obj)
items.append([str(key), fields])
return items
async def _handshake(ws: WebSocket) -> _Connection | None:
"""The 3-way link dialogue: hello → welcome → ready.
Returns the connection state for an accepted link, or None after
closing on rejection (bad offer, unknown link or key). Reads are
bounded: a peer that stalls mid-dialogue gets a plain server-error
close — never 1008, which the satellite reads as revocation.
"""
try:
hello = msgspec.json.decode(
await asyncio.wait_for(ws.receive_bytes(), _HANDSHAKE_TIMEOUT)
)
except TimeoutError:
await ws.close(code=1011)
return None
if hello.get("type") != "hello":
await ws.close(code=1008)
return None
link_id = hello.get("link") or ""
server_part: bytes | None = None
if link_id:
# Established link: prove the stored key.
link = db.data().satellite_links.get(link_id)
try:
proof = bytes.fromhex(hello.get("key") or "")
except ValueError:
proof = b""
if link is None or not proof or not secrets.compare_digest(link.key, proof):
await ws.close(code=1008)
return None
rp_ids = list(link.rp_ids)
else:
# Registration: spend the RAM-only offer, contribute a key part.
offer = _pop_offer(hello.get("key") or "")
if offer is None:
await ws.close(code=1008)
return None
link_id = secrets.token_urlsafe(12)
server_part = secrets.token_bytes(32)
rp_ids = offer.rp_ids
welcome: dict = {
"type": "welcome",
"link": link_id,
"domains": domain_infos(rp_ids),
}
if server_part is not None:
welcome["key"] = server_part.hex()
await _send(ws, welcome)
try:
ready = msgspec.json.decode(
await asyncio.wait_for(ws.receive_bytes(), _HANDSHAKE_TIMEOUT)
)
except TimeoutError:
await ws.close(code=1011)
return None
if ready.get("type") != "ready":
await ws.close(code=1008)
return None
if server_part is not None:
# Registration completes: the permanent credential is both parts.
try:
client_part = bytes.fromhex(ready.get("key") or "")
except ValueError:
client_part = b""
if not client_part:
await ws.close(code=1008)
return None
db.create_satellite_link(
link_id, rp_ids, server_part + client_part, label=hello.get("label") or ""
)
return _Connection(
ws=ws,
link_id=link_id,
scope=set(rp_ids),
rejected=ready.get("rejected") or {},
)
async def _apply_client_message(message: dict, conn: _Connection) -> None:
"""Satellite → upstream frames: session_refresh write-backs (confined
to the link's scope) and domains_ack reports."""
mtype = message.get("type")
if mtype == "domains_ack":
conn.rejected = message.get("rejected") or {}
return
if mtype != "session_refresh":
return
key = message.get("key") or ""
session = db.data().sessions.get(key)
if session is None or session.rp_id not in conn.scope:
return
try:
validated = msgspec.convert(message.get("validated"), datetime)
except msgspec.ValidationError:
return
db.update_session(
key,
ip=message.get("ip") or None,
user_agent=message.get("user_agent") or None,
validated=validated,
)
@app.websocket("/ws")
async def sync_websocket(ws: WebSocket):
await ws.accept()
queue: asyncio.Queue | None = None
try:
conn = await _handshake(ws)
if conn is None:
return
queue = syncfeed.subscribe(conn.scope)
_connections[queue] = conn
for table in syncfeed.TABLES:
await _send(
ws,
{
"type": "snapshot",
"table": table,
"items": _snapshot_items(table, conn.scope),
},
)
await _send(ws, {"type": "ready", "rp_ids": sorted(conn.scope)})
sender = asyncio.create_task(_pump(ws, queue))
try:
while True:
await _apply_client_message(
msgspec.json.decode(await ws.receive_bytes()), conn
)
finally:
sender.cancel()
except WebSocketDisconnect, RuntimeError:
pass
except Exception:
_logger.exception("Sync WebSocket failed")
finally:
if queue is not None:
_connections.pop(queue, None)
syncfeed.unsubscribe(queue)
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
try:
while True:
await _send(ws, await queue.get())
except WebSocketDisconnect, RuntimeError, asyncio.CancelledError:
pass
except Exception:
_logger.debug("Sync event pump ended", exc_info=True)
+14 -1
View File
@@ -11,7 +11,7 @@ from fastapi import (
)
from fastapi.responses import FileResponse, JSONResponse
from paskia import db
from paskia import db, satellite
from paskia.authsession import (
delete_credential,
expires,
@@ -27,6 +27,19 @@ from paskia.util.apistructs import ApiCreateLinkResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http")
async def proxy_remote_domain(request: Request, call_next):
"""Profile and avatar data live on the remote for remote domains.
A remote domain's auth host normally resolves to the remote itself;
if traffic for it reaches the satellite anyway (misrouted DNS), the
satellite forwards rather than writing to its local database.
"""
if (proxied := await satellite.forward_request(request)) is not None:
return proxied
return await call_next(request)
def _can_manage_avatar(ctx, target_user) -> bool:
if ctx.user.uuid == target_user.uuid:
return True
+723
View File
@@ -0,0 +1,723 @@
"""Satellite side of remote domains: uplinks, RAM-only replicas, dispatch.
A domain configured with ``DomainConfig.remote`` is backed by an upstream
paskia instance over a **link**: registered online (3-way handshake over
the sync WebSocket — one ``register_uplink`` path serves the admin UI and
the ``paskia satellite`` CLI), then kept up by a long-lived connection per
uplink. The upstream owns the domain set: it pushes the link's domains
(rp-id, display name, origins verbatim) at handshake and whenever the
scope or a domain changes; the satellite validates them against its local
config, persists the accepted set, and reports rejections. Local edits of
remote domains are rejected — the only satellite-side configuration is
link creation and unlinking.
This module owns the rest of the feature: it resolves which store serves
a request host (local DB or an uplink's read replica), dispatches session
writes (refresh write-behind, logout eviction), and forwards requests the
satellite cannot answer (exchange-code redemption, OIDC, reset tokens,
session-less lookups like /check) to the upstream.
A replica is a ReplicaDB instance, never persisted, fed by the sync
channel (snapshot on connect, then live events) and swept for expired
sessions locally. The link's scope confines the replica to the pushed
domains' sessions and credentials, each session carrying the
ApiSessionContext the upstream computed for it (identity display data and
effective permissions for the session's host) — the user graph itself
never leaves the upstream. While the channel is down the replica stays
trusted for the uplink's cache_ttl, then reads fail closed (503; a large
cache_ttl gives fail-open behavior bounded by session expiry).
"""
import asyncio
import contextlib
import logging
import secrets
import socket
import time
from datetime import UTC, datetime
from urllib.parse import unquote, urlsplit, urlunsplit
from uuid import UUID
import httpx
import msgspec
import websockets
from fastapi import HTTPException, Request, Response
from paskia import db, domains, syncfeed
from paskia.config import SESSION_LIFETIME
from paskia.db.structs import (
DB,
Config,
Credential,
DomainConfig,
Org,
OriginEntry,
Permission,
RemoteConfig,
Role,
Session,
SessionContext,
Uplink,
User,
)
from paskia.util.apistructs import ApiSessionContext
from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__)
_TABLES = {
"credentials": (Credential, True),
"sessions": (Session, False),
}
_RECONNECT_DELAY = 5
_SWEEP_INTERVAL = 60
_HANDSHAKE_TIMEOUT = 15 # seconds the upstream may take to answer a hello
class LinkRevokedError(Exception):
"""The upstream closed the handshake with a policy violation: the link
is revoked (or the key desynced) — the satellite unlinks its side."""
class ReplicaDB(DB):
"""A sync replica's store: the uplink's sessions and credentials plus
the per-session contexts (identity and effective permissions) the
upstream pushes. Holds no user graph of its own; lookups join the
session with its pushed context."""
def __post_init__(self):
super().__post_init__()
self.session_contexts: dict[str, ApiSessionContext] = {}
def session_ctx_for(self, s: Session, host: str | None) -> SessionContext | None:
if s.client_uuid is not None or s.host != host:
return None
stored = self.session_contexts.get(s.key)
if stored is None:
return None
try:
credential = self.credentials[s.credential_uuid]
except KeyError:
return None
user = User(
display_name=stored.user.display_name,
role_uuid=stored.role.uuid,
created_at=s.validated,
visits=0,
theme=stored.user.theme,
)
user.uuid = stored.user.uuid
role = Role(org_uuid=stored.org.uuid, display_name=stored.role.display_name)
role.uuid = stored.role.uuid
org = Org(display_name=stored.org.display_name)
org.uuid = stored.org.uuid
return SessionContext(
session=s,
user=user,
org=org,
role=role,
credential=credential,
permissions=[
Permission(scope=scope, display_name=scope)
for scope in stored.permissions
],
)
def ws_url_for(base_url: str) -> str:
"""The upstream's sync WebSocket URL for an http(s) base URL."""
return base_url.replace("http", "ws", 1) + "/auth/api/sync/ws"
def parse_link_url(url: str) -> tuple[str, str]:
"""Split a satellite link URL — ``wss://<initial-key>@<host>`` — into
the upstream's https base URL and the initial registration key.
The wss scheme is deliberate: the URL is meant for a satellite to
connect to, and browsers refuse to open it.
"""
parts = urlsplit(url.strip())
if parts.scheme not in ("wss", "ws"):
raise ValueError(
"A link URL is a wss:// URL copied from the upstream's satellites panel"
)
key = unquote(parts.password or parts.username or "")
if not key:
raise ValueError("The link URL carries no registration key")
netloc = parts.hostname or ""
if not netloc:
raise ValueError("The link URL carries no host")
if parts.port:
netloc = f"{netloc}:{parts.port}"
scheme = "https" if parts.scheme == "wss" else "http"
base = urlunsplit((scheme, netloc, parts.path, "", "")).rstrip("/")
return base, key
def _label() -> str:
"""This instance's self-reported name on links (informational)."""
return socket.gethostname() or "satellite"
def _uplink_domains(uplink_id: str) -> dict[str, DomainConfig]:
"""The config domains currently served by an uplink."""
return {
rp_id: d
for rp_id, d in db.data().config.domains.items()
if d.remote is not None and d.remote.link == uplink_id
}
def _accepted_domains(
uplink_id: str, infos: list[dict]
) -> tuple[dict[str, DomainConfig], dict[str, str]]:
"""Validate a pushed domain set against the local configuration.
The upstream is authoritative for the domains it pushes, but they must
not collide with what this instance already serves: an rp-id served
locally or by another uplink is rejected, as is a push that would break
the merged config (e.g. an auth-host collision with a local domain).
Returns (accepted, {rp_id: rejection reason}).
"""
merged = {
rp_id: d
for rp_id, d in db.data().config.domains.items()
if not (d.remote is not None and d.remote.link == uplink_id)
}
accepted: dict[str, DomainConfig] = {}
rejected: dict[str, str] = {}
for info in infos:
rp_id = (info.get("rp_id") or "").strip().lower()
if not rp_id:
continue
existing = db.data().config.domains.get(rp_id)
if existing is not None and not (
existing.remote is not None and existing.remote.link == uplink_id
):
rejected[rp_id] = "served locally or by another uplink"
continue
try:
origins = msgspec.convert(
info.get("origins") or {}, dict[str, bool | OriginEntry]
)
except msgspec.ValidationError:
rejected[rp_id] = "malformed origins"
continue
domain = DomainConfig(
rp_name=info.get("rp_name") or None,
origins=origins,
remote=RemoteConfig(link=uplink_id),
)
try:
domains.validate_config(
Config(domains={**merged, **accepted, rp_id: domain})
)
except ValueError as e:
rejected[rp_id] = str(e)
continue
accepted[rp_id] = domain
return accepted, rejected
def _drop_replica_rows(replica: ReplicaDB, removed_rp_ids: set[str]) -> None:
"""Evict a removed domain's rows from a replica."""
for key, s in list(replica.sessions.items()):
if s.rp_id in removed_rp_ids:
del replica.sessions[key]
replica.session_contexts.pop(key, None)
for cred_uuid, c in list(replica.credentials.items()):
if c.rp_id in removed_rp_ids:
del replica.credentials[cred_uuid]
def apply_domains_push(
uplink_id: str, infos: list[dict], replica_db: ReplicaDB | None = None
) -> dict[str, str]:
"""Apply a domain set pushed by the uplink: persist the accepted set
(adding, updating and removing the link's domains) and rebuild the
registry. Returns the {rp_id: reason} rejections to report upstream."""
accepted, rejected = _accepted_domains(uplink_id, infos)
current = _uplink_domains(uplink_id)
if accepted != current:
db.apply_uplink_domains(uplink_id, accepted)
domains.init_registry(db.data().config)
if replica_db is not None and (removed := set(current) - set(accepted)):
_drop_replica_rows(replica_db, removed)
return rejected
async def register_uplink(url: str) -> dict:
"""Register this instance as a satellite of an upstream.
The one registration path, serving the admin UI and the ``paskia
satellite`` CLI alike: connect to the link URL's sync endpoint, run the
3-way handshake (spending the URL's RAM-only initial key), then commit
the permanent link credential and the pushed domain set to the local
database. The replica connection itself is (re)established by the
manager afterwards, through the same handshake as an established link.
"""
base_url, initial_key = parse_link_url(url)
if any(u.url == base_url for u in db.data().uplinks.values()):
raise ValueError(f"Already linked to {base_url} — unlink first to re-register")
try:
async with websockets.connect(
ws_url_for(base_url), ping_interval=5, ping_timeout=5
) as ws:
await ws.send(
syncfeed.encode(
{
"type": "hello",
"link": None,
"key": initial_key,
"label": _label(),
}
)
)
try:
welcome = msgspec.json.decode(
await asyncio.wait_for(ws.recv(), _HANDSHAKE_TIMEOUT)
)
except websockets.exceptions.ConnectionClosed as e:
raise RuntimeError(
"the upstream rejected the link URL (unknown or expired offer)"
) from e
except TimeoutError as e:
raise RuntimeError("the upstream did not answer in time") from e
if welcome.get("type") != "welcome":
raise RuntimeError("the upstream did not complete the registration")
link_id = welcome.get("link") or ""
try:
server_part = bytes.fromhex(welcome.get("key") or "")
except ValueError:
server_part = b""
if not link_id or not server_part:
raise RuntimeError("the upstream sent a malformed registration")
accepted, rejected = _accepted_domains(
link_id, welcome.get("domains") or []
)
client_part = secrets.token_bytes(32)
await ws.send(
syncfeed.encode(
{"type": "ready", "key": client_part.hex(), "rejected": rejected}
)
)
except RuntimeError:
raise
except Exception as e:
raise RuntimeError(f"the upstream is unreachable: {e}") from e
uplink = Uplink(
url=base_url, created=datetime.now(UTC), key=server_part + client_part
)
db.create_uplink(link_id, uplink, accepted)
domains.init_registry(db.data().config)
if manager.started:
await manager.sync_uplinks()
return {
"link": link_id,
"url": base_url,
"domains": sorted(accepted),
"rejected": rejected,
}
class UplinkReplica:
"""One uplink's replica, its sync client and write-behind queue."""
def __init__(self, uplink: Uplink):
self.uplink = uplink # uplink.uplink_id is set
self.db = ReplicaDB()
self.last_contact = 0.0 # monotonic time the feed last went down
self.connected = False
self._pending_refresh: dict[str, dict] = {}
self._refresh_signal = asyncio.Event()
self._task: asyncio.Task | None = None
self._sweeper: asyncio.Task | None = None
self._stopped = True
def available(self) -> bool:
"""Synced, and connected now or within cache_ttl of the disconnect."""
if not self.last_contact:
return False
return self.connected or (
time.monotonic() - self.last_contact <= self.uplink.cache_ttl
)
def refresh_session(
self, key: str, validated: datetime, ip: str | None, user_agent: str | None
) -> None:
"""Apply a /validate refresh locally and queue it for the upstream."""
session = self.db.sessions.get(key)
if session is not None:
session.validated = validated
if ip is not None:
session.ip = ip
if user_agent is not None:
session.user_agent = user_agent
self._pending_refresh[key] = {
"type": "session_refresh",
"key": key,
"validated": msgspec.to_builtins(validated),
"ip": ip,
"user_agent": user_agent,
}
self._refresh_signal.set()
async def start(self) -> None:
self._stopped = False
self._task = asyncio.create_task(self._run())
self._task.add_done_callback(lambda t: _replica_done(self, t))
self._sweeper = asyncio.create_task(self._sweep())
async def stop(self) -> None:
self._stopped = True
for task in (self._task, self._sweeper):
if task:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, LinkRevokedError):
await task
async def _sweep(self) -> None:
while True:
await asyncio.sleep(_SWEEP_INTERVAL)
limit = datetime.now(UTC) - SESSION_LIFETIME
for key in [k for k, s in self.db.sessions.items() if s.validated < limit]:
del self.db.sessions[key]
self.db.session_contexts.pop(key, None)
async def _run(self) -> None:
while not self._stopped:
try:
await self._connect()
except asyncio.CancelledError:
raise
except LinkRevokedError:
raise # the manager unlinks via the done-callback
except Exception as e:
_logger.info("Sync to %s failed: %s", self.uplink.url, e)
if self.connected:
# The TTL clock starts when the feed goes down, not at the
# last message — an idle connection is healthy.
self.connected = False
self.last_contact = time.monotonic()
if not self._stopped:
await asyncio.sleep(_RECONNECT_DELAY)
async def _recv(self, ws, timeout: float | None = None) -> dict:
try:
if timeout is None:
message = await ws.recv()
else:
message = await asyncio.wait_for(ws.recv(), timeout)
return msgspec.json.decode(message)
except websockets.exceptions.ConnectionClosedError as e:
if e.code == 1008:
raise LinkRevokedError(self.uplink.url) from e
raise
async def _connect(self) -> None:
async with websockets.connect(
ws_url_for(self.uplink.url),
# Prompt dead-peer detection: availability semantics count on it
ping_interval=5,
ping_timeout=5,
) as ws:
await ws.send(
syncfeed.encode(
{
"type": "hello",
"link": self.uplink.uplink_id,
"key": self.uplink.key.hex(),
"label": _label(),
}
)
)
welcome = await self._recv(ws, timeout=_HANDSHAKE_TIMEOUT)
if welcome.get("type") != "welcome":
raise ValueError("sync: protocol error (expected welcome)")
# The upstream re-pushes the link's domain set on every connect
# (the scope may have changed while offline) — apply and ack.
rejected = apply_domains_push(
self.uplink.uplink_id, welcome.get("domains") or [], replica_db=self.db
)
await ws.send(syncfeed.encode({"type": "ready", "rejected": rejected}))
sender = asyncio.create_task(self._send_loop(ws))
staging: ReplicaDB | None = None
ready_at = 0.0
try:
while True:
if ready_at:
# Periodic reconnects give full-snapshot reconciliation
remaining = self.uplink.refresh_interval - (
time.monotonic() - ready_at
)
if remaining <= 0:
return
try:
message = msgspec.json.decode(
await asyncio.wait_for(ws.recv(), remaining)
)
except TimeoutError:
return # periodic resync: reconnect for a snapshot
else:
message = msgspec.json.decode(await ws.recv())
mtype = message.get("type")
if mtype == "snapshot":
staging = staging or ReplicaDB()
for key, fields in message["items"]:
_apply(staging, message["table"], key, fields)
elif mtype == "event":
if staging is not None:
raise ValueError("sync: event before ready")
_apply(
self.db,
message["table"],
message["key"],
message.get("fields"),
)
elif mtype == "domains":
# Live scope/origin change: apply and acknowledge.
rejected = apply_domains_push(
self.uplink.uplink_id,
message.get("domains") or [],
replica_db=self.db,
)
await ws.send(
syncfeed.encode(
{"type": "domains_ack", "rejected": rejected}
)
)
elif mtype == "ready":
if staging is not None:
self.db = staging
staging = None
self.connected = True
self.last_contact = ready_at = time.monotonic()
finally:
sender.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sender
async def _send_loop(self, ws) -> None:
while True:
self._refresh_signal.clear()
while self._pending_refresh:
_, message = self._pending_refresh.popitem()
await ws.send(syncfeed.encode(message))
await self._refresh_signal.wait()
def _replica_done(replica: UplinkReplica, task: asyncio.Task) -> None:
"""A replica's loop ended: a revocation (the only way out besides
cancellation) unlinks the satellite side."""
if task.cancelled():
return
exc = task.exception()
if not isinstance(exc, LinkRevokedError):
return
_logger.info("Uplink %s revoked the link — unlinking", replica.uplink.url)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(manager.unlink(replica.uplink.uplink_id))
def _apply(replica: ReplicaDB, table: str, key: str, fields: dict | None) -> None:
"""Apply an upsert (fields given) or delete (fields None) to a replica.
Session upserts carry their upstream-computed context as ``ctx``."""
cls, uuid_key = _TABLES[table]
store = getattr(replica, table)
store_key = UUID(key) if uuid_key else key
if fields is None:
store.pop(store_key, None)
if table == "sessions":
replica.session_contexts.pop(key, None)
return
fields = dict(fields)
ctx = fields.pop("ctx", None) if table == "sessions" else None
obj = msgspec.convert(fields, cls)
if uuid_key:
obj.uuid = store_key
else:
obj.key = key
store[store_key] = obj
if table == "sessions":
if ctx is not None:
replica.session_contexts[key] = msgspec.convert(ctx, ApiSessionContext)
else:
replica.session_contexts.pop(key, None)
class SatelliteManager:
"""Replicas keyed by uplink id; one connection per uplink carries all
of the link's domains."""
def __init__(self):
self.replicas: dict[str, UplinkReplica] = {}
self.started = False
async def start(self) -> None:
self.started = True
await self.sync_uplinks()
async def stop(self) -> None:
self.started = False
for replica in self.replicas.values():
await replica.stop()
self.replicas.clear()
for url in list(_clients):
await _drop_client(url)
async def sync_uplinks(self) -> None:
"""Start/stop replicas to match the stored uplinks."""
wanted = db.data().uplinks
for uplink_id in list(self.replicas):
if uplink_id not in wanted:
replica = self.replicas.pop(uplink_id)
await replica.stop()
await _drop_client(replica.uplink.url)
for uplink_id, uplink in wanted.items():
uplink.uplink_id = uplink_id # fresh objects lack it until load
replica = self.replicas.get(uplink_id)
if replica is None or replica.uplink != uplink:
if replica is not None:
await replica.stop()
replica = UplinkReplica(uplink)
self.replicas[uplink_id] = replica
await replica.start()
async def unlink(self, uplink_id: str) -> None:
"""Drop an uplink and every domain it serves (admin unlink, or the
upstream revoked the link)."""
replica = self.replicas.pop(uplink_id, None)
if replica is not None:
await replica.stop()
await _drop_client(replica.uplink.url)
db.delete_uplink(uplink_id)
domains.init_registry(db.data().config)
manager = SatelliteManager()
# -------------------------------------------------------------------------
# Host-keyed dispatch: the only interface the rest of the app uses
# -------------------------------------------------------------------------
def replica_for_host(host: str | None) -> UplinkReplica | None:
"""The replica serving this host, or None for locally served hosts."""
domain = domains.registry().resolve(host)
if domain is None or domain.remote is None:
return None
return manager.replicas.get(domain.remote.link)
def store_for_host(host: str | None) -> DB:
"""The data store to read for a request host: the local database, or
the replica of the uplink backing the host's domain."""
domain = domains.registry().resolve(host)
if domain is None or domain.remote is None:
return db.data()
replica = manager.replicas.get(domain.remote.link)
if replica is None or not replica.available():
raise HTTPException(503, "Remote authentication service unavailable")
return replica.db
def refresh_session(
key: str,
host: str | None,
ip: str | None,
user_agent: str | None,
validated: datetime,
ctx=None,
):
"""/validate refresh: write-behind for remote domains, else local DB."""
replica = replica_for_host(host)
if replica is not None:
replica.refresh_session(key, validated, ip, user_agent)
else:
db.update_session(
key, ip=ip, user_agent=user_agent, validated=validated, ctx=ctx
)
def evict_session(auth: str, host: str | None) -> None:
"""Drop a session from the replica (its remote deletion arrives via sync)."""
replica = replica_for_host(host)
if replica is not None:
key = hash_secret("cookie", auth)
replica.db.sessions.pop(key, None)
replica.db.session_contexts.pop(key, None)
_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
_HOP_BY_HOP = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"content-length",
"accept-encoding",
"content-encoding",
}
_clients: dict[str, httpx.AsyncClient] = {}
async def _drop_client(url: str) -> None:
"""Close and forget the forwarding client for an uplink URL."""
client = _clients.pop(url, None)
if client is not None:
await client.aclose()
def _uplink_url(domain: domains.Domain) -> str:
"""The base URL of the uplink backing a remote domain (503 if the
config and the uplinks table desynced — a bug, not a local answer)."""
uplink = db.data().uplinks.get(domain.remote.link)
if uplink is None:
raise HTTPException(503, "Remote authentication service unavailable")
return uplink.url
async def forward_request(request: Request) -> Response | None:
"""Forward the request to its domain's uplink, or None when local.
The original Host header is preserved so the upstream dispatches to the
same domain (sessions are host-bound). The user's cookie authenticates
the forwarded call; the satellite needs no credentials of its own.
"""
domain = domains.registry().resolve(request.headers.get("host"))
if domain is None or domain.remote is None:
return None
url = _uplink_url(domain)
client = _clients.get(url)
if client is None:
client = _clients[url] = httpx.AsyncClient(base_url=url, timeout=_TIMEOUT)
try:
upstream = await client.request(
request.method,
request.url.path,
params=request.url.query,
content=await request.body(),
headers={
k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP
},
)
except httpx.TransportError as e:
raise HTTPException(503, "Remote authentication service unavailable") from e
response = Response(content=upstream.content, status_code=upstream.status_code)
# Raw headers to preserve repeated Set-Cookie
response.raw_headers = [
(k, v) for k, v in upstream.headers.raw if k.decode().lower() not in _HOP_BY_HOP
]
return response
+130
View File
@@ -0,0 +1,130 @@
"""RAM-only change feed letting satellite instances mirror this server.
Nothing here touches the database file: committed mutations are pushed to
connected satellites over the sync WebSocket (fastapi/sync.py). Satellites
authenticate with a link credential created at registration (stored in the
database, scoped to the link's rp-ids); a scoped subscription receives only
its own rp-ids' sessions and credentials — never the user graph. The scope
set is live: adjusting the link's scope mutates it in place.
Data minimization is per session: every session upsert carries an embedded
``ctx`` (the ApiSessionContext also sent to users) holding the session's
user/org/role display data and effective permissions, computed for the
session's bound host. Mutations of the user graph (users, orgs, roles,
permissions) are not published; instead they trigger an eager re-push of
the affected sessions with freshly computed contexts, so revocations and
renames reach satellites within one RTT.
There is deliberately no replay log: snapshots are small, so a reconnecting
satellite simply takes a fresh one.
"""
import asyncio
from uuid import UUID
import msgspec
# Tables mirrored by satellites, all filtered to the subscriber's rp-id
# (reset tokens, sync tokens, OIDC data and domain config are
# instance-local and never replicated).
TABLES = ("credentials", "sessions")
SCOPED_TABLES = TABLES
# The user graph is never published; upserts of these tables drive the
# eager re-push of affected session contexts instead.
_GRAPH_TABLES = ("permissions", "orgs", "roles", "users")
_subscribers: dict[asyncio.Queue, set[str] | None] = {}
def session_ctx_dict(session) -> dict | None:
"""The session's ApiSessionContext as builtins (embedded in session
events and snapshots), computed for the session's bound host."""
from paskia import db # noqa: PLC0415 (import cycle)
from paskia.util import userinfo # noqa: PLC0415 (import cycle)
data = db.data()
if data is None:
return None
ctx = data.session_ctx_for(session, session.host)
if ctx is None:
return None
return msgspec.to_builtins(userinfo.build_session_context(ctx))
def _repush_contexts(table: str, key: str) -> None:
"""Re-emit the sessions whose context a user-graph upsert may have
changed: each carries a freshly computed ctx. Deletions are not needed
here — those cascade as session/credential delete events."""
from paskia import db # noqa: PLC0415 (import cycle)
data = db.data()
if data is None:
return
uuid = UUID(key)
if table == "users":
affected = {uuid}
elif table == "roles":
affected = {u.uuid for u in data.users.values() if u.role_uuid == uuid}
elif table == "orgs":
affected = {
u.uuid
for u in data.users.values()
if data.roles.get(u.role_uuid) is not None
and data.roles[u.role_uuid].org_uuid == uuid
}
else: # permissions: roles containing it, then their users
affected = {
u.uuid
for u in data.users.values()
if (role := data.roles.get(u.role_uuid)) is not None
and uuid in role.permissions
}
for skey, session in data.sessions.items():
if session.user_uuid in affected:
emit("sessions", skey, session)
def emit(table: str, key: str, obj) -> None:
"""Publish an upsert (obj given) or delete (obj None) to subscribers."""
if not _subscribers:
return
if table in _GRAPH_TABLES:
# The user graph stays on the master; its changes reach satellites
# as re-pushed session contexts.
if obj is not None:
_repush_contexts(table, key)
return
fields = msgspec.to_builtins(obj) if obj is not None else None
if table == "sessions" and obj is not None:
fields["ctx"] = session_ctx_dict(obj)
event = {"type": "event", "table": table, "key": key, "fields": fields}
for queue, scope in list(_subscribers.items()):
if (
scope is not None
and table in SCOPED_TABLES
and fields is not None
and fields.get("rp_id") not in scope
):
# Out of scope upsert; deletes pass (a replica ignores unknown
# keys, and the deleted object no longer tells its rp-id).
continue
try:
queue.put_nowait(event)
except asyncio.QueueFull:
# Slow consumer: drop it; the client reconnects and resyncs.
_subscribers.pop(queue, None)
def subscribe(scope: set[str] | None = None) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
_subscribers[queue] = scope
return queue
def unsubscribe(queue: asyncio.Queue) -> None:
_subscribers.pop(queue, None)
def encode(message: dict) -> bytes:
return msgspec.json.encode(message)
+13 -3
View File
@@ -11,10 +11,18 @@ from datetime import datetime
from uuid import UUID
import msgspec
from uarite import uaparse
from paskia import db
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
from paskia.util import useragent
from paskia.db.structs import (
Credential,
Org,
OriginEntry,
Permission,
RemoteConfig,
Role,
User,
)
# -------------------------------------------------------------------------
# API structs - inherit from db structs, add uuid for serialization
@@ -124,7 +132,7 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
credential_uuid=s.credential_uuid,
host=s.host,
ip=s.ip,
user_agent=useragent.compact_user_agent(s.user_agent),
user_agent=uaparse(s.user_agent).pretty,
validated=s.validated,
last_renewed=s.validated,
is_current=s.key == current_key,
@@ -177,6 +185,7 @@ class ApiSettings(msgspec.Struct):
auth_site_url: str
session_cookie: str
version: str
remote: bool = False # this instance satellite-serves the domain
class ApiDomain(msgspec.Struct):
@@ -194,6 +203,7 @@ class ApiDomain(msgspec.Struct):
site_url: str
auth_site_url: str
auth_host: str | None
remote: RemoteConfig | None = None
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
+2 -3
View File
@@ -1,6 +1,5 @@
"""Small, dependency-free constants shared by CLI and server modules."""
import os
# App-side default; paskia.__main__ keeps its own literal copy because
# fastapi-vue-setup reads DEFAULT_PORT from the CLI module on upgrades.
DEFAULT_PORT = 4401
DEVMODE = os.getenv("PASKIA_DEV") == "1"
-12
View File
@@ -98,15 +98,3 @@ def normalize_host(raw_host: str | None) -> str | None:
# Strip port from host:port
netloc = netloc.rsplit(":", 1)[0]
return netloc.lower().rstrip(".") or None
def format_endpoint(ep: dict) -> str:
"""Format an endpoint dict to a listen string (e.g. 'unix:/path' or 'host:port')."""
if uds := ep.get("uds"):
return f"unix:{uds}"
host = ep["host"]
port = ep["port"]
# Bracket IPv6 addresses
if ":" in host:
host = f"[{host}]"
return f"{host}:{port}"
+11 -18
View File
@@ -2,35 +2,28 @@
Domain configuration lives in the database (``Config.domains``); the
``PASKIA_CONFIG`` environment variable only carries the effective listen
endpoints so that child processes (uvicorn reload / workers) can derive
site URLs the same way the parent did.
endpoints and whether to persist them, so that child processes (uvicorn
reload / workers) derive site URLs the same way the parent did. The CLI
entry point mutates the bound object before ``server.run()`` calls
``teleport()`` to pass it on.
"""
import os
from functools import lru_cache
import msgspec
from fastapi_vue import env
class ServeConfig(msgspec.Struct):
"""Process-global serve parameters."""
listen: list[str] | None = None
save: bool = False # Persist listen to the stored config on startup
@lru_cache(maxsize=1)
def _load() -> ServeConfig | None:
raw = os.getenv("PASKIA_CONFIG")
if not raw:
return None
return msgspec.json.decode(raw.encode(), type=ServeConfig)
def serve_config() -> ServeConfig | None:
"""Return cached serve configuration loaded from PASKIA_CONFIG."""
return _load()
def serve_config() -> ServeConfig:
"""Return the serve configuration bound to PASKIA_CONFIG."""
return env(ServeConfig, name="CONFIG")
def clear_cache() -> None:
"""Clear cached serve configuration; next serve_config() reloads."""
_load.cache_clear()
"""Drop the bound configuration; next serve_config() re-decodes."""
env._bindings.pop("CONFIG", None) # noqa: SLF001
+65 -60
View File
@@ -2,22 +2,21 @@
from __future__ import annotations
import os
import re
from sys import stderr
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from fastapi_vue import env
from fastapi_vue.hostutil import parse_endpoints
from fastapi_vue.server import print_startup_box
from paskia._version import __version__
from paskia.domains import auth_host_url, origin_url, partition_origins
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import format_endpoint, wildcard_base
from paskia.util import hostutil
from paskia.util.hostutil import wildcard_base
if TYPE_CHECKING:
from paskia.domains import DomainRegistry
BOX_WIDTH = 80 # Maximum inner width (excluding box chars)
URL_COL = 22 # Column where header URLs start (past the logo graphic)
# ANSI color codes
@@ -26,46 +25,12 @@ YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
_TOKENS = re.compile(r"\033\[[0-9;]*m|.")
def _visible_len(text: str) -> int:
"""Calculate visible length of text, ignoring ANSI escape codes."""
return len(re.sub(r"\033\[[0-9;]*m", "", text))
def _truncate(text: str, width: int) -> str:
"""Cut text to at most `width` visible chars, keeping ANSI codes intact."""
if _visible_len(text) <= width:
return text
out = []
visible = 0
for tok in _TOKENS.findall(text):
if tok.startswith("\033"):
out.append(tok)
elif visible < width - 1:
out.append(tok)
visible += 1
else:
break
return "".join(out) + "…" + RESET
def line(text: str = "", width: int = BOX_WIDTH) -> str:
"""Format a line inside the box with proper padding, truncating if needed."""
text = _truncate(text, width)
padding = width - _visible_len(text)
return f"┃ {text}{' ' * padding} ┃\n"
def top(width: int = BOX_WIDTH) -> str:
return "┏" + "━" * (width + 2) + "┓\n"
def bottom(width: int = BOX_WIDTH) -> str:
return "┗" + "━" * (width + 2) + "┛\n"
def _compact_url(url: str) -> str:
"""Bare host for https URLs; scheme and port kept for plain http."""
stripped = url.removeprefix("https://")
@@ -85,9 +50,47 @@ def _origin_phrase(key: str, rp_id: str) -> str:
return _compact_url(origin_url(key))
def _covered_by_wildcard(key: str, pattern: str) -> bool:
"""Whether an origins-table key is redundant given a wildcard key.
Mirrors DomainConfig matching (sansio._allowlisted): a wildcard covers
hostnames under its base over https (any port), except under localhost
where any scheme and any port match. Plain http entries outside
localhost are therefore never covered and stay listed.
"""
base = wildcard_base(pattern)
if base is None:
return False
# Keys are bare hosts (https:// and '/' stripped by origin_key, port
# kept) or full origins; urlparse needs a scheme or '//' prefix.
hostname = urlparse(key if "://" in key else f"//{key}").hostname
if not hostname:
return False
if pattern.startswith("**."):
matched = hostutil.is_subdomain(hostname, base)
else:
# '*.base' covers exactly one subdomain level
matched = (
hostname.endswith(f".{base}") and "." not in hostname[: -len(base) - 1]
)
if not matched:
return False
if hostutil.is_subdomain(base, "localhost"):
return True # localhost: any scheme, any port
return "://" not in key or key.startswith("https://")
def _signin_summary(in_domain: list[str], rp_id: str) -> str:
"""Compact summary of a domain's in-domain sign-in sites."""
phrases = [_origin_phrase(key, rp_id) for key in sorted(in_domain)]
# Prune entries already covered by a reported wildcard (e.g. the auth
# host under '**.{rp-id}'); http origins outside localhost survive.
wildcards = [k for k in in_domain if wildcard_base(k)]
keys = [
k
for k in in_domain
if wildcard_base(k) or not any(_covered_by_wildcard(k, w) for w in wildcards)
]
phrases = [_origin_phrase(key, rp_id) for key in sorted(keys)]
if len(phrases) > 2:
n = len(phrases) - 1
return f"{phrases[0]}, +{n} site{'s' if n > 1 else ''}"
@@ -95,7 +98,10 @@ def _signin_summary(in_domain: list[str], rp_id: str) -> str:
def print_startup_config(
registry: DomainRegistry, listen: list[str] | None = None
registry: DomainRegistry,
listen: list[str] | None = None,
*,
default_port: int,
) -> None:
"""Print server configuration on startup (one section per domain)."""
# Key graphic with yellow shading (bright for highlights, dark for body)
@@ -106,18 +112,15 @@ def print_startup_config(
domains = sorted(registry.domains, key=lambda d: d.rp_id)
# Format listen endpoints (dev mode only uses the first endpoint)
endpoints = list(parse_endpoints(listen, DEFAULT_PORT))
if DEVMODE:
endpoints = endpoints[:1] # server.run reload=True uses only one
parts = [format_endpoint(ep) for ep in endpoints]
# Endpoints as bound, passed to fastapi_vue for the {listen} field
endpoints = list(parse_endpoints(listen, default_port))
# Header URLs: when a vite dev server is configured, its URL (marked
# "vite dev"); otherwise one per configured auth host (a full origin URL,
# clickable in terminals). If none are configured, guess one domain
# (prefer the shortest https rp_id) and link its /auth/ site path.
# Entries are pre-styled: bold for the URL, plain for any marker.
vite_url = os.environ.get("PASKIA_VITE_URL") if DEVMODE else None
vite_url = env.vite_url if env.dev else None
if vite_url:
header_urls = [f"{w}{vite_url}{r} (vite dev)"]
else:
@@ -138,9 +141,10 @@ def print_startup_config(
rows = []
# Logo lines 4-5 carry the first two header URLs; further URLs go on
# blank-gutter lines beneath the graphic, all at the same column.
# @VERSION@/@LISTEN@ are filled in by fastapi_vue's print_startup_box.
logo = [
f" {b}▄▄▄▄▄{r}",
f"{b}█{y} {b}█{r} Paskia {__version__} @ {' '.join(parts)}",
f"{b}█{y} {b}█{r} Paskia @VERSION@ @ @LISTEN@",
f"{b}█{y} {b}█{y}▄▄▄▄▄▄▄▄▄▄▄▄{r}",
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r}",
f" {y}▀▀▀▀▀{r}",
@@ -156,7 +160,7 @@ def print_startup_config(
rows.append(f"{' ' * URL_COL}{url}")
for domain in domains:
# One compact line per domain; overlong lines are capped at render.
# One compact line per domain.
rp_name = domain.rp_name
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
head = f"{w}{domain.rp_id}{r}{suffix}"
@@ -164,21 +168,22 @@ def print_startup_config(
rows.append(f"{head} — no sign-in sites")
continue
in_domain, related = partition_origins(domain.rp_id, domain.config.origins)
parts = []
phrases = []
if in_domain:
parts.append(_signin_summary(in_domain, domain.rp_id))
parts.extend(_compact_url(origin_url(k)) for k in sorted(related))
phrases.append(_signin_summary(in_domain, domain.rp_id))
phrases.extend(_compact_url(origin_url(k)) for k in sorted(related))
# "with" implies the rp_id itself may sign in (exact key or a full
# wildcard); otherwise the origins are a mere list, after a colon.
covers_self = any(
k == domain.rp_id or k == f"**.{domain.rp_id}" for k in in_domain
)
sep = " with " if covers_self else ": "
rows.append(f"{head}{sep}{' and '.join(parts)}")
rows.append(f"{head}{sep}{' and '.join(phrases)}")
# Size the box to the widest row, capped at BOX_WIDTH.
width = min(BOX_WIDTH, max(_visible_len(t) for t in rows))
out = [top(width)]
out.extend(line(text, width) for text in rows)
out.append(bottom(width))
stderr.write("".join(out))
# fastapi_vue prints the box: version from package metadata, listen
# addresses as bound (localhost expanded to both loopbacks). Braces in
# our content (e.g. an rp-name) are escaped before template formatting.
text = "\n".join(rows)
text = text.replace("{", "{{").replace("}", "}}")
text = text.replace("@VERSION@", "{version}").replace("@LISTEN@", "{listen}")
print_startup_box(text, "paskia.fastapi.mainapp:app", endpoints)
-29
View File
@@ -1,29 +0,0 @@
from ua_parser import parse
def compact_user_agent(ua: str | None) -> str:
"""Format user agent string into a compact display format.
Returns empty string for empty/missing user agents.
Returns original UA for unrecognized ones.
"""
if not ua or not ua.strip() or ua == "-":
return ""
r = parse(ua)
browser = r.user_agent.family if r.user_agent else None
ver = r.user_agent.major if r.user_agent else ""
os_name = r.os.family if r.os else None
dev = r.device.family if r.device else None
# If browser is unrecognized, return original UA
if browser in (None, "Other") and os_name in (None, "Other"):
return ua
# Filter out "Other" values
browser = browser if browser and browser != "Other" else ""
os_name = os_name if os_name and os_name != "Other" else ""
# Exclude device if it's "Other" or matches browser family (parser bug)
if dev in (None, "Other") or dev == browser:
dev = ""
# Build compact string, filtering empty parts
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
result = " ".join(p for p in parts if p).strip()
return result
+11 -5
View File
@@ -1,6 +1,6 @@
"""User information formatting and retrieval logic."""
from paskia import aaguid, db
from paskia import aaguid, satellite
from paskia.db import SessionContext
from paskia.util import avatar, hostutil
from paskia.util.apistructs import (
@@ -43,24 +43,30 @@ async def build_user_info(
ctx: SessionContext | None = None,
) -> ApiUserDetail:
"""Build user info struct for authenticated users."""
user = db.data().users[user_uuid]
data = satellite.store_for_host(request_host)
user = data.users[user_uuid]
normalized_host = hostutil.normalize_host(request_host)
user_sessions = [s for s in data.sessions.values() if s.user_uuid == user_uuid]
user_credentials = [
c for c in data.credentials.values() if c.user_uuid == user_uuid
]
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=session_key,
normalized_host=normalized_host,
)
for s in user.sessions
for s in user_sessions
}
return ApiUserDetail(
user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
credentials={c.uuid: c for c in user.credentials},
credentials={c.uuid: c for c in user_credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
for k, v in aaguid.filter(c.aaguid for c in user.credentials).items()
for k, v in aaguid.filter(c.aaguid for c in user_credentials).items()
},
sessions=sessions,
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
+3 -3
View File
@@ -21,9 +21,9 @@ dependencies = [
"pyjwt[crypto]>=2.11.0",
"jsondiff>=2.2.1",
"msgspec>=0.20.0",
"fastapi-vue~=1.4.2",
"ua-parser[regex]>=1.0.1",
"kanta>=0.7.0",
"fastapi-vue~=1.7.2",
"kanta>=0.9.2",
"uarite>=0.2.1",
]
[dependency-groups]
dev = [
+68 -58
View File
@@ -10,6 +10,7 @@ import subprocess
import sys
from contextlib import suppress
from pathlib import Path
from subprocess import CalledProcessError
from urllib.parse import urlparse
import tracerite
@@ -68,10 +69,13 @@ def build_caddyfile(origins: list[str], viteurl: str, backurl: str) -> str:
return "\n".join(caddyfile_parts)
async def run_caddy(
origins: list[str], viteurl: str, backurl: str
) -> asyncio.subprocess.Process:
"""Start Caddy as HTTPS reverse proxy, wait for ready signal."""
async def run_caddy(origins: list[str], viteurl: str, backurl: str) -> None:
"""Run Caddy as HTTPS reverse proxy for the group's lifetime.
Waits for the ready signal, then drains stderr until Caddy exits or the
task is cancelled (ProcessGroup shutdown), terminating Caddy on exit.
Raises CalledProcessError if Caddy dies, cancelling the group.
"""
caddy_path = shutil.which("caddy")
if not caddy_path:
logger.warning("Caddy not found. Install it to use --caddy option.")
@@ -86,57 +90,56 @@ async def run_caddy(
stdin=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
proc.stdin.write(caddyfile.encode())
await proc.stdin.drain()
proc.stdin.close()
try:
proc.stdin.write(caddyfile.encode())
await proc.stdin.drain()
proc.stdin.close()
# Wait for ready signal or failure
while True:
if proc.returncode is not None:
remaining = await proc.stderr.read()
for line in remaining.decode().splitlines():
if line:
logger.info("caddy: %s", line)
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
raise SystemExit(1)
line = await proc.stderr.readline()
if not line:
continue
decoded = line.decode().rstrip()
if "serving initial configuration" in decoded:
break
# Parse and show errors during startup
if decoded:
try:
log = json.loads(decoded)
level = log.get("level", "")
if level in ("error", "fatal", "warn"):
logger.warning("caddy: %s", log.get("msg", decoded))
except json.JSONDecodeError:
if "error" in decoded.lower() or "fatal" in decoded.lower():
logger.warning("caddy: %s", decoded)
# Start background task to drain stderr
async def drain_caddy_stderr():
# Wait for ready signal or failure
while True:
if proc.returncode is not None:
await log_caddy_stderr(proc.stderr, starting=True)
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
raise CalledProcessError(proc.returncode, cmd)
line = await proc.stderr.readline()
if not line:
break
decoded = line.decode().rstrip()
if decoded:
try:
log = json.loads(decoded)
level = log.get("level", "")
if level in ("error", "fatal", "warn"):
logger.warning("caddy: %s", log.get("msg", decoded))
except json.JSONDecodeError:
pass # Ignore non-JSON output after startup
continue
asyncio.create_task(drain_caddy_stderr())
return proc
decoded = line.decode().rstrip()
if "serving initial configuration" in decoded:
break
log_caddy_line(decoded, starting=True)
# Drain stderr until Caddy exits
await proc.wait()
await log_caddy_stderr(proc.stderr)
raise CalledProcessError(proc.returncode, cmd)
finally:
with suppress(ProcessLookupError):
proc.terminate()
await proc.wait()
def log_caddy_line(decoded: str, *, starting: bool = False) -> None:
"""Log one Caddy stderr line (JSON during/after startup)."""
if not decoded:
return
try:
log = json.loads(decoded)
level = log.get("level", "")
if level in ("error", "fatal", "warn"):
logger.warning("caddy: %s", log.get("msg", decoded))
except json.JSONDecodeError:
if starting and ("error" in decoded.lower() or "fatal" in decoded.lower()):
logger.warning("caddy: %s", decoded)
async def log_caddy_stderr(stream: asyncio.StreamReader, *, starting: bool = False) -> None:
"""Drain and log remaining Caddy stderr."""
while line := await stream.readline():
log_caddy_line(line.decode().rstrip(), starting=starting)
def _split_multi(values: list[str] | None) -> list[str]:
@@ -204,22 +207,25 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
caddy_origins.append(f"https://{rp_id}")
seen: set = set()
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
pg._procs.append(caddy_proc)
pg._cmds[caddy_proc.pid] = "caddy"
pg.create_task(run_caddy(caddy_origins, viteurl, backurl))
pg.create_task(check_ports_free(viteurl, backurl))
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
await check_ports_free(viteurl, backurl)
await pg.spawn(*paskia)
await pg.spawn(*paskia, vital=True)
await pg.wait(
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
)
await pg.spawn(*vite, cwd=frontend_path)
await pg.spawn(*vite, cwd=frontend_path, vital=True)
def main():
tracerite.load()
parser = argparse.ArgumentParser(add_help=False)
parser = argparse.ArgumentParser(
add_help=False,
description="Run Vite and FastAPI development servers",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=HELP_EPILOG,
)
parser.add_argument(
"-l",
"--listen",
@@ -243,8 +249,12 @@ def main():
)
args, remaining = parser.parse_known_args()
with suppress(KeyboardInterrupt):
try:
asyncio.run(run_devserver(args, remaining))
except* KeyboardInterrupt:
pass # user stopped the devserver: normal exit
except* subprocess.SubprocessError, RuntimeError:
raise SystemExit(1) from None # logged in devutil already; exit 1
HELP_EPILOG = """
+11 -4
View File
@@ -11,20 +11,27 @@ from pathlib import Path
MIN_NODE_VERSION = 20
class _PrefixFormatter(logging.Formatter):
"""Formatter that adds prefix based on log level."""
class _Formatter(logging.Formatter):
"""Prefix formatter, intentionally different from fastapi_vue.logging.
INFO and below pass through unprefixed so messages can use their own
markings (>>>, ###); WARNING and above get an emoji prefix.
"""
def format(self, record: logging.LogRecord) -> str:
if record.levelno >= logging.ERROR:
return f"🛑 {record.getMessage()}"
if record.levelno >= logging.WARNING:
return f"⚠️ {record.getMessage()}"
return f"💣 {record.getMessage()}"
return record.getMessage()
_handler = logging.StreamHandler()
_handler.setFormatter(_PrefixFormatter())
_handler.setFormatter(_Formatter())
logger = logging.getLogger("fastapi-vue")
logger.addHandler(_handler)
logger.setLevel(logging.INFO)
logger.propagate = False # own handler; do not double-print via a configured root
def _check_node_version(node_path: str) -> None:
+75 -95
View File
@@ -1,108 +1,87 @@
# ruff: noqa: INP001
"""Utilities meant for devserver script, used only in source repository with dev deps."""
from __future__ import annotations
import asyncio
import subprocess
import sys
from asyncio.subprocess import Process
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from subprocess import CalledProcessError
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
if TYPE_CHECKING:
from collections.abc import Coroutine
from collections.abc import Awaitable
class ProcessGroup:
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
class ProcessGroup(asyncio.TaskGroup):
"""TaskGroup with structured ownership of async subprocesses."""
def __init__(self) -> None:
"""Initialize empty process tracking."""
self._procs: list[asyncio.subprocess.Process] = []
self._cmds: dict[int, str] = {} # pid -> command name
def __init__(self, *, terminate_timeout: float = 10) -> None:
"""Set the grace period before terminate() escalates to kill()."""
super().__init__()
self._terminate_timeout = terminate_timeout
self._cmds: dict[Process, tuple[str, ...]] = {}
async def spawn(
self,
*cmd: str,
cwd: str | None = None,
) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it."""
cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc
self, *cmd: str, cwd: str | None = None, vital: bool = False
) -> Process:
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
async def wait(
self,
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
) -> None:
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
async def run() -> None:
name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
try:
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._cmds[proc] = cmd
started.set_result(proc)
except Exception as e: # noqa: BLE001
started.set_exception(e)
return
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
returncode = await proc.wait()
if returncode != 0:
cmd_name = self._cmds.get(proc.pid, "unknown")
raise subprocess.CalledProcessError(returncode, cmd_name)
tasks = [
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
for w in waitables
]
try:
await asyncio.gather(*tasks)
except subprocess.CalledProcessError as e:
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
raise SystemExit(1) from None
async def __aenter__(self) -> Self:
"""Enter the async context manager."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
"""Wait for one process to exit, terminate others, then wait for all."""
await self._cleanup(immediate=exc_type is not None)
async def _cleanup(self, *, immediate: bool = False) -> None:
running = [p for p in self._procs if p.returncode is None]
if not running:
return
if not immediate:
# Wait for any one process to exit
with suppress(asyncio.CancelledError):
await asyncio.wait(
[asyncio.create_task(p.wait()) for p in running],
return_when=asyncio.FIRST_COMPLETED,
)
# Terminate remaining processes
for p in self._procs:
if p.returncode is None:
try:
returncode = await proc.wait()
finally:
with suppress(ProcessLookupError):
p.terminate()
# Wait for all to finish (with overall timeout), shielded from cancellation
still_running = [p for p in self._procs if p.returncode is None]
if still_running:
with suppress(asyncio.CancelledError):
proc.terminate()
try:
await asyncio.shield(
asyncio.wait_for(
asyncio.gather(*[p.wait() for p in still_running]),
timeout=10,
),
)
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
except TimeoutError:
for p in self._procs:
if p.returncode is None:
with suppress(ProcessLookupError):
p.kill()
await p.wait()
with suppress(ProcessLookupError):
proc.kill()
await proc.wait()
if vital:
logger.warning("Vital process %s exited", name)
raise CalledProcessError(returncode, cmd)
started = asyncio.get_running_loop().create_future()
self.create_task(run())
return await asyncio.shield(started)
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
"""Wait concurrently and return results in argument order."""
async def task(w: Process | Awaitable) -> Any: # noqa: ANN401
if not isinstance(w, Process):
return await w
if retcode := await w.wait():
cmd = self._cmds[w]
logger.warning(
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
)
raise CalledProcessError(retcode, cmd)
return retcode
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(task(w)) for w in waitables]
return tuple(task.result() for task in tasks)
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
@@ -128,42 +107,43 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
writer.close()
except OSError, EOFError, ValueError, TimeoutError:
return None
for line in data.decode("latin-1").split("\r\n"):
for line in data.decode(errors="replace").split("\r\n"):
if line.lower().startswith("server:"):
return line.split(":", 1)[1].strip()
return line[7:].strip()
return ""
async def check_ports_free(*urls: str) -> None:
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
"""Verify URLs are not responding (ports are free).
async def check(url: str) -> None:
server = await http_get_server(url, timeout=0.1)
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
RuntimeError (handled like a failed process) if any URL responds.
"""
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
for url, server in zip(urls, servers, strict=True):
if server is not None:
logger.warning(
logger.error(
"Conflicting %s already running at %s", server or "server", url
)
raise SystemExit(1)
await asyncio.gather(*[check(url) for url in urls])
raise RuntimeError(url)
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
"""Wait for the server to be ready by polling an endpoint.
Use empty path to disable the check and make this return immediately.
Raises SystemExit(1) if server doesn't start in time.
Logs, then raises RuntimeError if the server doesn't start in time.
"""
if not path:
return
for attempt in range(max_attempts):
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
logger.info("✓ Backend ready!")
logger.info("🟢 Backend ready!")
return
if attempt == max_attempts - 1:
logger.warning("Backend didn't start in time")
raise SystemExit(1)
logger.error("Backend at %s didn't start in time", url)
raise RuntimeError(url)
await asyncio.sleep(0.1)
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env -S uv run
"""Release build for paskia (PyPI) and paskia-js (npm).
Usage: release.py [patch|minor|major] (default: patch)
Bumps the version from the latest vX.Y.Z tag, commits "Release x.y.z" with
the paskia-js version bump and tags it, then builds both packages from a
clean slate. On failure the release commit and tag are rolled back.
Publishing is left to the user; the command is printed on success.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Build outputs removed before building
ARTIFACTS = ["dist", "paskia-js/dist", "paskia/frontend-build", "build"]
# Dependency state removed for a fresh upstream resolve (all untracked)
JS_DIRS = ["paskia-js", "frontend"]
JS_JUNK = ["node_modules", "package-lock.json", "deno.lock", "bun.lock"]
BUMPS = ("patch", "minor", "major")
def run(cmd: list[str], cwd: Path = REPO_ROOT) -> None:
print(f"### {' '.join(cmd)}")
subprocess.run(cmd, cwd=cwd, check=True) # noqa: S603
def abort(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
raise SystemExit(1)
def git(*args: str) -> str:
return subprocess.run( # noqa: S603
["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, text=True
).stdout.strip()
def check_clean_tree() -> None:
status = git("status", "--porcelain")
if status:
print(status, file=sys.stderr)
abort("working tree is not clean; commit or stash all changes first")
def latest_version() -> tuple[int, int, int]:
"""Highest vX.Y.Z tag, as a tuple."""
tags = []
for tag in git("tag", "--list", "v*").splitlines():
parts = tag.removeprefix("v").split(".")
if len(parts) == 3 and all(p.isdigit() for p in parts):
tags.append(tuple(int(p) for p in parts))
if not tags:
abort("no existing vX.Y.Z tags found")
return max(tags)
def next_version(bump: str) -> tuple[int, int, int]:
major, minor, patch = latest_version()
if bump == "major":
return (major + 1, 0, 0)
if bump == "minor":
return (major, minor + 1, 0)
return (major, minor, patch + 1)
def set_js_version(version: str) -> None:
pkg_path = REPO_ROOT / "paskia-js/package.json"
pkg = json.loads(pkg_path.read_text())
if pkg.get("version") == version:
return
print(f"paskia-js/package.json: {pkg.get('version')} -> {version}")
pkg["version"] = version
pkg_path.write_text(json.dumps(pkg, indent=2) + "\n")
def remove(path: Path, rel: str) -> None:
if not path.exists():
return
print(f"rm -rf {rel}")
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
def clean() -> None:
for rel in ARTIFACTS:
remove(REPO_ROOT / rel, rel)
for d in JS_DIRS:
for junk in JS_JUNK:
remove(REPO_ROOT / d / junk, f"{d}/{junk}")
def main() -> None:
bump = sys.argv[1] if len(sys.argv) == 2 else "patch"
if len(sys.argv) > 2 or bump not in BUMPS:
print(__doc__)
raise SystemExit(1)
check_clean_tree()
version_tuple = next_version(bump)
version = ".".join(str(p) for p in version_tuple)
tag = f"v{version}"
if tag in git("tag", "--list", tag).splitlines():
abort(f"tag {tag} already exists")
print(f"Release version: {version}")
previous_head = git("rev-parse", "HEAD")
released = False
try:
# Clean before committing: only the uv build (hatch-vcs) depends on
# the tag, so the release commit can be made from a clean slate.
clean()
set_js_version(version)
run(["git", "add", "paskia-js/package.json"])
run(["git", "commit", "-m", f"Release {version}"])
run(["git", "tag", tag])
released = True
# uv build runs the hatch hook that builds paskia-js and the Vue
# frontend into paskia/frontend-build with fresh dependencies.
run(["uv", "build"])
# Explicit paskia-js build: verifies the package standalone and
# leaves paskia-js/dist ready for npm publish.
run(["npm", "install"], cwd=REPO_ROOT / "paskia-js")
run(["npm", "run", "build"], cwd=REPO_ROOT / "paskia-js")
except BaseException:
if released:
print("Build failed; rolling back the release commit and tag.", file=sys.stderr)
subprocess.run(["git", "tag", "-d", tag], cwd=REPO_ROOT, check=False) # noqa: S603
# The tree was clean before the release commit, so a hard reset
# back to it is safe.
subprocess.run(["git", "reset", "--hard", previous_head], cwd=REPO_ROOT, check=False) # noqa: S603
raise
# Push the release commit to the tracking remote, then the new tag.
# Not rolled back on failure: the local release is intact, just push again.
try:
run(["git", "push"])
run(["git", "push", "--tags"])
except subprocess.CalledProcessError:
abort("push failed; the release commit and tag exist locally, push manually")
print(f"\nBuild completed successfully for version {version}.")
print("To publish, review the artifacts and run:")
print("\nuv publish && cd paskia-js && npm publish")
if __name__ == "__main__":
main()
+16 -4
View File
@@ -172,6 +172,7 @@ def test_serve_uses_stored_config(run_cli, tmp_path):
assert calls["listen"] is None # stored listen (None) used
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
assert serve.listen is None
assert serve.save is False
def test_serve_listen_override_not_persisted(run_cli, tmp_path):
@@ -181,24 +182,33 @@ def test_serve_listen_override_not_persisted(run_cli, tmp_path):
assert calls["listen"] == ["4403"]
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
assert serve.listen == ["4403"]
assert serve.save is False
# Stored config keeps the original listen value
assert stored_config(tmp_path).listen == ["4402"]
def test_serve_listen_save_persists(run_cli, tmp_path):
"""--save teleports the save flag; the app persists, the CLI is read-only."""
run_cli("init", "--listen", "4402")
calls = run_cli("--listen", "4403", "--save")
assert calls["listen"] == ["4403"]
assert stored_config(tmp_path).listen == ["4403"]
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
assert serve.listen == ["4403"]
assert serve.save is True
# The CLI itself does not write the database
assert stored_config(tmp_path).listen == ["4402"]
def test_serve_listen_save_clear(run_cli, tmp_path):
"""--listen "" --save clears the stored endpoints (back to default)."""
"""--listen "" --save teleports a clear (back to default) for the app."""
run_cli("init", "--listen", "4402")
run_cli("--listen", "", "--save")
assert stored_config(tmp_path).listen is None
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
assert serve.listen is None
assert serve.save is True
assert stored_config(tmp_path).listen == ["4402"]
def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path):
@@ -314,7 +324,9 @@ def test_migrate_merges_kantadb_into_existing_database(run_cli, tmp_path):
def test_migrate_merge_label_combines_rp_ids(run_cli, tmp_path):
"""A multi-domain source merges in one transaction, rp-ids slash-joined."""
run_cli("init", "company.com")
src = write_kantadb(tmp_path, {"one.com": "One", "two.com": "Two"}, name="x.kantadb")
src = write_kantadb(
tmp_path, {"one.com": "One", "two.com": "Two"}, name="x.kantadb"
)
run_cli("migrate", str(src))
+16
View File
@@ -941,6 +941,22 @@ class TestLegacyConversion:
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
assert config.domains["example.com"].origins == {"**.example.com": True}
def test_convert_auth_host_with_empty_origins_keeps_wildcard(self, tmp_path):
"""A dedicated auth host with no configured origins still allowed
the whole rp-id domain in the legacy format — the auth host must
not become the only allowed origin."""
src_file = tmp_path / "main.db"
asyncio.run(
_write_legacy(
src_file,
LegacyConfig(rp_id="example.com", auth_host="auth.example.com"),
)
)
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
origins = config.domains["example.com"].origins
assert origins["**.example.com"] is True
assert origins["auth.example.com"] == OriginEntry(auth_host=True)
# -------------------------------------------------------------------------
# Transaction log censoring
+835
View File
@@ -0,0 +1,835 @@
"""Tests for remote (satellite) domains: config, links, replica, feed."""
import secrets
import time
from datetime import UTC, datetime
from uuid import UUID
import httpx
import msgspec
import pytest
import pytest_asyncio
from fastapi import Response
import paskia.db.operations as ops_db
from paskia import domains, satellite, syncfeed
from paskia.db.structs import (
Config,
Credential,
DomainConfig,
OriginEntry,
RemoteConfig,
Session,
Uplink,
User,
)
from paskia.fastapi import sync
from paskia.fastapi.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME
from paskia.util.apistructs import (
ApiOrgContext,
ApiRoleContext,
ApiSessionContext,
ApiUserContext,
)
from paskia.util.crypto import hash_secret
from .conftest import TEST_RP_ID, create_test_session
REMOTE_URL = "http://remote.test"
UPLINK_ID = "up1"
def _api_ctx(display_name="Remote Admin", permissions=("auth:admin",)):
"""An upstream-pushed per-session context, as embedded in session events."""
return ApiSessionContext(
user=ApiUserContext(uuid=UUID(int=104), display_name=display_name),
org=ApiOrgContext(uuid=UUID(int=101), display_name="Org"),
role=ApiRoleContext(uuid=UUID(int=103), display_name="Admins"),
permissions=list(permissions),
)
def _uplink(**kw) -> Uplink:
uplink = Uplink(url=REMOTE_URL, created=datetime.now(UTC), key=b"k", **kw)
uplink.uplink_id = UPLINK_ID
return uplink
def _remote_domain_config(**kw) -> Config:
return Config(
domains={
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True}),
"example.com": DomainConfig(
origins={
"**.example.com": True,
"auth.example.com": OriginEntry(auth_host=True),
},
remote=RemoteConfig(link=UPLINK_ID, **kw),
),
}
)
def test_remote_domain_valid():
domains.validate_config(_remote_domain_config())
def test_remote_domain_requires_auth_host():
config = _remote_domain_config()
config.domains["example.com"].origins = {"**.example.com": True}
with pytest.raises(ValueError, match="auth host"):
domains.validate_config(config)
def test_sanitize_preserves_remote():
config, warnings = domains.sanitize_config(_remote_domain_config())
assert not warnings
assert config.domains["example.com"].remote.link == UPLINK_ID
def test_sanitize_drops_remote_without_link():
"""A remote config pointing nowhere (e.g. a v1 leftover) degrades to a
plain local domain instead of failing to serve."""
config = _remote_domain_config()
config.domains["example.com"].remote = RemoteConfig()
sanitized, warnings = domains.sanitize_config(config)
assert any("remote dropped" in w for w in warnings)
assert sanitized.domains["example.com"].remote is None
domains.validate_config(sanitized)
def test_apply_upsert_and_delete():
replica = satellite.ReplicaDB()
cred = Credential.create(
credential_id=b"cid",
user=UUID(int=1),
aaguid=UUID(int=0),
public_key=b"pk",
sign_count=0,
rp_id="example.com",
)
cred.uuid = UUID(int=2)
satellite._apply(replica, "credentials", str(cred.uuid), _builtins(cred))
assert replica.credentials[cred.uuid].public_key == b"pk"
satellite._apply(replica, "credentials", str(cred.uuid), None)
assert not replica.credentials
def _builtins(obj):
return msgspec.to_builtins(obj)
def test_apply_session_roundtrip():
"""Sessions keep their string key and datetime/UUID fields; the
embedded ctx lands in the replica's context map (and goes with it)."""
replica = satellite.ReplicaDB()
session = Session.create(
user=UUID(int=1),
credential=UUID(int=2),
key=hash_secret("cookie", "sekret"),
host="app2.example.com",
ip="127.0.0.1",
user_agent="ua",
validated=datetime.now(UTC),
rp_id="example.com",
)
fields = _builtins(session)
fields["ctx"] = _builtins(_api_ctx())
satellite._apply(replica, "sessions", session.key, fields)
stored = replica.sessions[session.key]
assert stored.host == "app2.example.com"
assert stored.validated == session.validated
assert stored.user_uuid == UUID(int=1)
assert replica.session_contexts[session.key].user.display_name == "Remote Admin"
satellite._apply(replica, "sessions", session.key, None)
assert not replica.sessions
assert not replica.session_contexts
def test_apply_credential_bytes_roundtrip():
"""credential_id/public_key are bytes over the wire (base64 in JSON)."""
replica = satellite.ReplicaDB()
cred = Credential.create(
credential_id=secrets.token_bytes(32),
user=UUID(int=1),
aaguid=UUID(int=0),
public_key=secrets.token_bytes(64),
sign_count=3,
rp_id="example.com",
)
cred.uuid = UUID(int=9)
# Simulate the full wire path: builtins -> JSON -> builtins
wire = msgspec.json.decode(msgspec.json.encode(_builtins(cred)))
satellite._apply(replica, "credentials", str(cred.uuid), wire)
stored = replica.credentials[cred.uuid]
assert stored.credential_id == cred.credential_id
assert stored.public_key == cred.public_key
assert stored.sign_count == 3
def test_feed_emit_to_subscribers():
queue = syncfeed.subscribe()
try:
cred = Credential.create(
credential_id=b"c",
user=UUID(int=1),
aaguid=UUID(int=0),
public_key=b"p",
sign_count=0,
rp_id="example.com",
)
syncfeed.emit("credentials", "k1", cred)
syncfeed.emit("credentials", "k1", None)
assert queue.get_nowait()["fields"]["sign_count"] == 0
assert queue.get_nowait()["fields"] is None
finally:
syncfeed.unsubscribe(queue)
def test_feed_drops_full_queue():
queue = syncfeed.subscribe()
try:
for i in range(1001):
syncfeed.emit("credentials", f"k{i}", None)
assert queue.qsize() == 1000
syncfeed.emit("credentials", "k1001", None) # subscriber already dropped
assert queue.qsize() == 1000
finally:
syncfeed.unsubscribe(queue)
@pytest.mark.asyncio
async def test_operations_repush_session_contexts(test_db, test_user, test_credential):
"""User-graph changes are never published; instead the affected
sessions are re-pushed with a freshly computed ctx."""
queue = syncfeed.subscribe()
try:
key, _ = create_test_session(
user_uuid=test_user.uuid, credential_uuid=test_credential.uuid
)
_drain(queue) # the session store itself emits (with ctx)
ops_db.update_user_display_name(test_user.uuid, "Renamed")
events = _drain(queue)
assert all(e["table"] != "users" for e in events)
repushed = [e for e in events if e["table"] == "sessions" and e["key"] == key]
assert repushed
assert repushed[-1]["fields"]["ctx"]["user"]["display_name"] == "Renamed"
finally:
syncfeed.unsubscribe(queue)
@pytest.mark.asyncio
async def test_replica_refresh_and_evict():
replica = satellite.UplinkReplica(_uplink())
token = secrets.token_urlsafe(12)
session = Session.create(
user=UUID(int=1),
credential=UUID(int=2),
key=hash_secret("cookie", token),
host="app2.example.com",
ip="1.1.1.1",
user_agent="ua",
validated=datetime(2020, 1, 1, tzinfo=UTC),
)
replica.db.sessions[session.key] = session
now = datetime.now(UTC)
replica.refresh_session(session.key, now, "2.2.2.2", "new-ua")
assert replica.db.sessions[session.key].validated == now
queued = replica._pending_refresh[session.key]
assert queued["type"] == "session_refresh"
assert queued["ip"] == "2.2.2.2"
# Host-keyed dispatch eviction (the replica's uplink is resolved by host)
domains.configure(listen=["localhost:4401"])
domains.init_registry(_remote_domain_config())
satellite.manager.replicas[UPLINK_ID] = replica
satellite.evict_session(token, "app2.example.com")
assert not replica.db.sessions
satellite.manager.replicas.pop(UPLINK_ID)
def test_availability_gate():
replica = satellite.UplinkReplica(_uplink(cache_ttl=60))
assert not replica.available() # never synced
replica.last_contact = time.monotonic()
assert replica.available()
# -------------------------------------------------------------------------
# Link URLs and registration offers
# -------------------------------------------------------------------------
def test_parse_link_url():
base, key = satellite.parse_link_url(
"wss://alpha.bravo.charlie.delta.echo@auth.example.com"
)
assert base == "https://auth.example.com"
assert key == "alpha.bravo.charlie.delta.echo"
base, key = satellite.parse_link_url("ws://k3y@127.0.0.1:4401")
assert base == "http://127.0.0.1:4401"
assert key == "k3y"
with pytest.raises(ValueError, match="wss"):
satellite.parse_link_url("https://alpha.bravo@auth.example.com")
with pytest.raises(ValueError, match="key"):
satellite.parse_link_url("wss://auth.example.com")
def test_offer_mint_and_spend():
"""Offers are wss URLs with a RAM-only, single-use, expiring key."""
url = sync.create_offer(["example.com"], "https://auth.example.com")
assert url.startswith("wss://")
assert url.endswith("@auth.example.com")
key = url.split("://", 1)[1].split("@", 1)[0]
offer = sync._pop_offer(key)
assert offer is not None and offer.rp_ids == ["example.com"]
assert sync._pop_offer(key) is None # single-use
assert sync._pop_offer("unknown.key.here") is None
expired = sync.create_offer([], "http://127.0.0.1:4401")
assert expired.startswith("ws://")
key = expired.split("://", 1)[1].split("@", 1)[0]
sync._offers[key].created -= sync.OFFER_TTL + 1
assert sync._pop_offer(key) is None # expired
# -------------------------------------------------------------------------
# Pushed-domain application (satellite side)
# -------------------------------------------------------------------------
_PUSHED = {
"rp_id": "example.com",
"rp_name": "Example",
"origins": {"**.example.com": True, "auth.example.com": {"auth_host": True}},
}
@pytest.mark.asyncio
async def test_apply_domains_push_accept_and_remove(test_db, domain_registry):
"""A push persists the accepted set (marked remote via the link); a
later push without the domain removes it."""
rejected = satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
assert not rejected
domain = test_db.config.domains["example.com"]
assert domain.remote.link == UPLINK_ID
assert domain.rp_name == "Example"
assert domains.registry().get("example.com").remote is not None
# Idempotent: the same push again changes nothing
assert not satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
# The uplink's scope shrank: the domain leaves the local config
rejected = satellite.apply_domains_push(UPLINK_ID, [])
assert not rejected
assert "example.com" not in test_db.config.domains
assert domains.registry().get("example.com") is None
@pytest.mark.asyncio
async def test_apply_domains_push_rejects_local_conflict(test_db, domain_registry):
"""A pushed rp-id that this instance serves locally is rejected."""
rejected = satellite.apply_domains_push(
UPLINK_ID, [{**_PUSHED, "rp_id": TEST_RP_ID}]
)
assert TEST_RP_ID in rejected
assert test_db.config.domains[TEST_RP_ID].remote is None
@pytest.mark.asyncio
async def test_apply_domains_push_rejects_auth_host_collision(test_db, domain_registry):
"""A pushed auth host that collides with a local rp-id is rejected
(the merged config must stay servable)."""
rejected = satellite.apply_domains_push(
UPLINK_ID,
[
{
"rp_id": "example.com",
"rp_name": "Example",
"origins": {"**.example.com": True, TEST_RP_ID: {"auth_host": True}},
}
],
)
assert "example.com" in rejected
assert "example.com" not in test_db.config.domains
# -------------------------------------------------------------------------
# API-level: endpoints served from an injected replica
# -------------------------------------------------------------------------
def _replica_db() -> tuple[satellite.ReplicaDB, str]:
"""A replica holding one credential/session and its pushed context."""
replica = satellite.ReplicaDB()
cred = Credential.create(
credential_id=b"cid",
user=UUID(int=104),
aaguid=UUID(int=0),
public_key=b"pk",
sign_count=0,
rp_id="example.com",
)
cred.uuid = UUID(int=105)
replica.credentials[cred.uuid] = cred
secret = secrets.token_urlsafe(12)
session = Session.create(
user=UUID(int=104),
credential=cred.uuid,
key=hash_secret("cookie", secret),
host="app2.example.com",
ip="127.0.0.1",
user_agent="pytest",
validated=datetime.now(UTC),
rp_id="example.com",
)
replica.sessions[session.key] = session
replica.session_contexts[session.key] = _api_ctx()
return replica, secret
@pytest_asyncio.fixture
async def remote_client(test_db):
"""ASGI client with example.com as a remote domain on a warm replica."""
config = _remote_domain_config()
domains.configure(listen=["localhost:4401"])
domains.init_registry(config)
ops_db.create_uplink(UPLINK_ID, _uplink(), {})
replica_db, secret = _replica_db()
replica = satellite.UplinkReplica(_uplink())
replica.db = replica_db
replica.last_contact = time.monotonic()
replica.connected = True
satellite.manager.replicas[UPLINK_ID] = replica
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://localhost:4401"
) as client:
yield client, secret, replica
satellite.manager.replicas.pop(UPLINK_ID, None)
@pytest.mark.asyncio
async def test_forward_served_from_replica(remote_client):
client, secret, _ = remote_client
r = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
)
assert r.status_code == 204
assert r.headers["remote-name"] == "Remote Admin"
assert r.headers["remote-groups"] == "auth:admin"
@pytest.mark.asyncio
async def test_forward_replica_denies_missing_perm(remote_client):
client, secret, _ = remote_client
r = await client.get(
"/auth/api/forward?perm=other:scope",
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
)
assert r.status_code == 403
@pytest.mark.asyncio
async def test_validate_renews_locally_and_queues_writebehind(remote_client):
client, secret, replica = remote_client
session = next(iter(replica.db.sessions.values()))
session.validated = datetime(2020, 1, 1, tzinfo=UTC) # force refresh threshold
r = await client.post(
"/auth/api/validate",
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
)
assert r.status_code == 200
assert r.json()["renewed"] is True
assert session.validated.year > 2020 # applied to the replica
queued = replica._pending_refresh[session.key]
assert queued["type"] == "session_refresh"
@pytest.mark.asyncio
async def test_remote_domain_503_when_replica_stale(remote_client):
client, secret, replica = remote_client
replica.connected = False
replica.last_contact = 0
r = await client.get(
"/auth/api/forward",
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
)
assert r.status_code == 503
@pytest.mark.asyncio
async def test_logout_proxied_and_evicted(remote_client, monkeypatch):
client, secret, replica = remote_client
async def fake_forward(request):
return Response(status_code=200, content=b'{"message": "Logged out"}')
monkeypatch.setattr(satellite, "forward_request", fake_forward)
r = await client.post(
"/auth/api/logout",
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
)
assert r.status_code == 200
assert not replica.db.sessions # evicted optimistically
assert not replica.db.session_contexts
@pytest.mark.asyncio
async def test_admin_rejects_remote_writes(client, session_token, test_db):
"""Remote domains are uplink-managed: the domains API refuses to
create, edit or delete them."""
headers = {
"Host": "localhost:4401",
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
}
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"origins": {
"**.example.com": True,
"auth.example.com": {"auth_host": True},
},
"remote": {"link": "x"},
},
headers=headers,
)
assert r.status_code == 400
assert "uplink" in r.json()["detail"]
# An existing remote domain (as an uplink push would have stored it)
ops_db.apply_uplink_domains(
UPLINK_ID,
{
"example.com": DomainConfig(
rp_name="Example",
origins={
"**.example.com": True,
"auth.example.com": OriginEntry(auth_host=True),
},
remote=RemoteConfig(link=UPLINK_ID),
)
},
)
r = await client.patch(
"/auth/api/admin/domains/example.com",
json={"rp_name": "Ex", "origins": {"**.example.com": True}},
headers=headers,
)
assert r.status_code == 400
assert "uplink" in r.json()["detail"]
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
assert r.status_code == 400
assert "unlink" in r.json()["detail"]
def test_feed_scoped_subscription_filters():
"""A scoped subscriber gets only its rp-ids' sessions/credentials
(upserts filtered, deletes passed); the user graph is never published."""
scoped = syncfeed.subscribe({"example.com"})
try:
in_scope = Session.create(
user=UUID(int=1),
credential=UUID(int=2),
key="k1",
host="app2.example.com",
ip="1.1.1.1",
user_agent="ua",
validated=datetime.now(UTC),
rp_id="example.com",
)
out_scope = Session.create(
user=UUID(int=1),
credential=UUID(int=3),
key="k2",
host="localhost",
ip="1.1.1.1",
user_agent="ua",
validated=datetime.now(UTC),
rp_id="localhost",
)
syncfeed.emit("sessions", "k1", in_scope)
syncfeed.emit("sessions", "k2", out_scope)
syncfeed.emit("users", "u1", User.create(display_name="A", role=UUID(int=1)))
syncfeed.emit("sessions", "k2", None) # deletes pass regardless
assert [e["key"] for e in _drain(scoped)] == ["k1", "k2"]
finally:
syncfeed.unsubscribe(scoped)
def test_feed_scope_updates_live():
"""The subscriber scope is mutable in place: a link's scope adjustment
takes effect without a reconnect."""
scope = {"example.com"}
queue = syncfeed.subscribe(scope)
try:
session = Session.create(
user=UUID(int=1),
credential=UUID(int=2),
key="k1",
host="localhost",
ip="1.1.1.1",
user_agent="ua",
validated=datetime.now(UTC),
rp_id="localhost",
)
syncfeed.emit("sessions", "k1", session)
assert not _drain(queue)
scope.add("localhost") # scope widened live
syncfeed.emit("sessions", "k1", session)
assert [e["key"] for e in _drain(queue)] == ["k1"]
finally:
syncfeed.unsubscribe(queue)
def _drain(queue):
items = []
while not queue.empty():
items.append(queue.get_nowait())
return items
@pytest.mark.asyncio
async def test_settings_reports_remote_flag(remote_client):
client, _, _ = remote_client
r = await client.get("/auth/api/settings", headers={"Host": "app2.example.com"})
assert r.status_code == 200
assert r.json()["remote"] is True
r = await client.get("/auth/api/settings", headers={"Host": "localhost:4401"})
assert r.json()["remote"] is False
@pytest.mark.asyncio
async def test_logout_remote_down_returns_503(remote_client, monkeypatch):
"""A transport failure on a proxied call is a deliberate 503, not a 500."""
client, secret, replica = remote_client
class Down:
async def request(self, *args, **kwargs):
raise httpx.ConnectError("down")
monkeypatch.setitem(satellite._clients, REMOTE_URL, Down())
r = await client.post(
"/auth/api/logout",
headers={"Host": "app2.example.com", "Cookie": f"{AUTH_COOKIE_NAME}={secret}"},
)
assert r.status_code == 503
# Not evicted: the remote never saw the logout, the session is still valid
assert replica.db.sessions
@pytest.mark.asyncio
async def test_user_api_proxied_on_remote_auth_host(remote_client, monkeypatch):
"""Profile traffic for a remote domain's auth host is forwarded, never
written to the satellite's local database."""
client, _, _ = remote_client
async def fake_forward(request):
return Response(status_code=200, content=b"proxied")
monkeypatch.setattr(satellite, "forward_request", fake_forward)
r = await client.get(
f"/auth/api/user/{UUID(int=104)}/profile.webp",
headers={"Host": "auth.example.com"},
)
assert r.status_code == 200
assert r.content == b"proxied"
# -------------------------------------------------------------------------
# Satellite links and uplinks (admin API)
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_admin_satellite_link_lifecycle(client, session_token, test_db):
"""Offer minting (auth host required), scope adjustment, revocation."""
headers = {
"Host": "localhost:4401",
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
}
r = await client.post(
"/auth/api/admin/domains/",
json={
"rp_id": "example.com",
"rp_name": "Example",
"origins": {"**.example.com": True},
},
headers=headers,
)
assert r.status_code == 200, r.text
# No auth host yet — the domain cannot be scoped into a link
r = await client.post(
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
)
assert r.status_code == 400
assert "auth host" in r.json()["detail"]
r = await client.patch(
"/auth/api/admin/domains/example.com",
json={
"rp_name": "Example",
"origins": {
"**.example.com": True,
"auth.example.com": {"auth_host": True},
},
},
headers=headers,
)
assert r.status_code == 200, r.text
# Mint an offer: a wss URL carrying the RAM-only initial key
r = await client.post(
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
)
assert r.status_code == 200, r.text
body = r.json()
assert body["url"].startswith("wss://")
assert "@auth.example.com" in body["url"]
key = body["url"].split("://", 1)[1].split("@", 1)[0]
assert key.count(".") == 4 # five-word passphrase
assert body["expires_in"] > 0
# Unknown domains and nested scopes are refused
r = await client.post(
"/auth/api/admin/satellites/",
json={"rp_ids": ["nope.example"]},
headers=headers,
)
assert r.status_code == 400
# Register a link directly (the handshake itself is covered live),
# then list, adjust scope, revoke
ops_db.create_satellite_link("ln1", ["example.com"], b"key", label="sat-1")
r = await client.get("/auth/api/admin/satellites/", headers=headers)
assert r.status_code == 200
(entry,) = r.json()
assert entry["id"] == "ln1"
assert entry["label"] == "sat-1"
assert entry["rp_ids"] == ["example.com"]
assert entry["connected"] is False
r = await client.patch(
"/auth/api/admin/satellites/ln1", json={"rp_ids": []}, headers=headers
)
assert r.status_code == 200
assert test_db.satellite_links["ln1"].rp_ids == []
r = await client.delete("/auth/api/admin/satellites/ln1", headers=headers)
assert r.status_code == 200
assert not test_db.satellite_links
r = await client.delete("/auth/api/admin/satellites/ln1", headers=headers)
assert r.status_code == 400
@pytest.mark.asyncio
async def test_admin_offer_scope_rejects_remote_domain(client, session_token, test_db):
"""A satellite-served domain cannot be scoped into a link: no nesting."""
headers = {
"Host": "localhost:4401",
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
}
ops_db.apply_uplink_domains(
UPLINK_ID,
{
"example.com": DomainConfig(
origins={
"**.example.com": True,
"auth.example.com": OriginEntry(auth_host=True),
},
remote=RemoteConfig(link=UPLINK_ID),
)
},
)
r = await client.post(
"/auth/api/admin/satellites/", json={"rp_ids": ["example.com"]}, headers=headers
)
assert r.status_code == 400
assert "nested" in r.json()["detail"]
@pytest.mark.asyncio
async def test_admin_uplinks_list_and_unlink(client, session_token, test_db):
"""Uplinks list shows served domains and replica state; unlink removes
the uplink and its domains."""
headers = {
"Host": "localhost:4401",
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
}
ops_db.create_uplink(UPLINK_ID, _uplink(), {})
ops_db.apply_uplink_domains(
UPLINK_ID,
{
"example.com": DomainConfig(
origins={
"**.example.com": True,
"auth.example.com": OriginEntry(auth_host=True),
},
remote=RemoteConfig(link=UPLINK_ID),
)
},
)
r = await client.get("/auth/api/admin/uplinks/", headers=headers)
assert r.status_code == 200
(entry,) = r.json()
assert entry["id"] == UPLINK_ID
assert entry["url"] == REMOTE_URL
assert entry["domains"] == ["example.com"]
assert entry["connected"] is False
r = await client.delete(f"/auth/api/admin/uplinks/{UPLINK_ID}", headers=headers)
assert r.status_code == 200
assert not test_db.uplinks
assert "example.com" not in test_db.config.domains
r = await client.get("/auth/api/admin/uplinks/", headers=headers)
assert r.json() == []
@pytest.mark.asyncio
async def test_admin_register_uplink_endpoint(client, session_token, monkeypatch):
"""The registration endpoint runs the handshake synchronously (the
handshake itself is covered by the live tests) and reports failures."""
headers = {
"Host": "localhost:4401",
"Cookie": f"{AUTH_COOKIE_NAME}={session_token}",
}
async def fake_register(url):
return {
"link": "ln1",
"url": "https://remote.test",
"domains": ["example.com"],
"rejected": {},
}
monkeypatch.setattr(satellite, "register_uplink", fake_register)
r = await client.post(
"/auth/api/admin/uplinks/",
json={"url": "wss://alpha.bravo.charlie.delta.echo@remote.test"},
headers=headers,
)
assert r.status_code == 200
assert r.json()["domains"] == ["example.com"]
async def failing_register(url):
raise RuntimeError("the upstream rejected the link URL")
monkeypatch.setattr(satellite, "register_uplink", failing_register)
r = await client.post(
"/auth/api/admin/uplinks/", json={"url": "wss://x@remote.test"}, headers=headers
)
assert r.status_code == 400
assert "rejected" in r.json()["detail"]
async def malformed(url):
raise ValueError("A link URL is a wss:// URL")
monkeypatch.setattr(satellite, "register_uplink", malformed)
r = await client.post(
"/auth/api/admin/uplinks/", json={"url": "nope"}, headers=headers
)
assert r.status_code == 400
+593
View File
@@ -0,0 +1,593 @@
"""Live end-to-end tests for satellite domains over a real sync channel.
A real uvicorn server plays the upstream (its sync WebSocket fed from the
process-global database), while the satellite side runs through the ASGI
app with a real UplinkReplica started by the real SatelliteManager. Unlike
test_remote.py (which injects a warm replica by hand), nothing here is
stubbed: the 3-way handshake, pushed domains, snapshot, live events,
write-behind, dead-peer TTL handling, reconnect reconciliation, live scope
changes and revocation all cross a TCP socket.
One in-process distortion remains: both sides share the module-level
database and registry, so the satellite's pushed-domain application is
arranged to be a no-op (the link's domains are absent from the shared
config — the satellite registry is synthetic instead), and endpoints the
satellite *forwards* to the upstream (logout, set-session, OIDC) would
re-forward and loop here — those paths are covered in test_remote.py with
a mocked forward_request.
"""
import asyncio
import os
import socket
import subprocess
import sys
import time
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from uuid import UUID
import httpx
import msgspec.structs
import pytest
import pytest_asyncio
import uvicorn
from kanta import Kanta
import paskia.db.operations as ops_db
from paskia import domains, satellite
from paskia.db.structs import (
DB,
Config,
Credential,
DomainConfig,
OriginEntry,
RemoteConfig,
Uplink,
)
from paskia.fastapi import sync
from paskia.fastapi.mainapp import app
from paskia.fastapi.session import AUTH_COOKIE_NAME
from .conftest import TEST_RP_ID, create_test_session
REMOTE_DOMAIN = "example.com"
APP_HOST = "app2.example.com"
LINK_ID = "live-link"
LINK_KEY = b"e2e-link-key"
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
async def wait_for(desc: str, condition, timeout: float = 10) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return
await asyncio.sleep(0.02)
raise AssertionError(f"timed out waiting for {desc}")
@pytest_asyncio.fixture
async def live_server(test_db):
"""A uvicorn-served upstream over a real TCP socket.
Yields a namespace: db, url, port, start_server (for restarts),
servers.
"""
servers: list[tuple[uvicorn.Server, asyncio.Task]] = []
async def start_server(port: int) -> uvicorn.Server:
config = uvicorn.Config(
app, host="127.0.0.1", port=port, log_level="error", lifespan="off"
)
server = uvicorn.Server(config)
task = asyncio.create_task(server.serve())
servers.append((server, task))
while not server.started:
await asyncio.sleep(0.02)
return server
port = _free_port()
url = f"http://127.0.0.1:{port}"
domains.configure(listen=["localhost:4401"])
domains.init_registry(test_db.config)
await start_server(port)
try:
yield SimpleNamespace(
db=test_db, url=url, port=port, start_server=start_server, servers=servers
)
finally:
for server, task in servers:
server.should_exit = True
await task
@pytest_asyncio.fixture
async def live_remote(live_server):
"""An established link against the live upstream, and the satellite
manager started for real: the replica connects over TCP and runs the
established-link handshake (hello {link, key} → welcome → ready).
Both sides' link rows are stored directly (registration itself is
covered by the dedicated tests); the satellite's remote domain lives
only in the registry, not the shared config, so pushed-domain
application stays a no-op.
"""
live = live_server
ops_db.create_satellite_link(LINK_ID, [REMOTE_DOMAIN], LINK_KEY, label="e2e")
uplink = Uplink(url=live.url, created=datetime.now(UTC), key=LINK_KEY)
ops_db.create_uplink(LINK_ID, uplink, {})
domains.configure(listen=["localhost:4401"])
domains.init_registry(
Config(
domains={
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True}),
REMOTE_DOMAIN: DomainConfig(
origins={
f"**.{REMOTE_DOMAIN}": True,
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
},
remote=RemoteConfig(link=LINK_ID),
),
}
)
)
await satellite.manager.start()
replica = satellite.manager.replicas[LINK_ID]
await wait_for("initial snapshot", lambda: replica.available())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://localhost:4401"
) as client:
try:
live.client = client
live.replica = replica
yield live
finally:
await satellite.manager.stop()
def _app_headers(secret: str) -> dict[str, str]:
return {"Host": APP_HOST, "Cookie": f"{AUTH_COOKIE_NAME}={secret}"}
@pytest_asyncio.fixture
async def remote_credential(test_db, test_user):
"""A credential registered on the remote domain. The feed is rp-id
scoped: only the link's own domains' credentials replicate."""
credential = Credential.create(
credential_id=os.urandom(32),
user=test_user.uuid,
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
public_key=os.urandom(64),
sign_count=0,
rp_id=REMOTE_DOMAIN,
)
ops_db.create_credential(credential)
return credential
# -------------------------------------------------------------------------
# Registration (the real 3-way handshake, crossing TCP)
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_live_registration(live_server):
"""A RAM-only offer is spent by the handshake; both sides store the
link with identical key material, and the established-link handshake
works right after."""
live = live_server
offer_url = sync.create_offer([], live.url)
assert offer_url.startswith("ws://")
result = await satellite.register_uplink(offer_url)
link_id = result["link"]
assert result["domains"] == [] # empty scope: register first, scope later
uplink = live.db.uplinks[link_id]
assert uplink.url == live.url
link = live.db.satellite_links[link_id]
assert uplink.key == link.key and len(link.key) == 64 # both parts
assert link.label # the satellite's self-reported name
# Re-registering to the same upstream is refused (the offer itself is
# single-use — covered in test_remote.py's offer test)
with pytest.raises(ValueError, match="Already linked"):
await satellite.register_uplink(offer_url)
# The established-link handshake: the replica connects and syncs
await satellite.manager.start()
try:
replica = satellite.manager.replicas[link_id]
await wait_for("established link sync", lambda: replica.available())
finally:
await satellite.manager.stop()
@pytest.mark.asyncio
async def test_live_registration_rejections(live_server):
"""Malformed URLs, unknown keys and unreachable hosts all fail the
registration without storing anything."""
with pytest.raises(ValueError, match="wss"):
await satellite.register_uplink("https://key@auth.example.com")
with pytest.raises(ValueError, match="key"):
await satellite.register_uplink("wss://auth.example.com")
with pytest.raises(RuntimeError, match="rejected"):
await satellite.register_uplink(
f"ws://unknown.key@127.0.0.1:{live_server.port}"
)
with pytest.raises(RuntimeError, match="unreachable"):
await satellite.register_uplink("ws://some.key@127.0.0.1:1")
assert not live_server.db.uplinks
assert not live_server.db.satellite_links
@pytest.mark.asyncio
async def test_cli_satellite_command(live_server, tmp_path):
"""The whole satellite-only setup, two processes: the CLI registers
against the live upstream (creating its own database on the way) and
stores the link and the pushed domains — 'paskia' then serves them."""
live = live_server
ops_db.create_domain(
REMOTE_DOMAIN,
DomainConfig(
rp_name="Example",
origins={
f"**.{REMOTE_DOMAIN}": True,
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
},
),
)
offer_url = sync.create_offer([REMOTE_DOMAIN], live.url)
proc = await asyncio.to_thread(
subprocess.run,
[sys.executable, "-m", "paskia", "satellite", offer_url],
cwd=tmp_path,
capture_output=True,
text=True,
env={k: v for k, v in os.environ.items() if not k.startswith("PASKIA_")},
timeout=60,
)
assert proc.returncode == 0, proc.stderr
assert REMOTE_DOMAIN in proc.stdout
# The satellite's own database holds the uplink and the pushed domain,
# and both sides hold the same key material for the link.
sat_db_path = tmp_path / "paskia.kantadb"
kanta = Kanta(str(sat_db_path), DB())
await kanta.open(readonly=True)
try:
sat_domains = dict(kanta.data.config.domains)
uplinks = dict(kanta.data.uplinks)
finally:
await kanta.close()
domain = sat_domains[REMOTE_DOMAIN]
assert domain.remote is not None
assert domain.rp_name == "Example"
((link_id, uplink),) = uplinks.items()
assert uplink.url == live.url
assert live.db.satellite_links[link_id].key == uplink.key
assert live.db.satellite_links[link_id].rp_ids == [REMOTE_DOMAIN]
# -------------------------------------------------------------------------
# Established link: sync behavior
# -------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_live_snapshot_and_context_repush(
live_remote, test_user, remote_credential
):
"""Sessions arrive with their upstream-computed context; user-graph
changes eagerly re-push the affected sessions' contexts."""
live = live_remote
key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.session_contexts)
assert live.replica.db.session_contexts[key].user.display_name == "Test Admin"
ops_db.update_user_display_name(test_user.uuid, "Live Rename")
await wait_for(
"rename to propagate",
lambda: (
live.replica.db.session_contexts[key].user.display_name == "Live Rename"
),
)
@pytest.mark.asyncio
async def test_live_permission_revocation_propagates(
live_remote, test_user, remote_credential
):
"""Removing a permission from the user's role re-pushes the session
context: the satellite denies within one RTT."""
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.session_contexts)
r = await live.client.get(
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
)
assert r.status_code == 204
perm_uuid = next(
p.uuid for p in live.db.permissions.values() if p.scope == "auth:admin"
)
ops_db.remove_permission_from_role(test_user.role_uuid, perm_uuid)
await wait_for(
"revocation to propagate",
lambda: "auth:admin" not in live.replica.db.session_contexts[key].permissions,
)
r = await live.client.get(
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
)
assert r.status_code == 403
@pytest.mark.asyncio
async def test_live_forward_and_writebehind(live_remote, test_user, remote_credential):
"""A session created on the upstream authorizes locally; the /validate
refresh travels back over the wire into the upstream's database."""
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.sessions)
r = await live.client.get(
"/auth/api/forward?perm=auth:admin", headers=_app_headers(secret)
)
assert r.status_code == 204
assert r.headers["remote-name"] == "Test Admin"
# Age the session on the upstream (propagates), then validate on the satellite
stale = datetime.now(UTC) - timedelta(minutes=10)
ops_db.update_session(key, validated=stale)
await wait_for(
"staleness to propagate",
lambda: live.replica.db.sessions[key].validated == stale,
)
r = await live.client.post("/auth/api/validate", headers=_app_headers(secret))
assert r.status_code == 200
assert r.json()["renewed"] is True
await wait_for(
"write-behind to land on the upstream",
lambda: live.db.sessions[key].validated > stale,
)
@pytest.mark.asyncio
async def test_live_session_delete_propagates(
live_remote, test_user, remote_credential
):
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.sessions)
ops_db.delete_session(key)
await wait_for(
"session delete to propagate", lambda: key not in live.replica.db.sessions
)
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 401
@pytest.mark.asyncio
async def test_live_wrong_key_rejected(live_remote):
"""An uplink whose key does not match the stored link's never syncs."""
uplink = Uplink(url=live_remote.url, created=datetime.now(UTC), key=b"not-the-key")
uplink.uplink_id = "bad-link"
bad = satellite.UplinkReplica(uplink)
await bad.start()
try:
await asyncio.sleep(1.5)
assert not bad.available()
finally:
await bad.stop()
@pytest.mark.asyncio
async def test_live_disconnect_ttl_and_reconnect(
live_remote, test_user, remote_credential
):
"""Channel down: replica keeps serving within cache_ttl, then fails
closed (503); reconnect starts from a snapshot and reconciles drift."""
live = live_remote
key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for("session sync", lambda: key in live.replica.db.sessions)
server, task = live.servers[0]
server.should_exit = True
await task
await wait_for(
"dead peer detection", lambda: not live.replica.connected, timeout=15
)
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 204 # still authoritative within cache_ttl
# Expire the trust window (a copy: the db-owned struct is not mutated)
uplink_copy = msgspec.structs.replace(live.replica.uplink, cache_ttl=0)
uplink_copy.uplink_id = LINK_ID
live.replica.uplink = uplink_copy
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 503
# Drift while the channel is down, then let the client auto-reconnect
ops_db.update_user_display_name(test_user.uuid, "While Down")
await live.start_server(live.port)
await wait_for("reconnect", lambda: live.replica.available(), timeout=20)
assert (
live.replica.db.session_contexts[key].user.display_name == "While Down"
) # snapshot reconciled the drift
r = await live.client.get("/auth/api/forward", headers=_app_headers(secret))
assert r.status_code == 204
@pytest.mark.asyncio
async def test_unlink_stops_replica(live_remote):
"""Unlinking drops the uplink, its domains and the replica."""
live = live_remote
await satellite.manager.unlink(LINK_ID)
assert LINK_ID not in satellite.manager.replicas
assert LINK_ID not in live.db.uplinks
@pytest.mark.asyncio
async def test_live_revocation_unlinks_satellite(live_remote):
"""Revoking the link upstream drops the connection; the satellite's
handshake retry gets a policy close and unlinks its side."""
live = live_remote
ops_db.delete_satellite_link(LINK_ID)
await sync.close_link(LINK_ID)
await wait_for(
"satellite to unlink", lambda: LINK_ID not in live.db.uplinks, timeout=25
)
assert LINK_ID not in satellite.manager.replicas
@pytest.mark.asyncio
async def test_live_out_of_scope_session_not_replicated(
live_remote, test_user, test_credential, remote_credential
):
"""The feed is scoped to the link's rp-ids: sessions of other domains
(here the satellite's own) never cross the wire."""
live = live_remote
local_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost:4401",
rp_id=TEST_RP_ID,
)
# Positive control: an in-scope session created afterwards does arrive,
# so the out-of-scope one has already passed through the pipe.
remote_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for(
"in-scope session sync", lambda: remote_key in live.replica.db.sessions
)
assert local_key not in live.replica.db.sessions
@pytest.mark.asyncio
async def test_live_out_of_scope_writebehind_ignored(
live_remote, test_user, test_credential, remote_credential
):
"""A session_refresh for a session outside the link's scope is ignored
by the upstream (fail-closed), while an in-scope one lands."""
live = live_remote
stale = datetime.now(UTC) - timedelta(minutes=10)
local_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost:4401",
rp_id=TEST_RP_ID,
)
remote_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
ops_db.update_session(local_key, validated=stale)
ops_db.update_session(remote_key, validated=stale)
now = datetime.now(UTC)
live.replica.refresh_session(local_key, now, None, None)
live.replica.refresh_session(remote_key, now, None, None)
await wait_for(
"in-scope refresh to land",
lambda: live.db.sessions[remote_key].validated > stale,
)
assert live.db.sessions[local_key].validated == stale
@pytest.mark.asyncio
async def test_live_scope_shrink_stops_feed(live_remote, test_user, remote_credential):
"""Narrowing the link's scope on the upstream takes effect on the live
connection: sessions of removed domains stop flowing."""
live = live_remote
ops_db.set_satellite_link_scope(LINK_ID, [])
await sync.push_link_domains(LINK_ID)
removed_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
# Positive control: widen the scope again; a session created now does
# arrive, so the earlier one demonstrably passed a closed pipe.
ops_db.set_satellite_link_scope(LINK_ID, [REMOTE_DOMAIN])
await sync.push_link_domains(LINK_ID)
remote_key, _ = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=remote_credential.uuid,
host=APP_HOST,
rp_id=REMOTE_DOMAIN,
)
await wait_for(
"re-scoped session sync", lambda: remote_key in live.replica.db.sessions
)
assert removed_key not in live.replica.db.sessions
@pytest.mark.asyncio
async def test_live_domain_conflict_reported_upstream(live_remote, test_db):
"""A pushed domain the satellite serves locally is rejected, and the
rejection is reported into the upstream's link state."""
# The shared config now holds the domain authoritatively — from the
# satellite's view this is the local-conflict case.
ops_db.create_domain(
REMOTE_DOMAIN,
DomainConfig(
rp_name="Example",
origins={
f"**.{REMOTE_DOMAIN}": True,
f"auth.{REMOTE_DOMAIN}": OriginEntry(auth_host=True),
},
),
)
await sync.push_link_domains(LINK_ID)
await wait_for(
"rejection to arrive",
lambda: (
REMOTE_DOMAIN in sync.links_status().get(LINK_ID, {}).get("rejected", {})
),
)
# The satellite kept its (synthetic) registry — no local overwrite
assert test_db.config.domains[REMOTE_DOMAIN].remote is None
+51
View File
@@ -0,0 +1,51 @@
"""Tests for startup box sign-in summaries (paskia/util/startupbox.py).
Pruning must mirror the actual origin matching in sansio._allowlisted:
wildcards cover https origins (any port) under their base, except under
localhost where any scheme and any port match.
"""
from paskia.util.startupbox import _signin_summary
def test_auth_host_pruned_under_full_wildcard():
"""'**.vasanko.com' already covers auth.vasanko.com."""
keys = ["**.vasanko.com", "auth.vasanko.com"]
assert _signin_summary(keys, "vasanko.com") == "all subdomains"
def test_http_origin_not_covered_by_https_wildcard():
"""Plain http outside localhost is not wildcard-covered; stays listed."""
keys = ["**.example.com", "http://app.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "all subdomains, http://app.example.com"
def test_localhost_wildcard_covers_any_scheme_and_port():
keys = ["**.localhost", "http://localhost:3000", "localhost:8080"]
assert _signin_summary(keys, "localhost") == "all subdomains"
def test_https_port_key_covered_by_wildcard():
"""Wildcards match https origins at any port, so 'host:8443' is redundant."""
keys = ["**.example.com", "app.example.com:8443"]
assert _signin_summary(keys, "example.com") == "all subdomains"
def test_single_level_wildcard_pruning():
"""'*.example.com' covers one subdomain level only."""
keys = ["*.example.com", "app.example.com", "deep.app.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "subdomains, deep.app.example.com"
def test_no_wildcard_keeps_all_entries():
keys = ["auth.example.com", "app.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "app.example.com, auth.example.com"
def test_entries_outside_wildcard_base_kept():
keys = ["**.app.example.com", "auth.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "all subdomains of app.example.com, auth.example.com"