Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6634f285ae | ||
|
|
6432b03174 | ||
|
|
4991e7be43 | ||
|
|
6895d71ad2 | ||
|
|
7c04a97892 | ||
|
|
a38bb6ccec | ||
|
|
7a34961fa5 | ||
|
|
dd9f6e094b | ||
|
|
1f3410aa88 | ||
|
|
f2b82f04a9 | ||
|
|
444eca651e | ||
|
|
6d49b59cec | ||
|
|
fa0d03729b | ||
|
|
0a59e26e45 | ||
|
|
9da1abc2c7 | ||
|
|
0638117279 | ||
|
|
28fa0ff61f | ||
|
|
05ea188ac9 | ||
|
|
ebe18912fc | ||
|
|
3a6e2bb370 | ||
|
|
0f38a8b096 | ||
|
|
d0c7949052 | ||
|
|
2209e2395c | ||
|
|
da0b3e0e8b | ||
|
|
b5733657f9 | ||
|
|
4de164c457 | ||
|
|
3e4f77ba93 | ||
|
|
66c2a9bf07 | ||
|
|
c4360df110 | ||
|
|
c676665795 | ||
|
|
55dd43661c | ||
|
|
753ce868c6 | ||
|
|
3e0152e688 | ||
|
|
c5efa03908 | ||
|
|
0ca4e07e23 | ||
|
|
baa993e586 | ||
|
|
42240dd2c7 | ||
|
|
2ec709905e | ||
|
|
6eb862278f | ||
|
|
dbd697772a |
No files matched your search
@@ -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
|
||||
|
||||
@@ -235,3 +236,4 @@ See [Multi-Site documentation](docs/MultiSite.md) for details.
|
||||
- [Trusted Headers for Backend Apps](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Headers.md)
|
||||
- [Frontend integration](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/Integration.md)
|
||||
- [Paskia API](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/API.md)
|
||||
- [Remote Proxy / Satellite](https://git.zi.fi/LeoVasanko/paskia/src/branch/main/docs/RemoteProxy.md) — serve domains from a local satellite instance
|
||||
+8
-1
@@ -76,8 +76,15 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
| POST | /auth/api/admin/domains/ | Create domain `{rp_id, rp_name?, origins?, related?}` | 200/400/401/403 |
|
||||
| PATCH | /auth/api/admin/domains/{rp_id} | Update domain rp_name/origins/related | 200/400/401/403 |
|
||||
| DELETE | /auth/api/admin/domains/{rp_id} | Delete domain (refused while credentials remain) | 200/400/401/403 |
|
||||
| GET | /auth/api/admin/satellites/ | List registered satellite links with live state | 200/401/403 |
|
||||
| POST | /auth/api/admin/satellites/ | Mint a satellite registration offer `{rp_ids}` — wss link URL, RAM-only, 10 min, single-use | 200/400/401/403 |
|
||||
| PATCH | /auth/api/admin/satellites/{id} | Adjust a link's domain scope (pushed over the live link) | 200/400/401/403 |
|
||||
| DELETE | /auth/api/admin/satellites/{id} | Revoke a satellite link (drops its connections) | 200/400/401/403 |
|
||||
| GET | /auth/api/admin/uplinks/ | List this instance's uplinks with served domains and live state | 200/401/403 |
|
||||
| POST | /auth/api/admin/uplinks/ | Register an uplink from a pasted link URL `{url}` (online handshake) | 200/400/401/403 |
|
||||
| DELETE | /auth/api/admin/uplinks/{id} | Unlink an uplink (drops it and its domains) | 200/400/401/403 |
|
||||
|
||||
Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately.
|
||||
Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately. Remote (satellite-served) domains are uplink-managed and cannot be written here; see [Remote Proxy / Satellite](RemoteProxy.md) for the link model. The same applies to the satellite/uplink endpoints.
|
||||
|
||||
`origins` is a single object keyed by all of the domain's sign-in sites. Entries *within* the rp-id domain are in-domain sites: bare hosts, wildcards under the rp-id following the shell-glob convention (`**.example.com` covers the apex and subdomains at any depth, `*.example.com` exactly one subdomain level — https only, any scheme and port under localhost; plain `*` is not accepted), or full origins when not https. Entries *outside* the rp-id domain are related origins that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5, no wildcards); those are published at `/.well-known/webauthn` on the rp-id host. An empty object allows nothing of the domain itself. A value of `true` marks presence; `{"auth_host": true}` additionally marks an in-domain entry as the domain's authentication host.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -1009,6 +1009,7 @@ async function submitDialog() {
|
||||
@create-domain="createDomain"
|
||||
@open-domain="openDomain"
|
||||
@delete-domain="deleteDomain"
|
||||
@domains-changed="loadDomains"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
<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 { formatDate } from '@/utils/helpers'
|
||||
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 <url></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><span class="muted">·</span><span>{{ formatDate(link.created) }}</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><span class="muted">·</span><span>{{ formatDate(uplink.created) }}</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 <url></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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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
@@ -1,14 +1,14 @@
|
||||
# Paskia
|
||||
|
||||

|
||||
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "paskia",
|
||||
"version": "1.4.0",
|
||||
"version": "2.1.0",
|
||||
"description": "Paskia authentication utilities for JavaScript",
|
||||
"author": "Leo Vasanko",
|
||||
"license": "Unlicense",
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
isAuthIframeOpen,
|
||||
hideAuthIframe,
|
||||
showAuthIframe,
|
||||
profile,
|
||||
} from './overlay'
|
||||
|
||||
export { SessionValidator } from './validate'
|
||||
@@ -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)
|
||||
|
||||
+107
-29
@@ -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,68 @@ 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}")
|
||||
# A successful CLI run means the server was not holding the database
|
||||
# open — the linked domains start serving on the next start.
|
||||
print("Start the server to serve them: paskia")
|
||||
if fresh:
|
||||
print("Created the database.")
|
||||
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 +268,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 +289,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 +321,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 +363,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))
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
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()
|
||||
sync.drop_domain_from_links(rp_id, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
@@ -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)
|
||||
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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""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
|
||||
|
||||
|
||||
def push_link_domains(link_id: str) -> None:
|
||||
"""Queue the link's current domain set to its live connections and
|
||||
update their feed scope (after scope edits or origin changes). Frames
|
||||
flow through the connection's queue: the event pump stays the single
|
||||
sender, so frames never interleave with a concurrent send."""
|
||||
link = db.data().satellite_links.get(link_id)
|
||||
if link is None:
|
||||
return
|
||||
message = {"type": "domains", "domains": domain_infos(link.rp_ids)}
|
||||
for queue, conn in list(_connections.items()):
|
||||
if conn.link_id != link_id:
|
||||
continue
|
||||
conn.scope.clear()
|
||||
conn.scope.update(link.rp_ids)
|
||||
try:
|
||||
queue.put_nowait(message)
|
||||
except asyncio.QueueFull:
|
||||
# Slow consumer: drop it like syncfeed does — the reconnect's
|
||||
# welcome re-pushes the domain set anyway.
|
||||
_connections.pop(queue, None)
|
||||
syncfeed.unsubscribe(queue)
|
||||
|
||||
|
||||
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:
|
||||
push_link_domains(link_id)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
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 "")[:80],
|
||||
)
|
||||
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
@@ -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
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
"""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
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
"""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 ({rp_id: rejection reason}, changed) — the rejections
|
||||
to report upstream, and whether the accepted set moved (the caller then
|
||||
resyncs: a newly added domain's existing sessions only arrive with a
|
||||
fresh snapshot)."""
|
||||
accepted, rejected = _accepted_domains(uplink_id, infos)
|
||||
current = _uplink_domains(uplink_id)
|
||||
changed = accepted != current
|
||||
if changed:
|
||||
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, changed
|
||||
|
||||
|
||||
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.
|
||||
# The snapshot that follows covers any added domains, so no
|
||||
# reconnect is needed here.
|
||||
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. A
|
||||
# changed domain set means a resync: a newly added
|
||||
# domain's existing sessions only arrive with a fresh
|
||||
# snapshot, so reconnect for one.
|
||||
rejected, changed = 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}
|
||||
)
|
||||
)
|
||||
if changed:
|
||||
return
|
||||
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)
|
||||
# Snapshot: a revocation task may mutate the uplinks mid-loop
|
||||
for uplink_id, uplink in list(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
|
||||
@@ -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
|
||||
# scope set (reset tokens, link tables, OIDC data and domain config are
|
||||
# instance-local and never replicated).
|
||||
TABLES = ("credentials", "sessions")
|
||||
|
||||
|
||||
# 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 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)
|
||||
@@ -14,7 +14,15 @@ import msgspec
|
||||
from uarite import uaparse
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
|
||||
from paskia.db.structs import (
|
||||
Credential,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
RemoteConfig,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API structs - inherit from db structs, add uuid for serialization
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
+34
-63
@@ -2,24 +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 import hostutil
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import format_endpoint, wildcard_base
|
||||
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
|
||||
@@ -28,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://")
|
||||
@@ -107,9 +70,9 @@ def _covered_by_wildcard(key: str, pattern: str) -> bool:
|
||||
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
|
||||
]
|
||||
matched = (
|
||||
hostname.endswith(f".{base}") and "." not in hostname[: -len(base) - 1]
|
||||
)
|
||||
if not matched:
|
||||
return False
|
||||
if hostutil.is_subdomain(base, "localhost"):
|
||||
@@ -135,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)
|
||||
@@ -146,23 +112,24 @@ 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:
|
||||
header_urls = [
|
||||
f"{w}{url}{r}" for d in domains if (url := auth_host_url(d.config))
|
||||
f"{w}{url}{r}"
|
||||
for d in domains
|
||||
# A satellite-served domain's auth host lives on the upstream —
|
||||
# the header links this instance's own account pages.
|
||||
if d.config.remote is None and (url := auth_host_url(d.config))
|
||||
]
|
||||
if not header_urls:
|
||||
guess = min(
|
||||
@@ -178,9 +145,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}",
|
||||
@@ -196,29 +164,32 @@ 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}"
|
||||
if domain.remote is not None:
|
||||
head += " 🛰" # satellite-served via an uplink
|
||||
if not domain.config.origins:
|
||||
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)
|
||||
+11
-5
@@ -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}
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ dependencies = [
|
||||
"pyjwt[crypto]>=2.11.0",
|
||||
"jsondiff>=2.2.1",
|
||||
"msgspec>=0.20.0",
|
||||
"fastapi-vue~=1.4.2",
|
||||
"kanta>=0.7.0",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"kanta>=0.9.2",
|
||||
"uarite>=0.2.1",
|
||||
]
|
||||
[dependency-groups]
|
||||
|
||||
+68
-58
@@ -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,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Executable
+158
@@ -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
@@ -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))
|
||||
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
"""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, changed = satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
||||
assert not rejected
|
||||
assert changed
|
||||
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
|
||||
rejected, changed = satellite.apply_domains_push(UPLINK_ID, [_PUSHED])
|
||||
assert not rejected and not changed
|
||||
|
||||
# The uplink's scope shrank: the domain leaves the local config
|
||||
rejected, changed = satellite.apply_domains_push(UPLINK_ID, [])
|
||||
assert not rejected
|
||||
assert changed
|
||||
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, changed = satellite.apply_domains_push(
|
||||
UPLINK_ID, [{**_PUSHED, "rp_id": TEST_RP_ID}]
|
||||
)
|
||||
assert TEST_RP_ID in rejected
|
||||
assert not changed
|
||||
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
|
||||
@@ -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, [])
|
||||
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])
|
||||
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),
|
||||
},
|
||||
),
|
||||
)
|
||||
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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user