MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch, per-domain credentials and sessions, domains managed at runtime in the admin UI — previously one RP per instance - Cross-domain sign-in via Related Origin Requests: per-domain related-origins list with a served .well-known/webauthn document - Explicit per-domain origin lists with shell-glob wildcards (**. for apex + any subdomain depth, *. for one level), editable in the admin UI with validation and self-lockout guards - Per-domain auth hosts: the account/admin UI can live on a different host per domain, no longer confined to subdomains of a single RP - CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an existing database; 'paskia migrate' converts legacy databases BREAKING CHANGES (v2.0): - Database schema: config is now per-domain and credentials/sessions carry an rp_id — existing databases must be converted with 'paskia migrate' - Origins are now explicit: main implicitly allowed every subdomain of the RP; configure '**.' origins to reproduce that behavior - CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
This commit is contained in:
@@ -6,6 +6,8 @@ dist/
|
||||
package-lock.json
|
||||
paskia.sqlite
|
||||
*.paskiadb
|
||||
*.converted-bak
|
||||
*.kantadb
|
||||
*.data
|
||||
/paskia/frontend-build
|
||||
/paskia/_version.py
|
||||
|
||||
@@ -36,10 +36,11 @@ Paskia includes set of login, reauthentication and forbidden dialogs that it can
|
||||
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
||||
|
||||
```sh
|
||||
uvx paskia --rp-id example.com
|
||||
uvx paskia init example.com
|
||||
uvx paskia
|
||||
```
|
||||
|
||||
On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
|
||||
The first command bootstraps the database and prints a registration link for the Admin. The second starts the server on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out the rp-id (defaults to `localhost`).
|
||||
|
||||
For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
|
||||
|
||||
@@ -51,22 +52,24 @@ uv tool install paskia
|
||||
|
||||
## Configuration
|
||||
|
||||
You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
|
||||
Bootstrapping is done once with `paskia init`; after that, `paskia` serves all configured domains from the database `paskia.kantadb` in the current directory. Domain configuration (rp-name, auth host, origins) is managed via the admin web interface, including adding further domains (rp-ids).
|
||||
|
||||
```text
|
||||
paskia [options]
|
||||
paskia init [rp-id] [rp-name] [options] # one-time bootstrap; with an existing
|
||||
# database, adds the rp-id (or renames it)
|
||||
paskia migrate [rp-id] # convert a legacy {rp-id}.paskiadb database
|
||||
paskia [-l endpoint] # serve
|
||||
```
|
||||
|
||||
| Option | Description | Default |
|
||||
| init option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
|
||||
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
|
||||
| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
|
||||
| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
|
||||
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
||||
| --save | Save current options to database | (only --rp-id required on further invocations) |
|
||||
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* (stored in the database) | **localhost:4401** |
|
||||
| *rp-id* (positional) | Main/top domain for passkeys | **localhost** |
|
||||
| *rp-name* (positional) | Branding name of the domain (passkey auth, login dialog) | Same as rp-id |
|
||||
|
||||
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
|
||||
Origins, auth hosts and related domains are configured afterwards in the admin panel's Domains section.
|
||||
|
||||
The `paskia` serve command accepts only `--listen` (overriding the stored value) and never converts databases: with no `paskia.kantadb` it tells you to run `paskia init`, or `paskia migrate` when a legacy `{rp-id}.paskiadb` database is present. `paskia migrate` converts the legacy database; with several candidates, the positional rp-id selects one by name and the rest are left in place.
|
||||
|
||||
## Tutorial: From Local Testing to Production
|
||||
|
||||
@@ -74,13 +77,14 @@ This section walks you through a complete example, from running Paskia locally t
|
||||
|
||||
### Step 1: Production Configuration
|
||||
|
||||
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||
For a real deployment, bootstrap Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
|
||||
|
||||
```sh
|
||||
uvx paskia --rp-id=example.com --rp-name="Example Corp"
|
||||
uvx paskia init example.com "Example Corp"
|
||||
uvx paskia
|
||||
```
|
||||
|
||||
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
|
||||
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The rp-name is the branding shown in UI and registered with passkeys for everything on your domain (rp id). Init prints a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
|
||||
|
||||
### Step 2: Set Up Caddy
|
||||
|
||||
@@ -177,20 +181,20 @@ curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/
|
||||
Create a systemd unit:
|
||||
|
||||
```sh
|
||||
sudo systemctl edit --force --full paskia@.service
|
||||
sudo systemctl edit --force --full paskia.service
|
||||
```
|
||||
|
||||
Paste the following and save:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Paskia for %i
|
||||
Description=Paskia
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=paskia
|
||||
WorkingDirectory=/srv/paskia
|
||||
ExecStart=uvx paskia@latest --rp-id=%i
|
||||
ExecStart=uvx paskia@latest
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -199,7 +203,7 @@ WantedBy=multi-user.target
|
||||
Run the service and view log:
|
||||
|
||||
```sh
|
||||
sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
|
||||
sudo systemctl enable --now paskia && sudo journalctl -n30 -ocat -fu paskia
|
||||
```
|
||||
|
||||
### Optional: Dedicated Authentication Site
|
||||
@@ -214,7 +218,15 @@ auth.example.com {
|
||||
|
||||
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
|
||||
|
||||
Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server.
|
||||
Set the auth host in the admin panel's Domains section.
|
||||
|
||||
## Multiple Domains and Related Origins
|
||||
|
||||
One Paskia instance can serve several domains (rp-ids) from the same database: users, orgs and permissions are shared, while passkeys are registered per domain. The master admin adds domains in the admin panel's Domains section; no restart is needed.
|
||||
|
||||
A domain can also let *other* domain names use its passkeys via WebAuthn [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) — list them in the domain's allowed origins (they show as related domains), and paskia serves the required `/.well-known/webauthn` declaration on the domain's main site. In-domain entries of the same list restrict which sites of the domain's own name may authenticate (a new domain defaults to `**.{domain}` — the apex and all subdomains over https).
|
||||
|
||||
See [Multi-Site documentation](docs/MultiSite.md) for details.
|
||||
|
||||
|
||||
## Further Documentation
|
||||
|
||||
+5
-2
@@ -1,9 +1,12 @@
|
||||
localhost {
|
||||
# Forwards API by caddy, bypassing the Vite dev proxy
|
||||
# WebSockets bypass directly to backend (workaround for bun proxy bug)
|
||||
# Avoids bug https://github.com/oven-sh/bun/issues/9882
|
||||
handle /api/* {
|
||||
handle /auth/ws/* {
|
||||
reverse_proxy :4402 # directly to backend
|
||||
}
|
||||
# Everything else goes to or via Vite
|
||||
# (Vite proxies /auth/api, /.well-known/openid-configuration and
|
||||
# /.well-known/webauthn to the backend)
|
||||
handle {
|
||||
reverse_proxy :4403 # vite dev server
|
||||
}
|
||||
|
||||
@@ -4,3 +4,10 @@ header -Remote-*
|
||||
handle @auth_api {
|
||||
reverse_proxy {$AUTH_UPSTREAM::4401}
|
||||
}
|
||||
# Paskia-served well-known endpoints: OIDC discovery and WebAuthn Related
|
||||
# Origin Requests (must reach paskia even when other /.well-known/* files
|
||||
# are served statically)
|
||||
@auth_wellknown path /.well-known/openid-configuration /.well-known/webauthn
|
||||
handle @auth_wellknown {
|
||||
reverse_proxy {$AUTH_UPSTREAM::4401}
|
||||
}
|
||||
|
||||
+20
-4
@@ -72,8 +72,14 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
| PATCH | /auth/api/admin/oidc-clients/{uuid} | Update OIDC client | 200/401/403 |
|
||||
| PATCH | /auth/api/admin/oidc-clients/{uuid}/reset-secret | Reset client secret | 200/401/403 |
|
||||
| DELETE | /auth/api/admin/oidc-clients/{uuid} | Delete OIDC client | 200/401/403 |
|
||||
| GET | /auth/api/admin/server-config/ | Get server config | 200/401/403 |
|
||||
| PATCH | /auth/api/admin/server-config/ | Update server config | 200/401/403 |
|
||||
| GET | /auth/api/admin/domains/ | List domains (rp-ids) with derived URLs | 200/401/403 |
|
||||
| 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 |
|
||||
|
||||
Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately.
|
||||
|
||||
`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.
|
||||
|
||||
### WebSockets: /auth/ws/*
|
||||
|
||||
@@ -86,7 +92,9 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
|
||||
These are for internal use only, but are documented here because they are the core piece in all passkey operations.
|
||||
|
||||
### Auth host mode (--auth-host)
|
||||
### Auth host mode (dedicated auth site)
|
||||
|
||||
A domain may configure a dedicated authentication host (auth-host, a subdomain of the rp-id) via the Domains admin panel.
|
||||
|
||||
#### On the auth host:
|
||||
- The Web UI is served at site root instead of /auth/* (that redirects to root paths)
|
||||
@@ -98,4 +106,12 @@ These are for internal use only, but are documented here because they are the co
|
||||
- /auth/api/* is served normally.
|
||||
- /auth/api/user/*, /auth/api/admin/*, and /auth/ws/* don't exist.
|
||||
|
||||
The WebSocket connections are directed to auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with.
|
||||
The WebSocket connections are directed to the auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with.
|
||||
|
||||
#### Auth hosts and other domains
|
||||
|
||||
Auth hosts are strictly per-domain: a domain without its own auth host uses its own hosts for the WebSocket flows, and `/auth/api/settings` reports `auth_host` (and the identical `own_auth_host`) as null. One domain's auth host never serves another domain implicitly. To consolidate logins on one host, mark that host as the auth host on each domain that should use it (possible when the host lies under each domain's rp-id, i.e. nested rp-ids); dispatch resolves a shared host to the best-matching (longest rp-id suffix) domain.
|
||||
|
||||
### Related Origin Requests: /.well-known/webauthn
|
||||
|
||||
`GET /.well-known/webauthn` returns `{"origins": [...]}` listing the domain's related origins (configured origins on domains unrelated to the rp-id), per WebAuthn Related Origin Requests. Browsers fetch this from the rp-id domain when an unrelated origin runs a ceremony with this domain's rp-id. Returns 404 when the domain has no related origins. If the rp-id's main site is hosted elsewhere, serve the JSON statically there (copy it from this instance).
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# Multiple Domains (Multi-Site)
|
||||
|
||||
One Paskia instance on one port serves several domains from a single
|
||||
database. Typical uses:
|
||||
|
||||
- `app1.company.com` and `app2.com` cannot share passkeys, but user
|
||||
management should stay under one roof.
|
||||
- A few alternative brand names should accept the _same_ passkeys.
|
||||
|
||||
## Shared vs. per-domain
|
||||
|
||||
**Shared across all domains:** user accounts, organizations, roles,
|
||||
permissions, and OIDC clients. A user account exists once and can sign in
|
||||
on every domain.
|
||||
|
||||
**Per domain:** passkeys and sessions.
|
||||
|
||||
- A passkey is registered to one domain name (enforced by the browser): a
|
||||
passkey created for `company.com` works on `company.com` and its
|
||||
subdomains, never on an unrelated name — unless that name is configured
|
||||
as a [related origin](#related-origins-sharing-passkeys-across-domain-names).
|
||||
A user active on two domains simply holds one passkey per domain.
|
||||
- A session is bound to the exact host that issued it.
|
||||
|
||||
## The simplest case: several sites on one domain
|
||||
|
||||
Multi-site does not require several domains. Sites under one name —
|
||||
`app1.company.com`, `app2.company.com` and so on — share the domain
|
||||
`company.com`: one passkey works on all of them (WebAuthn natively allows
|
||||
the domain and its subdomains), and the default `**.company.com` origin
|
||||
entry already lets every one of them sign in. Add explicit entries only
|
||||
to restrict which sites may sign in, and mark an auth host (see below)
|
||||
if you want sign-in centralized on one site.
|
||||
|
||||
## Managing domains
|
||||
|
||||
The admin panel's **Domains** section (master admins only) lists every
|
||||
domain with its allowed origins. There you can add, edit and delete
|
||||
domains; changes apply immediately without a restart and require a recent
|
||||
sign-in (within 5 minutes). The 🔑 and 🔗 markers in the origins column
|
||||
identify the auth host and related origins (below).
|
||||
|
||||
A domain consists of:
|
||||
|
||||
- **Domain (rp-id)** — the domain name passkeys belong to, e.g.
|
||||
`company.com`. ("rp-id" is the WebAuthn term; read it as "domain name".)
|
||||
It cannot be changed after creation, because existing passkeys are bound
|
||||
to it — delete and re-create the domain instead.
|
||||
- **Display name (rp-name)** — branding shown in sign-in dialogs and
|
||||
registered with passkeys.
|
||||
- **Allowed origins** — the sites where this domain's passkeys may sign
|
||||
in, plus any related origins.
|
||||
|
||||
Deleting a domain is refused while any passkey is still registered to it,
|
||||
when it is the last remaining domain, or when it is the domain you are
|
||||
currently using. Users and organizations are never deleted with a domain
|
||||
— they are shared. An edit that would lock you out (your current site
|
||||
could no longer run passkey ceremonies for that domain) is refused as
|
||||
well.
|
||||
|
||||
## Allowed origins (sign-in sites)
|
||||
|
||||
This list controls which sites may sign in with the domain's passkeys.
|
||||
Everything is explicit: an empty list allows nothing of the domain itself
|
||||
(related origins, below, still work — a domain can in principle run
|
||||
entirely on related origins). A new domain starts with one entry,
|
||||
`**.{domain}`, which suits most deployments.
|
||||
|
||||
Entry forms:
|
||||
|
||||
- `**.company.com` — the domain itself and its subdomains at any depth,
|
||||
https only. The default entry of a new domain.
|
||||
- `*.company.com` — exactly one subdomain level: `app.company.com` yes,
|
||||
but neither the apex `company.com` nor `a.b.company.com`. Use this when
|
||||
the apex or deeper subdomains should not serve sign-ins.
|
||||
- `app.company.com` — exactly this host, https only.
|
||||
- `http://localhost:8080` — a full origin with scheme, for non-https
|
||||
exceptions.
|
||||
|
||||
Under `localhost`, both wildcard forms match any scheme and any port, as
|
||||
a development convenience. An entry on a different domain name
|
||||
automatically becomes a related origin (🔗) instead — see below.
|
||||
|
||||
### Wildcard syntax
|
||||
|
||||
Wildcards follow the shell-glob convention — the same one permission
|
||||
scopes use (`*` within a segment, `**` across segments, see
|
||||
[the perm argument](api/perm.md)): `**` spans any number of hostname
|
||||
labels including none, `*` spans exactly one. Wildcards must stay within
|
||||
the domain, and plain `*` is not accepted — it would suggest "anything
|
||||
goes".
|
||||
|
||||
Conventions elsewhere differ: DNS, TLS and nginx take `*.example.com` to
|
||||
mean subdomains only (TLS: exactly one level), while browser-extension
|
||||
match patterns take it as apex plus any depth. The `*`/`**` split
|
||||
sidesteps that ambiguity, and the less obvious forms `*example.com` and
|
||||
`.example.com` remain unsupported on purpose.
|
||||
|
||||
## The auth host (🔑)
|
||||
|
||||
Marking one allowed origin as the **auth host** (row menu ⋮ → "Set as
|
||||
auth host") centralizes the account and admin interface on that site,
|
||||
e.g. `auth.company.com`:
|
||||
|
||||
- On the auth host the web UI is served at the site root (`/` instead of
|
||||
`/auth/`), and all passkey operations for the domain happen there.
|
||||
- The domain's other sites show only a minimal profile page at `/auth/`
|
||||
with logout and a link to the full profile; their sign-in dialogs talk
|
||||
to the auth host behind the scenes. Every sign-in site still needs to
|
||||
be listed in (or covered by) the allowed origins.
|
||||
|
||||
The auth host is strictly per-domain — domains never borrow each other's
|
||||
auth host. To consolidate several domains on one sign-in site, that site
|
||||
must lie under each domain's name (nested domains, e.g. domains
|
||||
`company.com` and `auth.company.com`) and be marked on each of them.
|
||||
|
||||
## Related origins: sharing passkeys across domain names
|
||||
|
||||
Sometimes a few different domain names should accept the _same_ passkeys
|
||||
— for example after a rebrand, when `app2.com` should keep working with
|
||||
existing `company.com` passkeys. Adding `app2.com` to `company.com`'s
|
||||
allowed origins makes it a **related origin**: browsers then let
|
||||
`app2.com` use `company.com` passkeys directly — no redirects, no
|
||||
cross-domain cookies. (This uses the WebAuthn "Related Origin Requests"
|
||||
mechanism, which is why the UI also says ROR.)
|
||||
|
||||
Rules:
|
||||
|
||||
- At most **5 related origins per domain** — a browser limit. This is for
|
||||
a small family of equally trusted sites, not for hundreds of customer
|
||||
domains; use separate domains for those.
|
||||
- Exact hosts only — no wildcards — and always outside the domain's own
|
||||
name.
|
||||
- The browser verifies the setup against
|
||||
`https://<domain>/.well-known/webauthn`. Paskia serves that document
|
||||
automatically when it hosts the domain's main site; if the main site is
|
||||
hosted elsewhere, copy the JSON document shown in the domain dialog and
|
||||
publish it there. The dialog also checks the published document for
|
||||
you.
|
||||
- A related origin shares the domain's security boundary completely — do
|
||||
not mix trust levels within one domain.
|
||||
- When several domains could claim a host: a host that _is_ a configured
|
||||
domain name always serves its own domain; otherwise an explicit related
|
||||
origin listing wins over merely falling under another domain's name.
|
||||
|
||||
Passkeys never move between domains. If you later consolidate separate
|
||||
domains onto one, users re-enroll: sign in once via remote authorization
|
||||
(below), then register a new passkey for the common domain from the
|
||||
profile page.
|
||||
|
||||
## Signing in across domains
|
||||
|
||||
Users exist once, but need a passkey per domain. Two mechanisms smooth
|
||||
this over:
|
||||
|
||||
- **Remote authorization:** a user without a passkey for the current
|
||||
domain can start a login request and approve it from any device already
|
||||
signed in — on _any_ domain of the instance. The approval screen shows
|
||||
which site is requesting access.
|
||||
- **Enroll on the spot:** when the signed-in user has no passkey for the
|
||||
current domain, the profile page offers "Add Passkey for {domain}", so
|
||||
everyday sign-in stays local from then on.
|
||||
|
||||
## OIDC with multiple domains
|
||||
|
||||
OIDC clients are shared by the whole instance: register a client once and
|
||||
it works through every domain. Each domain serves its own discovery URL
|
||||
(`https://<host>/.well-known/openid-configuration`), listed in the admin
|
||||
OIDC client view. Have each app pick **one** discovery URL and use it
|
||||
consistently, so its tokens always validate against the same issuer.
|
||||
|
||||
## Command line
|
||||
|
||||
The admin panel covers all domain management after bootstrap. On the
|
||||
command line:
|
||||
|
||||
- `paskia init [domain] [name]` — creates the database `paskia.kantadb`
|
||||
with the first domain. Run again with an existing database to add
|
||||
another domain (or update a display name).
|
||||
- `paskia migrate [domain]` — converts a legacy 1.x `{domain}.paskiadb`
|
||||
database to `paskia.kantadb`; see below.
|
||||
- `paskia` — serves all configured domains; takes no domain options, only
|
||||
`--listen` as a per-run override.
|
||||
|
||||
## Upgrading from 1.x
|
||||
|
||||
2.0 intentionally changes the on-disk layout and the domain configuration
|
||||
model:
|
||||
|
||||
- The database is the single file **`paskia.kantadb`** in the working
|
||||
directory; user files (avatars) live in **`paskia.data/users/`**.
|
||||
`paskia migrate` performs the conversion and renames the old database
|
||||
aside to `{domain}.paskiadb.converted-bak`. With several legacy
|
||||
databases, the positional argument selects one by name. Legacy wildcard
|
||||
origins convert as-is (https only, except any scheme and port under
|
||||
`localhost`); a legacy database without configured origins — where that
|
||||
meant the whole domain was allowed — gets an explicit `**.{domain}`
|
||||
entry.
|
||||
- Origins, auth hosts and related origins are no longer environment
|
||||
settings — they live in the database and are managed in the admin
|
||||
panel's Domains section. `PASKIA_AUTH_HOST` remains only as a
|
||||
development-server (vite) setting.
|
||||
- OIDC becomes instance-global: one signing key and one client set,
|
||||
reachable through every domain's discovery URL (previously each rp-id
|
||||
had its own). Existing clients keep working through any domain.
|
||||
@@ -173,4 +173,4 @@ The auth check then always returns 204 (except reauth with `max_age`, which stil
|
||||
|
||||
- The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia.
|
||||
- Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`.
|
||||
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`.
|
||||
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to Paskia instead of `/auth/`.
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ auth.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Remember to specify `paskia serve --auth-host auth.example.com` to restrict the authentication services to this domain.
|
||||
Remember to set the auth host for the domain in the admin panel's Domains section to restrict the authentication services to this domain.
|
||||
|
||||
Note that we still reserve `/auth/` on each site for logout page and any APIs your application may require, while full user profile and global options are only available on the auth host.
|
||||
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) fo
|
||||
|
||||
## WebSocket support for `/auth/`
|
||||
|
||||
If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
|
||||
If you use a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
|
||||
|
||||
## Public access
|
||||
|
||||
|
||||
@@ -123,4 +123,4 @@ The `Remote-*` success-headers glob already copies the `Remote-Public` header th
|
||||
- The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers.
|
||||
- HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically.
|
||||
- The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script).
|
||||
- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site.
|
||||
- If you use a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to the Paskia backend instead of exposing `/auth/` on every site.
|
||||
|
||||
@@ -92,7 +92,7 @@ authResponseHeaders:
|
||||
|
||||
The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them.
|
||||
|
||||
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`.
|
||||
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and set the domain's auth host in the admin panel's Domains section.
|
||||
|
||||
## Adjusting requirements
|
||||
|
||||
|
||||
+18
-7
@@ -75,18 +75,22 @@ Runs tests with Playwright Inspector for step-by-step debugging.
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── playwright.config.ts # Playwright configuration
|
||||
├── playwright.config.js # Playwright configuration
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── test-data/ # Test database (created at runtime)
|
||||
│ └── test.sqlite
|
||||
│ └── paskia.kantadb
|
||||
└── tests/
|
||||
├── global-setup.ts # Creates fresh DB, captures reset token
|
||||
├── global-setup.ts # Creates fresh DB (localhost + test.localhost domains), captures reset token
|
||||
├── global-teardown.ts # Cleanup
|
||||
├── passkey.spec.ts # Main E2E tests
|
||||
├── 10-passkey.spec.ts # Registration, authentication, session tests
|
||||
├── 20-api-auth.spec.ts # API-mode iframe flows (401/403/reauth)
|
||||
├── 50-multidomain.spec.ts# Multi-domain dispatch, related origins, auth hosts, remote login
|
||||
├── 99-logout.spec.ts # Logout (runs last)
|
||||
└── fixtures/
|
||||
├── virtual-authenticator.ts # Virtual authenticator setup
|
||||
└── passkey-helpers.ts # WebSocket helpers
|
||||
├── passkey-helpers.ts # WebSocket helpers
|
||||
└── remote-auth.ts # Pairing-code remote auth helpers
|
||||
```
|
||||
|
||||
## What's Tested
|
||||
@@ -107,6 +111,13 @@ e2e/
|
||||
- Logout (`/auth/api/logout`)
|
||||
- Invalid/missing token rejection
|
||||
|
||||
### Multi-Domain
|
||||
- Host-based domain dispatch (`localhost` vs `test.localhost`, 421 for unknown hosts)
|
||||
- Related Origin Requests well-known endpoint and admin domain API
|
||||
- Per-domain auth hosts (UI at the site root)
|
||||
- WebSocket cross-domain rules
|
||||
- Cross-domain remote login via pairing code
|
||||
|
||||
## How Virtual Authenticator Works
|
||||
|
||||
The tests use Chrome DevTools Protocol (CDP) to create a virtual authenticator:
|
||||
@@ -142,12 +153,12 @@ This creates an in-browser authenticator that:
|
||||
## Limitations
|
||||
|
||||
1. **Chromium only**: Virtual authenticator is a Chrome DevTools feature
|
||||
2. **No cross-origin**: Tests run on localhost; production-like origins need additional setup
|
||||
2. **Multi-domain via `*.localhost`**: Chrome resolves any `*.localhost` hostname to loopback, which the tests use for cross-domain scenarios; non-localhost domains are exercised only via explicit Host headers (Node-side requests)
|
||||
3. **Single user per run**: Bootstrap creates one admin user; additional users need admin API
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
1. **Check test database**: `e2e/test-data/test.sqlite` persists after tests
|
||||
1. **Check test database**: `e2e/test-data/paskia.kantadb` is removed during teardown; comment out the cleanup in `global-teardown.ts` to inspect it after a run
|
||||
2. **View server output**: Global setup echoes server bootstrap to console
|
||||
3. **Use trace viewer**: `npx playwright show-trace` on failure traces
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { test, expect } from './fixtures/virtual-authenticator'
|
||||
import {
|
||||
registerPasskey,
|
||||
getSessionCookieName,
|
||||
popDeviceToken,
|
||||
} from './fixtures/passkey-helpers'
|
||||
import {
|
||||
startRemoteAuthRequest,
|
||||
awaitRemoteAuthSession,
|
||||
permitRemoteAuth,
|
||||
} from './fixtures/remote-auth'
|
||||
|
||||
/**
|
||||
* Multi-domain E2E tests.
|
||||
*
|
||||
* The server is bootstrapped with two domains: localhost (default) and
|
||||
* test.localhost. Chrome resolves any *.localhost hostname to loopback, so
|
||||
* both domains are reachable over real HTTP from the browser.
|
||||
*
|
||||
* Covers:
|
||||
* - Host-based domain dispatch (settings, 421 for unknown hosts)
|
||||
* - Related Origin Requests well-known endpoint + admin domain API,
|
||||
* including HTTP dispatch to a related hostname
|
||||
* - Per-domain auth hosts: settings, UI at the site root, /auth/ redirect
|
||||
* - WebSocket cross-domain rule: rejected unless the Host is the origin
|
||||
* domain's own auth host
|
||||
* - Cross-domain remote login: a passkey registered on localhost permits a
|
||||
* session on test.localhost via pairing code
|
||||
* - The profile enrollment prompt on a domain where the user has no passkey
|
||||
*/
|
||||
|
||||
test.describe('Multi-domain E2E', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
const domainUrl = 'http://test.localhost:4404'
|
||||
|
||||
test('dispatches domains by host header', async ({ page }) => {
|
||||
// Browser navigation: Chrome maps *.localhost to loopback
|
||||
const domainResp = await page.goto(`${domainUrl}/auth/api/settings`)
|
||||
expect(domainResp?.status()).toBe(200)
|
||||
const domainSettings = await domainResp?.json()
|
||||
expect(domainSettings.rp_id).toBe('test.localhost')
|
||||
expect(domainSettings.own_auth_host).toBeNull()
|
||||
expect(domainSettings.auth_host).toBeNull()
|
||||
expect(domainSettings.ui_base_path).toBe('/auth/')
|
||||
|
||||
const defaultResp = await page.goto(`${baseUrl}/auth/api/settings`)
|
||||
expect(defaultResp?.status()).toBe(200)
|
||||
const defaultSettings = await defaultResp?.json()
|
||||
expect(defaultSettings.rp_id).toBe('localhost')
|
||||
expect(defaultSettings.auth_host).toBeNull()
|
||||
expect(defaultSettings.ui_base_path).toBe('/auth/')
|
||||
|
||||
// Unknown host is rejected with 421 Misdirected Request.
|
||||
// page.request is Node-side, so target loopback with an explicit Host.
|
||||
const unknownResp = await page.request.get(`${baseUrl}/auth/api/settings`, {
|
||||
headers: { Host: 'unknown.example.org' },
|
||||
})
|
||||
expect(unknownResp.status()).toBe(421)
|
||||
})
|
||||
|
||||
test('well-known webauthn endpoint reflects related origins', async ({ page }) => {
|
||||
// No related origins configured initially → 404
|
||||
const before = await page.request.get(`${baseUrl}/.well-known/webauthn`)
|
||||
expect(before.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('master admin manages domains and related origins via API', async ({ page, virtualAuthenticator }) => {
|
||||
// Fresh session via device token (domain writes require recent auth)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'No device tokens available')
|
||||
await page.goto('/auth/')
|
||||
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
|
||||
expect(reg.session_token).toBeTruthy()
|
||||
|
||||
const headers = { Cookie: `${getSessionCookieName()}=${reg.session_token}` }
|
||||
|
||||
// List domains
|
||||
const list = await page.request.get(`${baseUrl}/auth/api/admin/domains/`, { headers })
|
||||
expect(list.ok()).toBeTruthy()
|
||||
const domains = await list.json()
|
||||
expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost'])
|
||||
const localhostDomain = domains.find((r: any) => r.rp_id === 'localhost')
|
||||
expect(localhostDomain.origins).toEqual({ '**.localhost': true })
|
||||
|
||||
// Add a related origin (unrelated domain) to the localhost domain —
|
||||
// same origins table; classification is derived from the rp-id
|
||||
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
|
||||
headers,
|
||||
data: { rp_name: '', origins: { '**.localhost': true, 'app.example.com': true } },
|
||||
})
|
||||
expect(patch.ok()).toBeTruthy()
|
||||
|
||||
// The well-known endpoint now lists it
|
||||
const wk = await page.request.get(`${baseUrl}/.well-known/webauthn`)
|
||||
expect(wk.ok()).toBeTruthy()
|
||||
const wkJson = await wk.json()
|
||||
expect(wkJson.origins).toContain('https://app.example.com')
|
||||
|
||||
// The related hostname now dispatches to the listing domain (HTTP).
|
||||
// page.request is Node-side, so target loopback with an explicit Host.
|
||||
const relResp = await page.request.get(`${baseUrl}/auth/api/settings`, {
|
||||
headers: { Host: 'app.example.com' },
|
||||
})
|
||||
expect(relResp.ok()).toBeTruthy()
|
||||
expect((await relResp.json()).rp_id).toBe('localhost')
|
||||
|
||||
// Restore: back to the pristine seeded state for later tests
|
||||
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
|
||||
headers,
|
||||
data: { rp_name: '', origins: { '**.localhost': true } },
|
||||
})
|
||||
expect(restore.ok()).toBeTruthy()
|
||||
const after = await page.request.get(`${baseUrl}/.well-known/webauthn`)
|
||||
expect(after.status()).toBe(404)
|
||||
|
||||
// ...and the related hostname is unknown again
|
||||
const relGone = await page.request.get(`${baseUrl}/auth/api/settings`, {
|
||||
headers: { Host: 'app.example.com' },
|
||||
})
|
||||
expect(relGone.status()).toBe(421)
|
||||
})
|
||||
|
||||
test('per-domain auth host serves the domain UI at its site root', async ({ page, virtualAuthenticator }) => {
|
||||
// Fresh session via device token (domain writes require recent auth)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'No device tokens available')
|
||||
await page.goto('/auth/')
|
||||
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
|
||||
expect(reg.session_token).toBeTruthy()
|
||||
|
||||
const headers = { Cookie: `${getSessionCookieName()}=${reg.session_token}` }
|
||||
const authHost = 'auth.test.localhost:4404'
|
||||
|
||||
try {
|
||||
// Mark an auth host on the test.localhost domain. Chrome resolves any
|
||||
// *.localhost hostname to loopback, so the auth host is reachable.
|
||||
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
|
||||
headers,
|
||||
data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true }, '**.test.localhost': true } },
|
||||
})
|
||||
expect(patch.ok()).toBeTruthy()
|
||||
|
||||
// The auth host dispatches to its domain and reports itself in settings
|
||||
const settingsResp = await page.goto(`http://${authHost}/auth/api/settings`)
|
||||
expect(settingsResp?.status()).toBe(200)
|
||||
const settings = await settingsResp?.json()
|
||||
expect(settings.rp_id).toBe('test.localhost')
|
||||
expect(settings.auth_host).toBe(authHost)
|
||||
expect(settings.own_auth_host).toBe(authHost)
|
||||
expect(settings.ui_base_path).toBe('/')
|
||||
|
||||
// The UI lives at the site root on the auth host
|
||||
const rootResp = await page.goto(`http://${authHost}/`)
|
||||
expect(rootResp?.status()).toBe(200)
|
||||
expect(rootResp?.headers()['content-type']).toContain('text/html')
|
||||
|
||||
// /auth/ on the auth host redirects to the root
|
||||
const redir = await page.request.get(`${baseUrl}/auth/`, {
|
||||
headers: { Host: authHost },
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(redir.status()).toBe(307)
|
||||
expect(redir.headers()['location']).toMatch(/^http:\/\/auth\.test\.localhost(:\d+)?\/$/)
|
||||
} finally {
|
||||
// Restore: back to the pristine seeded state (later tests sign in on
|
||||
// test.localhost, and an empty table would allow nothing)
|
||||
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
|
||||
headers,
|
||||
data: { rp_name: '', origins: { '**.test.localhost': true } },
|
||||
})
|
||||
expect(restore.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
const after = await page.request.get(`${baseUrl}/auth/api/settings`, {
|
||||
headers: { Host: 'test.localhost:4404' },
|
||||
})
|
||||
expect((await after.json()).auth_host).toBeNull()
|
||||
})
|
||||
|
||||
test('WebSocket cross-domain connections require the origin domain\'s own auth host', async ({ page }) => {
|
||||
await page.goto(`${domainUrl}/auth/`)
|
||||
|
||||
// Same-domain WebSocket receives authentication options...
|
||||
const sameDomain: any = await page.evaluate(async () => {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(`ws://${location.host}/auth/ws/authenticate`)
|
||||
const timer = setTimeout(() => { ws.close(); resolve({ message: false }) }, 5000)
|
||||
ws.onmessage = () => { clearTimeout(timer); ws.close(); resolve({ message: true }) }
|
||||
ws.onerror = () => { clearTimeout(timer); resolve({ message: false }) }
|
||||
})
|
||||
})
|
||||
expect(sameDomain.message).toBe(true)
|
||||
|
||||
// ...but a cross-domain connection is closed pre-accept: test.localhost
|
||||
// has no auth host of its own, so no other host may serve its logins
|
||||
const crossDomain: any = await page.evaluate(async (host) => {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(`ws://${host}/auth/ws/authenticate`)
|
||||
let message = false
|
||||
const timer = setTimeout(() => { ws.close(); resolve({ message, code: -1 }) }, 5000)
|
||||
ws.onmessage = () => { message = true }
|
||||
ws.onclose = (event) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ message, code: event.code, wasClean: event.wasClean })
|
||||
}
|
||||
})
|
||||
}, new URL(baseUrl).host)
|
||||
expect(crossDomain.message).toBe(false)
|
||||
expect(crossDomain.wasClean).toBe(false)
|
||||
})
|
||||
|
||||
test('cross-domain remote login via pairing code', async ({ page, virtualAuthenticator }) => {
|
||||
// Register a fresh passkey on localhost (this test's virtual authenticator)
|
||||
const deviceToken = popDeviceToken()
|
||||
test.skip(!deviceToken, 'No device tokens available')
|
||||
await page.goto('/auth/')
|
||||
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
|
||||
expect(reg.session_token).toBeTruthy()
|
||||
|
||||
// Requester page on the other domain (no session there)
|
||||
const reqPage = await page.context().newPage()
|
||||
await reqPage.goto(`${domainUrl}/auth/`)
|
||||
|
||||
const pairingCode = await startRemoteAuthRequest(reqPage)
|
||||
expect(pairingCode.split('.')).toHaveLength(3)
|
||||
|
||||
// Approver permits with the localhost passkey; the "found" message names
|
||||
// the requesting domain
|
||||
const found = await permitRemoteAuth(page, pairingCode)
|
||||
expect(found.rp_id).toBe('test.localhost')
|
||||
|
||||
// The requester redeems the exchange code on its own domain and the
|
||||
// session validates there for the same user
|
||||
const validation = await awaitRemoteAuthSession(reqPage)
|
||||
expect(validation.ctx.user.uuid).toBe(reg.user)
|
||||
|
||||
// The session is recorded with the requesting host
|
||||
const userInfo = await reqPage.evaluate(async () => {
|
||||
const resp = await fetch('/auth/api/user-info')
|
||||
if (!resp.ok) throw new Error(`user-info failed: ${resp.status}`)
|
||||
return resp.json()
|
||||
})
|
||||
const current = Object.values(userInfo.sessions as any[]).find((s: any) => s.is_current) as any
|
||||
expect(current.host).toContain('test.localhost')
|
||||
|
||||
// The profile on test.localhost prompts adding a passkey for this domain,
|
||||
// and the existing localhost passkey carries a domain badge
|
||||
await reqPage.goto(`${domainUrl}/auth/`)
|
||||
const notice = reqPage.locator('.domain-enroll-notice')
|
||||
await expect(notice).toBeVisible({ timeout: 15000 })
|
||||
await expect(notice).toContainText('test.localhost')
|
||||
await expect(reqPage.locator('.badge-domain').first()).toHaveText('localhost')
|
||||
|
||||
await reqPage.close()
|
||||
})
|
||||
})
|
||||
Vendored
+187
@@ -0,0 +1,187 @@
|
||||
import { type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Remote authentication (pairing code) helpers for E2E tests.
|
||||
* These drive the /auth/ws/remote-auth/* protocol directly in browser context,
|
||||
* so requests carry the page origin's cookies and Chrome's host resolution.
|
||||
*/
|
||||
|
||||
// PBKDF2-SHA512 PoW solver; must match frontend/src/utils/pow.js.
|
||||
// Passed as source into page.evaluate and instantiated with eval there.
|
||||
const solvePoWSource = `async (challengeBytes, work) => {
|
||||
const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits'])
|
||||
const solution = new Uint8Array(8 * work)
|
||||
const nonce = new Uint32Array(2)
|
||||
const mask = 0x7FF
|
||||
for (let i = 0; i < work; i++) {
|
||||
let result
|
||||
do {
|
||||
if (++nonce[0] === 0x100000000) ++nonce[1]
|
||||
result = new Uint32Array(await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512' }, baseKey, 32))
|
||||
} while (result[0] & mask)
|
||||
solution.set(new Uint8Array(nonce.buffer), i * 8)
|
||||
}
|
||||
return solution
|
||||
}`
|
||||
|
||||
const b64helpersSource = `
|
||||
const b64dec = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
||||
const b64enc = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
||||
`
|
||||
|
||||
/**
|
||||
* Start a remote auth request on the given page (the device wanting to log in).
|
||||
* The page must already be navigated to the requesting domain's origin.
|
||||
* Keeps the WebSocket open on window.__raWs and collects later messages into
|
||||
* window.__raMsgs; resolves with the pairing code.
|
||||
*/
|
||||
export async function startRemoteAuthRequest(page: Page): Promise<string> {
|
||||
return page.evaluate(async ({ powSrc, b64src }) => {
|
||||
const solvePoW = eval(`(${powSrc})`)
|
||||
const { b64dec, b64enc } = eval(`(() => { ${b64src}; return { b64dec, b64enc } })()`)
|
||||
const w = window as any
|
||||
w.__raMsgs = []
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://${location.host}/auth/ws/remote-auth/request`)
|
||||
w.__raWs = ws
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
w.__raMsgs.push(data)
|
||||
if (typeof data.status === 'number' && data.status >= 400) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail || `request failed: ${data.status}`))
|
||||
return
|
||||
}
|
||||
if (data.pow && !data.pairing_code) {
|
||||
const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work)
|
||||
ws.send(JSON.stringify({ pow: b64enc(solution), action: 'login' }))
|
||||
return
|
||||
}
|
||||
if (data.pairing_code) {
|
||||
resolve(data.pairing_code)
|
||||
}
|
||||
}
|
||||
ws.onerror = () => reject(new Error('WebSocket error during remote auth request'))
|
||||
ws.onclose = (event) => {
|
||||
if (!event.wasClean && event.code !== 1000) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
||||
}
|
||||
})
|
||||
}, { powSrc: solvePoWSource, b64src: b64helpersSource })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the remote auth request on the page to complete, redeem the
|
||||
* exchange code via set-session, and return the /auth/api/validate response.
|
||||
*/
|
||||
export async function awaitRemoteAuthSession(page: Page, timeoutMs = 90000): Promise<any> {
|
||||
return page.evaluate(async ({ timeoutMs }) => {
|
||||
const w = window as any
|
||||
const msgs: any[] = w.__raMsgs
|
||||
if (!msgs) throw new Error('No remote auth request started on this page')
|
||||
const exchangeCode: string = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timed out waiting for remote auth completion')), timeoutMs)
|
||||
const iv = setInterval(() => {
|
||||
const done = msgs.find(m => m.status === 'authenticated' && m.exchange_code)
|
||||
const failed = msgs.find(m => ['denied', 'expired', 'timeout', 'cancelled'].includes(m.status) || (typeof m.status === 'number' && m.status >= 400))
|
||||
if (done) {
|
||||
clearTimeout(timer); clearInterval(iv)
|
||||
resolve(done.exchange_code)
|
||||
} else if (failed) {
|
||||
clearTimeout(timer); clearInterval(iv)
|
||||
reject(new Error(failed.detail || `Remote auth ${failed.status}`))
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
const resp = await fetch('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${exchangeCode}` },
|
||||
})
|
||||
if (!resp.ok) throw new Error(`set-session failed: ${resp.status}`)
|
||||
const validate = await fetch('/auth/api/validate', { method: 'POST' })
|
||||
if (!validate.ok) throw new Error(`validate failed: ${validate.status}`)
|
||||
return await validate.json()
|
||||
}, { timeoutMs })
|
||||
}
|
||||
|
||||
/**
|
||||
* Permit a remote auth request from the given page (the authenticating device).
|
||||
* The page must be on the approver's origin with a valid session cookie and a
|
||||
* virtual authenticator holding a credential for that domain.
|
||||
* Resolves with the "found" message (includes the requesting domain's rp_id).
|
||||
*/
|
||||
export async function permitRemoteAuth(page: Page, code: string): Promise<any> {
|
||||
return page.evaluate(async ({ code, powSrc, b64src }) => {
|
||||
const solvePoW = eval(`(${powSrc})`)
|
||||
const { b64dec, b64enc } = eval(`(() => { ${b64src}; return { b64dec, b64enc } })()`)
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://${location.host}/auth/ws/remote-auth/permit`)
|
||||
let stage = 0
|
||||
let foundMsg: any = null
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
try {
|
||||
if (typeof data.status === 'number' && data.status >= 400) {
|
||||
ws.close()
|
||||
reject(new Error(data.detail || `permit failed: ${data.status}`))
|
||||
return
|
||||
}
|
||||
if (data.pow && stage === 0) {
|
||||
const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work)
|
||||
stage = 1
|
||||
ws.send(JSON.stringify({ code, pow: b64enc(solution) }))
|
||||
return
|
||||
}
|
||||
if (data.status === 'found') {
|
||||
foundMsg = data
|
||||
const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work)
|
||||
stage = 2
|
||||
ws.send(JSON.stringify({ authenticate: true, pow: b64enc(solution) }))
|
||||
return
|
||||
}
|
||||
if (data.optionsJSON) {
|
||||
const opts = data.optionsJSON
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: b64dec(opts.challenge),
|
||||
rpId: opts.rpId,
|
||||
timeout: opts.timeout,
|
||||
userVerification: opts.userVerification,
|
||||
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
|
||||
type: cred.type,
|
||||
id: b64dec(cred.id),
|
||||
transports: cred.transports,
|
||||
})) || [],
|
||||
}
|
||||
}) as PublicKeyCredential | null
|
||||
if (!credential) throw new Error('Failed to get credential')
|
||||
const response = credential.response as AuthenticatorAssertionResponse
|
||||
ws.send(JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: b64enc(credential.rawId),
|
||||
response: {
|
||||
clientDataJSON: b64enc(response.clientDataJSON),
|
||||
authenticatorData: b64enc(response.authenticatorData),
|
||||
signature: b64enc(response.signature),
|
||||
userHandle: response.userHandle ? b64enc(response.userHandle) : null,
|
||||
},
|
||||
type: credential.type,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (data.status === 'success') {
|
||||
ws.close()
|
||||
resolve(foundMsg)
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
ws.close()
|
||||
reject(new Error(err.message || 'Permit failed'))
|
||||
}
|
||||
}
|
||||
ws.onerror = () => reject(new Error('WebSocket error during permit'))
|
||||
})
|
||||
}, { code, powSrc: solvePoWSource, b64src: b64helpersSource })
|
||||
}
|
||||
+78
-80
@@ -1,4 +1,4 @@
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import { execFileSync, spawn, spawnSync } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -20,55 +20,81 @@ interface TestState {
|
||||
/**
|
||||
* Global setup for E2E tests.
|
||||
*
|
||||
* Uses in-memory SQLite database for fast, isolated tests.
|
||||
* Captures the bootstrap reset token for initial user registration.
|
||||
* Bootstraps a fresh combined database (paskia.kantadb) with two domains —
|
||||
* localhost (default) and test.localhost — then starts the server with the
|
||||
* test data directory as its working directory. Captures the bootstrap reset
|
||||
* token from 'paskia init' output for initial user registration.
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
console.log('\n🔧 Setting up E2E test environment...\n')
|
||||
|
||||
// Create test data directory for state file
|
||||
if (!existsSync(testDataDir)) {
|
||||
// Start from a clean slate: the test data directory doubles as the server
|
||||
// working directory, so paskia.kantadb and paskia.data/ are created here
|
||||
rmSync(testDataDir, { recursive: true, force: true })
|
||||
mkdirSync(testDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Build the package first
|
||||
console.log(' Building package with uv build...')
|
||||
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
|
||||
execFileSync('uv', ['build'], { cwd: projectRoot, stdio: 'inherit' })
|
||||
console.log(' ✅ Build complete\n')
|
||||
|
||||
console.log(' Starting server with in-memory database...')
|
||||
if (COLLECT_COVERAGE) {
|
||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||
}
|
||||
|
||||
const state: TestState = {}
|
||||
|
||||
// Build server command - with or without coverage
|
||||
const serverArgs = COLLECT_COVERAGE
|
||||
? [
|
||||
'run', 'coverage', 'run', '--parallel-mode',
|
||||
'-m', 'paskia', '-l', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
: [
|
||||
'run', 'paskia', '-l', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
|
||||
// Use a fresh database file for tests
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing stale test database...')
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
// Bootstrap the database: two domains, localhost and test.localhost
|
||||
console.log(' Bootstrapping database with paskia init...')
|
||||
const initResult = spawnSync(
|
||||
'uv',
|
||||
[
|
||||
'run', '--project', projectRoot,
|
||||
'paskia', 'init', '-l', 'localhost:4404', 'localhost',
|
||||
],
|
||||
{ cwd: testDataDir, encoding: 'utf-8' }
|
||||
)
|
||||
const initOutput = `${initResult.stdout}${initResult.stderr}`
|
||||
process.stdout.write(initOutput)
|
||||
if (initResult.status !== 0) {
|
||||
throw new Error(`paskia init failed with exit code ${initResult.status}`)
|
||||
}
|
||||
const addResult = spawnSync(
|
||||
'uv',
|
||||
['run', '--project', projectRoot, 'paskia', 'init', 'test.localhost'],
|
||||
{ cwd: testDataDir, encoding: 'utf-8' }
|
||||
)
|
||||
process.stdout.write(`${addResult.stdout}${addResult.stderr}`)
|
||||
if (addResult.status !== 0) {
|
||||
throw new Error(`paskia init test.localhost failed with exit code ${addResult.status}`)
|
||||
}
|
||||
|
||||
// Start the server using Node's spawn
|
||||
// Parse the reset token from init output
|
||||
// Format: http://localhost:4404/auth/{token} where token is dot-separated words
|
||||
const match = initOutput.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||
if (!match) {
|
||||
throw new Error('Failed to capture reset token from paskia init output')
|
||||
}
|
||||
state.resetToken = match[1]
|
||||
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
|
||||
|
||||
// Start the server (serve mode: all configuration comes from the database)
|
||||
console.log(' Starting server...')
|
||||
const serverArgs = COLLECT_COVERAGE
|
||||
? [
|
||||
'run', '--project', projectRoot,
|
||||
'coverage', 'run', '--parallel-mode',
|
||||
'-m', 'paskia', '-l', 'localhost:4404'
|
||||
]
|
||||
: [
|
||||
'run', '--project', projectRoot,
|
||||
'paskia', '-l', 'localhost:4404'
|
||||
]
|
||||
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
cwd: projectRoot,
|
||||
cwd: testDataDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASKIA_DB: testDbFile,
|
||||
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -76,66 +102,38 @@ export default async function globalSetup() {
|
||||
|
||||
state.serverPid = serverProcess.pid
|
||||
|
||||
// Capture output to find reset token
|
||||
const resetTokenPromise = new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for server bootstrap (30s)'))
|
||||
}, 30000)
|
||||
|
||||
let output = ''
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
output += text
|
||||
process.stdout.write(text) // Echo to console
|
||||
|
||||
// Look for the reset token URL in the output
|
||||
// Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token}
|
||||
// where token is word.word.word.word.word (dot separated)
|
||||
const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/)
|
||||
if (match) {
|
||||
clearTimeout(timeout)
|
||||
// Wait a bit for server to fully start
|
||||
setTimeout(() => resolve(match[1]), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
serverProcess.stdout?.on('data', handleData)
|
||||
serverProcess.stderr?.on('data', handleData)
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
})
|
||||
serverProcess.stdout?.on('data', (data: Buffer) => process.stdout.write(data))
|
||||
serverProcess.stderr?.on('data', (data: Buffer) => process.stderr.write(data))
|
||||
|
||||
serverProcess.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`Server exited with code ${code}`))
|
||||
console.error(`Server exited unexpectedly with code ${code}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
state.resetToken = await resetTokenPromise
|
||||
console.log(`\n ✅ Captured reset token: ${state.resetToken}\n`)
|
||||
} catch (err) {
|
||||
console.error('Failed to capture reset token:', err)
|
||||
serverProcess.kill()
|
||||
throw err
|
||||
}
|
||||
|
||||
// Fetch session cookie name from server settings
|
||||
// Wait for the server to become ready and fetch the session cookie name
|
||||
console.log(' Waiting for server readiness...')
|
||||
const deadline = Date.now() + 30000
|
||||
let settings: any = null
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch('http://localhost:4404/auth/api/settings')
|
||||
const settings = await response.json()
|
||||
state.sessionCookie = settings.session_cookie
|
||||
console.log(` ✅ Session cookie name: ${state.sessionCookie}\n`)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settings:', err)
|
||||
serverProcess.kill()
|
||||
throw err
|
||||
if (response.ok) {
|
||||
settings = await response.json()
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Not up yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
}
|
||||
if (!settings) {
|
||||
serverProcess.kill()
|
||||
throw new Error('Server did not become ready in time (30s)')
|
||||
}
|
||||
state.sessionCookie = settings.session_cookie
|
||||
console.log(` ✅ Session cookie name: ${state.sessionCookie}`)
|
||||
console.log(` ✅ Domain: ${settings.rp_id} (${settings.rp_name})\n`)
|
||||
|
||||
// Save state for tests
|
||||
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
||||
|
||||
@@ -59,11 +59,13 @@ export default async function globalTeardown() {
|
||||
rmSync(stateFile, { force: true })
|
||||
}
|
||||
|
||||
// Clean up test database
|
||||
const testDbFile = join(testDataDir, 'test.paskiadb')
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing test database...')
|
||||
rmSync(testDbFile, { force: true, recursive: true })
|
||||
// Clean up test database and auxiliary data
|
||||
for (const name of ['paskia.kantadb', 'paskia.data']) {
|
||||
const p = join(testDataDir, name)
|
||||
if (existsSync(p)) {
|
||||
console.log(` Removing ${name}...`)
|
||||
rmSync(p, { force: true, recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Python coverage report if coverage was collected
|
||||
|
||||
@@ -37,11 +37,11 @@ function normalizeHost(raw) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Host mode is active when an auth_host is configured AND the current host differs from it.
|
||||
* Host mode is active when an own_auth_host is configured AND the current host differs from it.
|
||||
* In host mode, we show a limited profile view with logout and link to full profile.
|
||||
*/
|
||||
const isHostMode = computed(() => {
|
||||
const authHost = store.settings?.auth_host
|
||||
const authHost = store.settings?.own_auth_host
|
||||
if (!authHost) return false
|
||||
const currentHost = normalizeHost(window.location.host)
|
||||
const configuredHost = normalizeHost(authHost)
|
||||
@@ -99,7 +99,7 @@ onMounted(async () => {
|
||||
if (rpName) {
|
||||
// In host mode, show "account summary" style title
|
||||
// Settings are loaded but isHostMode depends on them, so check here
|
||||
const authHost = store.settings?.auth_host
|
||||
const authHost = store.settings?.own_auth_host
|
||||
const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost)
|
||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import { uuidv7 } from 'uuidv7'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
import { originDisplayEntries } from '@/utils/helpers'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -28,6 +28,7 @@ const error = ref(null)
|
||||
const orgs = ref([])
|
||||
const permissions = ref([])
|
||||
const oidcClients = ref([])
|
||||
const domains = ref([])
|
||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||
const currentUserId = ref(null) // UUID for user detail view
|
||||
const currentOidcId = ref(null) // UUID for OIDC client detail view
|
||||
@@ -174,6 +175,16 @@ async function loadAdminData() {
|
||||
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
||||
}
|
||||
|
||||
// Domain list is master-admin only; callers guard on isMasterAdmin
|
||||
async function loadDomains() {
|
||||
try {
|
||||
domains.value = await apiJson('/auth/api/admin/domains/')
|
||||
} catch (e) {
|
||||
console.warn('Unable to load domains', e)
|
||||
domains.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get users for a role as sorted array of [uuid, user]
|
||||
function roleUsers(org, roleUuid) {
|
||||
return Object.entries(org.users)
|
||||
@@ -207,6 +218,7 @@ function clearSensitiveState() {
|
||||
orgs.value = []
|
||||
permissions.value = []
|
||||
oidcClients.value = []
|
||||
domains.value = []
|
||||
userDetail.value = null
|
||||
editingOidcClient.value = null
|
||||
authenticated.value = false
|
||||
@@ -236,6 +248,7 @@ async function load() {
|
||||
await loadAdminData()
|
||||
// If we get here, user has admin access - now fetch user info for display
|
||||
await loadUserInfo()
|
||||
if (isMasterAdmin.value) await loadDomains()
|
||||
|
||||
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
@@ -452,31 +465,48 @@ function resetOidcSecret(clientId) {
|
||||
if (editingOidcClient.value?.client_id === clientId) {
|
||||
editingOidcClient.value = { ...editingOidcClient.value, client_secret }
|
||||
}
|
||||
// Also update dialog if open (for backwards compatibility)
|
||||
if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) {
|
||||
dialog.value.data.client_secret = client_secret
|
||||
}
|
||||
}
|
||||
|
||||
function createPermissionForClient(clientId) {
|
||||
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
||||
}
|
||||
|
||||
async function openServerConfig() {
|
||||
try {
|
||||
const config = await apiJson('/auth/api/admin/server-config')
|
||||
// Strip https:// scheme from stored origins and auth_host for editing
|
||||
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
|
||||
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
|
||||
openDialog('server-config', {
|
||||
rp_name: config.rp_name || '',
|
||||
auth_host,
|
||||
origins,
|
||||
originValidation: origins.map(() => null),
|
||||
function createDomain() {
|
||||
openDialog('domain-edit', {
|
||||
isNew: true,
|
||||
rp_id: '',
|
||||
rp_name: '',
|
||||
auth_host: '',
|
||||
origins: [],
|
||||
originValidation: [],
|
||||
wellKnownCheck: null,
|
||||
})
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
|
||||
}
|
||||
|
||||
function openDomain(domain) {
|
||||
// One combined list for editing, in display order: in-domain sites and
|
||||
// related origins, classified by hostname against the rp-id.
|
||||
const rows = originDisplayEntries(domain)
|
||||
openDialog('domain-edit', {
|
||||
isNew: false,
|
||||
rp_id: domain.rp_id,
|
||||
rp_name: domain.rp_name || '',
|
||||
auth_host: rows.find(r => r.auth)?.key || '',
|
||||
origins: rows.map(r => r.key),
|
||||
originValidation: rows.map(() => null),
|
||||
wellKnownCheck: null,
|
||||
})
|
||||
}
|
||||
|
||||
function deleteDomain(domain) {
|
||||
openDialog('confirm', {
|
||||
message: `Delete domain "${domain.rp_id}"? This is refused while any passkeys remain registered for it.`,
|
||||
action: async () => {
|
||||
await apiJson(`/auth/api/admin/domains/${domain.rp_id}`, { method: 'DELETE' })
|
||||
authStore.showMessage(`Domain "${domain.rp_id}" deleted.`, 'success', 2500)
|
||||
await loadDomains()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function deleteOidcClient(client) {
|
||||
@@ -875,50 +905,38 @@ async function submitDialog() {
|
||||
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'oidc-edit') {
|
||||
const { client_id, client_secret, isNew } = dialog.value.data
|
||||
const name = dialog.value.data.name?.trim()
|
||||
const uris = dialog.value.data.redirect_uris?.trim()
|
||||
if (!name) throw new Error('Client name required')
|
||||
} else if (t === 'domain-edit') {
|
||||
const d = dialog.value.data
|
||||
const rp_id = d.rp_id?.trim().toLowerCase()
|
||||
if (!rp_id) throw new Error('Domain (rp-id) required')
|
||||
const rp_name = d.rp_name?.trim() || ''
|
||||
const auth_host = d.auth_host?.trim().toLowerCase() || ''
|
||||
// One origins object holds in-domain sites and related origins
|
||||
// (ROR) together; the server classifies each key against the rp-id.
|
||||
// Keys are stored lowercased, without the https:// scheme.
|
||||
const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase()
|
||||
const origins = {}
|
||||
for (const o of d.origins || []) {
|
||||
const key = keyOf(o.trim())
|
||||
if (!key) continue
|
||||
origins[key] = key === auth_host ? { auth_host: true } : true
|
||||
}
|
||||
|
||||
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
|
||||
const req = client_secret
|
||||
? sha256Hex(client_secret).then(secret_hash => isNew
|
||||
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
|
||||
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
|
||||
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
|
||||
const req = d.isNew
|
||||
? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } })
|
||||
: apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } })
|
||||
req
|
||||
.then(() => {
|
||||
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||
loadAdminData()
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'server-config') {
|
||||
const rp_name = dialog.value.data.rp_name?.trim() || ''
|
||||
const auth_host = dialog.value.data.auth_host?.trim() || ''
|
||||
// Origins are stored as-is (hostnames); backend normalizes with https://
|
||||
const origins = dialog.value.data.origins
|
||||
.map(o => o.trim())
|
||||
.filter(o => o)
|
||||
|
||||
closeDialog()
|
||||
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
|
||||
.then(() => {
|
||||
authStore.showMessage('Server configuration updated.', 'success', 2500)
|
||||
authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||
loadDomains()
|
||||
// Reload settings to reflect rp_name changes
|
||||
authStore.loadSettings().then(() => {
|
||||
authStore.loadSettings(true).then(() => {
|
||||
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||
})
|
||||
})
|
||||
.catch(e => {
|
||||
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
|
||||
authStore.showMessage(e.message || 'Failed to save domain', 'error')
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'confirm') {
|
||||
@@ -973,6 +991,8 @@ async function submitDialog() {
|
||||
:orgs="orgs"
|
||||
:permissions="permissions"
|
||||
:oidc-clients="oidcClients"
|
||||
:domains="domains"
|
||||
:current-rp-id="authStore.settings?.rp_id || ''"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
:permission-summary="permissionSummary"
|
||||
@create-org="createOrg"
|
||||
@@ -986,7 +1006,9 @@ async function submitDialog() {
|
||||
@create-oidc-client="createOidcClient"
|
||||
@open-oidc-client="openOidcClient"
|
||||
@delete-oidc-client="deleteOidcClient"
|
||||
@open-server-config="openServerConfig"
|
||||
@create-domain="createDomain"
|
||||
@open-domain="openDomain"
|
||||
@delete-domain="deleteDomain"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
/>
|
||||
|
||||
@@ -1029,6 +1051,7 @@ async function submitDialog() {
|
||||
ref="adminOidcDetailRef"
|
||||
:client="editingOidcClient"
|
||||
:permissions="permissions"
|
||||
:domains="domains"
|
||||
:is-new="editingOidcClient.isNew"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@save="handleOidcSave"
|
||||
@@ -1047,11 +1070,8 @@ async function submitDialog() {
|
||||
<AdminDialogs
|
||||
:dialog="dialog"
|
||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||
:settings="authStore.settings"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
@reset-oidc-secret="resetOidcSecret"
|
||||
@create-permission-for-client="createPermissionForClient"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+440
-140
@@ -1,37 +1,73 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { compareOrigins } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
PERMISSION_ID_PATTERN: String,
|
||||
settings: Object
|
||||
PERMISSION_ID_PATTERN: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
||||
defineEmits(['submitDialog', 'closeDialog'])
|
||||
|
||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||
const NO_SUBMIT_TYPES = new Set([])
|
||||
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
||||
|
||||
// Initialize validation properties
|
||||
if (props.dialog?.data && props.dialog.type === 'server-config') {
|
||||
if (!('authHostValidation' in props.dialog.data)) {
|
||||
props.dialog.data.authHostValidation = null
|
||||
}
|
||||
}
|
||||
// The rp-id of the domain being edited in the 'domain-edit' dialog
|
||||
// (lowercased: classification compares against it, and hosts are
|
||||
// case-insensitive)
|
||||
const dialogRpId = computed(() => (props.dialog?.data?.rp_id || '').trim().toLowerCase())
|
||||
|
||||
// Block submit on hard errors: malformed entries, an over-cap related
|
||||
// list (the server rejects the save), a save that would lock the admin
|
||||
// out of the domain they are using, or validation still in flight.
|
||||
// Connectivity and rp-id mismatch results are warnings only (entries may
|
||||
// be hosted elsewhere, or a new domain whose DNS is not routed to this
|
||||
// instance yet).
|
||||
const isValidationInvalid = computed(() => {
|
||||
if (props.dialog?.type !== 'server-config') return false
|
||||
if (props.dialog?.type !== 'domain-edit') return false
|
||||
const d = props.dialog.data
|
||||
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
|
||||
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
|
||||
const bad = v => v === 'invalid' || v === 'validating'
|
||||
if (d.originValidation?.some(bad)) return true
|
||||
if (relatedEntries.value.length > 5) return true
|
||||
if (d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||||
if (lockoutWarning.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// A single origins list holds two kinds of entries: sites on the rp-id
|
||||
// domain form the in-domain sign-in allow-list; entries on other domain
|
||||
// names are related origins (WebAuthn ROR). Classification is automatic
|
||||
// from the hostname. A bare '*' or '**' is invalid (wildcards must sit
|
||||
// under the rp-id) and never a related origin.
|
||||
function isRelatedEntry(origin) {
|
||||
if (isWildcardEntry(origin)) return false // wildcards are never related
|
||||
const h = originHostname(origin)
|
||||
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value))
|
||||
}
|
||||
const relatedEntries = computed(() => {
|
||||
const d = props.dialog?.data
|
||||
if (!d?.origins) return []
|
||||
return d.origins.filter(isRelatedEntry)
|
||||
})
|
||||
|
||||
// Well-known document browsers fetch from the rp-id domain to verify the
|
||||
// related-origin list (never from the auth host).
|
||||
const wellKnownUrl = computed(() => {
|
||||
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
return host ? `https://${host}/.well-known/webauthn` : ''
|
||||
})
|
||||
|
||||
// ROR origins must be absolute https URLs in the well-known document.
|
||||
function asHttpsOrigin(origin) {
|
||||
const o = origin.trim().replace(/\/+$/, '')
|
||||
return o.startsWith('http') ? o : `https://${o}`
|
||||
}
|
||||
const wellKnownJson = computed(() =>
|
||||
JSON.stringify({ origins: relatedEntries.value.map(asHttpsOrigin) })
|
||||
)
|
||||
|
||||
// Copy-to-clipboard helper
|
||||
const authStore = useAuthStore()
|
||||
function copyText(value, label) {
|
||||
@@ -40,138 +76,405 @@ function copyText(value, label) {
|
||||
})
|
||||
}
|
||||
|
||||
function addOrigin() {
|
||||
// --- Lockout prevention (editing the domain in use) ---
|
||||
|
||||
// When the admin edits the domain they are currently signed in on and no
|
||||
// auth host is marked (with one, ceremonies move there and saving is
|
||||
// always allowed), their current page origin must stay allowed to run
|
||||
// passkey ceremonies — otherwise saving locks them out. Mirrors the
|
||||
// backend check (Passkey.validate_origin): an in-domain origin matches a
|
||||
// row exactly (scheme+host+port) or a wildcard row — '**.base' covers
|
||||
// the apex and subdomains at any depth, '*.base' exactly one subdomain
|
||||
// level — over https, except under localhost (any scheme and port);
|
||||
// a related row matches only on exact equality (https://host).
|
||||
const lockoutWarning = computed(() => {
|
||||
const d = props.dialog?.data
|
||||
if (d) {
|
||||
d.origins.push(rpId.value)
|
||||
if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null
|
||||
const rpId = dialogRpId.value
|
||||
if (!rpId || rpId !== authStore.settings?.rp_id) return null
|
||||
return pageOriginAllowed(d.origins || [], rpId) ? null : window.location.host
|
||||
})
|
||||
|
||||
// Whether any origin diagnostic is present
|
||||
const hasOriginDiagnostics = computed(() => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || !d) return false
|
||||
if (d.originValidation?.some(v => v === 'invalid' || v === 'unreachable' || v === 'mismatch')) return true
|
||||
return relatedEntries.value.length > 5 || !!lockoutWarning.value
|
||||
})
|
||||
|
||||
// Any runtime diagnostic to show in the dialog's attached feedback panel
|
||||
const hasDiagnostics = computed(
|
||||
() => hasOriginDiagnostics.value || !!props.dialog?.data?.wellKnownCheck
|
||||
)
|
||||
|
||||
function pageOriginAllowed(rows, rpId) {
|
||||
const toUrl = key => (isWildcardEntry(key) || key.includes('://')) ? key : 'https://' + key
|
||||
const inDomain = []
|
||||
const related = []
|
||||
for (const row of rows) {
|
||||
if (!originHostname(row)) continue
|
||||
const key = entryKey(row).toLowerCase()
|
||||
if (!key) continue
|
||||
const bucket = isRelatedEntry(row) ? related : inDomain
|
||||
bucket.push(toUrl(key))
|
||||
}
|
||||
const probe = origin => {
|
||||
let hostname
|
||||
try { hostname = new URL(origin).hostname } catch { return false }
|
||||
if (hostname === rpId || hostname.endsWith('.' + rpId)) {
|
||||
if (inDomain.includes(origin)) return true
|
||||
return inDomain.some(e => {
|
||||
const base = wildcardBase(e)
|
||||
if (!base) return false
|
||||
const matched = e.startsWith('**.')
|
||||
? hostname === base || hostname.endsWith('.' + base)
|
||||
: hostname.endsWith('.' + base) && !hostname.slice(0, -base.length - 1).includes('.')
|
||||
if (!matched) return false
|
||||
// Under localhost a wildcard matches any scheme and port
|
||||
return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://')
|
||||
})
|
||||
}
|
||||
return related.includes(origin)
|
||||
}
|
||||
// The page scheme may be http (e.g. on localhost) — probe both
|
||||
return probe(`https://${window.location.host}`) || probe(`http://${window.location.host}`)
|
||||
}
|
||||
|
||||
const originInputs = ref([])
|
||||
|
||||
async function addOrigin() {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
d.origins.push('')
|
||||
d.originValidation.push(null)
|
||||
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
||||
await nextTick()
|
||||
originInputs.value[originInputs.value.length - 1]?.focus()
|
||||
}
|
||||
|
||||
// Row validation runs after a short typing pause and immediately on
|
||||
// blur, so no error indication appears mid-edit. Empty rows are ignored.
|
||||
const originValidateTimers = new Map()
|
||||
|
||||
function scheduleValidateOrigin(i) {
|
||||
clearTimeout(originValidateTimers.get(i))
|
||||
originValidateTimers.set(i, setTimeout(() => {
|
||||
originValidateTimers.delete(i)
|
||||
validateOrigin(i)
|
||||
}, 600))
|
||||
}
|
||||
|
||||
function onOriginBlur(i) {
|
||||
clearTimeout(originValidateTimers.get(i))
|
||||
originValidateTimers.delete(i)
|
||||
validateOrigin(i)
|
||||
}
|
||||
|
||||
function removeOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (d) {
|
||||
// Row indices shift on removal — drop all pending validations
|
||||
for (const t of originValidateTimers.values()) clearTimeout(t)
|
||||
originValidateTimers.clear()
|
||||
d.origins.splice(i, 1)
|
||||
d.originValidation.splice(i, 1)
|
||||
}
|
||||
}
|
||||
function stripScheme(val, i) {
|
||||
const d = props.dialog?.data
|
||||
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
function stripSchemeAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||
}
|
||||
function focusOriginStart(e) {
|
||||
e.target.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
function validateOriginDomain(origin, rpId) {
|
||||
if (!origin.trim()) return false
|
||||
function isWellFormedDomain(value) {
|
||||
if (!value.trim()) return false
|
||||
try {
|
||||
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||
const hostname = url.hostname
|
||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
|
||||
// Any DNS label sequence (matching backend validate_rp_id): labels of
|
||||
// 1-63 alnum/hyphen chars, no leading/trailing hyphen, dot-separated
|
||||
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i.test(url.hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function validateOriginConnectivity(origin, i) {
|
||||
// Wildcard entries follow the shell-glob convention: '*.base' covers
|
||||
// exactly one subdomain level, '**.base' the apex and any depth.
|
||||
const isWildcardEntry = value => {
|
||||
const v = value.trim()
|
||||
return v.startsWith('*.') || v.startsWith('**.')
|
||||
}
|
||||
|
||||
// Base domain of a wildcard entry (lowercased); null when the value is
|
||||
// not a wildcard pattern or has no base.
|
||||
function wildcardBase(value) {
|
||||
const v = value.trim()
|
||||
if (v.startsWith('**.')) return v.slice(3).replace(/\.+$/, '').toLowerCase() || null
|
||||
if (v.startsWith('*.')) return v.slice(2).replace(/\.+$/, '').toLowerCase() || null
|
||||
return null
|
||||
}
|
||||
|
||||
function originHostname(origin) {
|
||||
const v = origin.trim()
|
||||
if (!v || v === '*' || v === '**') return null // a bare '*' or '**' is not a valid entry
|
||||
if (isWildcardEntry(v)) {
|
||||
const base = wildcardBase(v)
|
||||
return base && isWellFormedDomain(base) ? base : null
|
||||
}
|
||||
try {
|
||||
const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
|
||||
// The URL parser keeps malformed hostnames like '.localhost' or
|
||||
// 'a..b.com' — reject anything that is not clean dot-separated labels
|
||||
return url.hostname && isWellFormedDomain(url.hostname) ? url.hostname : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinDomain(origin, rpId) {
|
||||
const hostname = originHostname(origin)
|
||||
if (!hostname) return false
|
||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||
}
|
||||
|
||||
async function validateOriginConnectivity(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const value = d.origins[i]
|
||||
|
||||
d.originValidation[i] = 'validating'
|
||||
try {
|
||||
const cleanOrigin = origin.replace(/\/+$/, '')
|
||||
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
|
||||
const cleanValue = value.replace(/\/+$/, '')
|
||||
const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
|
||||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (d.origins[i] !== value) return // entry changed while validating
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||
// Only update if the origin hasn't changed
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = result
|
||||
}
|
||||
// Valid when the entry is served by this instance for the edited domain
|
||||
d.originValidation[i] = (data.rp_id && data.rp_id === dialogRpId.value) ? 'valid' : 'mismatch'
|
||||
} else {
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
}
|
||||
d.originValidation[i] = 'unreachable'
|
||||
}
|
||||
} catch (e) {
|
||||
if (d.origins[i] === origin) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
if (d.origins[i] === value) {
|
||||
d.originValidation[i] = 'unreachable'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateOrigin(origin, i) {
|
||||
// A '*' typed into an empty field expands to '**.<rp-id>' with the second
|
||||
// asterisk selected: typing on (e.g. '.') replaces the selection —
|
||||
// yielding '*.<rp-id>' — while the rp-id stays at the end; Backspace
|
||||
// deletes the second asterisk; doing nothing keeps the any-depth form.
|
||||
// Only typed input into an empty field triggers this — never pasting or
|
||||
// deleting (e.g. backspacing '**' down to '*' must not re-expand).
|
||||
function onOriginInput(i, e) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(origin, id)) {
|
||||
validateOriginConnectivity(origin, i)
|
||||
} else {
|
||||
d.originValidation[i] = 'invalid'
|
||||
const el = e.target
|
||||
const oldKey = entryKey(d.origins[i])
|
||||
let value = el.value
|
||||
if (value === '*' && dialogRpId.value && (e.inputType === 'insertText' || e.inputType === 'insertCompositionText')) {
|
||||
value = '**.' + dialogRpId.value
|
||||
el.value = value
|
||||
el.setSelectionRange(1, 2)
|
||||
}
|
||||
d.origins[i] = value
|
||||
// Keep the auth-host mark on a renamed entry, unless it no longer
|
||||
// qualifies (wildcards and related origins cannot be the auth host)
|
||||
if (d.auth_host && oldKey === d.auth_host) {
|
||||
const key = entryKey(value)
|
||||
d.auth_host = key && !key.startsWith('*') && !isRelatedEntry(value) ? key : ''
|
||||
}
|
||||
d.originValidation[i] = null
|
||||
scheduleValidateOrigin(i)
|
||||
}
|
||||
|
||||
async function validateAuthHostConnectivity(authHost) {
|
||||
function validateOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
|
||||
d.authHostValidation = 'validating'
|
||||
try {
|
||||
const cleanAuthHost = authHost.replace(/\/+$/, '')
|
||||
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
|
||||
const response = await fetch(testUrl + '/auth/api/settings', {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
||||
// Only update if the auth_host hasn't changed
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = result
|
||||
}
|
||||
} else {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (d.auth_host === authHost) {
|
||||
d.authHostValidation = 'invalid-connectivity'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (!d || !d.auth_host?.trim()) {
|
||||
d.authHostValidation = null // Allow empty
|
||||
const value = d.origins[i]
|
||||
// Empty rows are ignored — never errors, and skipped on save
|
||||
if (!value || !value.trim()) {
|
||||
d.originValidation[i] = null
|
||||
return
|
||||
}
|
||||
|
||||
const id = rpId.value
|
||||
if (validateOriginDomain(d.auth_host, id)) {
|
||||
validateAuthHostConnectivity(d.auth_host)
|
||||
} else {
|
||||
d.authHostValidation = 'invalid-domain'
|
||||
if (!originHostname(value)) {
|
||||
d.originValidation[i] = 'invalid'
|
||||
return
|
||||
}
|
||||
if (isWildcardEntry(value)) {
|
||||
// Wildcards have no concrete site to probe, and are only allowed
|
||||
// within the domain (related origins are individual hosts)
|
||||
d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid'
|
||||
return
|
||||
}
|
||||
validateOriginConnectivity(i)
|
||||
}
|
||||
|
||||
// Fetch the well-known document and check it lists every related origin.
|
||||
// Runs automatically whenever the related set changes; result is a
|
||||
// warning only, never a submit blocker (the rp-id site may be hosted
|
||||
// elsewhere, and cross-origin fetches can fail for unrelated reasons).
|
||||
async function testWellKnown() {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const related = relatedEntries.value.map(asHttpsOrigin)
|
||||
if (!related.length) {
|
||||
d.wellKnownCheck = null
|
||||
return
|
||||
}
|
||||
const key = related.join('|')
|
||||
d.wellKnownCheck = 'validating'
|
||||
try {
|
||||
const response = await fetch(wellKnownUrl.value, { headers: { 'Accept': 'application/json' } })
|
||||
if (!response.ok) throw new Error('not ok')
|
||||
const doc = await response.json()
|
||||
if (related.join('|') !== key) return // list changed while fetching
|
||||
const listed = new Set((doc.origins || []).map(o => String(o).replace(/\/+$/, '')))
|
||||
const missing = related.filter(o => !listed.has(o))
|
||||
d.wellKnownCheck = missing.length ? 'missing' : 'valid'
|
||||
d.wellKnownMissing = missing
|
||||
} catch {
|
||||
if (related.join('|') === key) d.wellKnownCheck = 'unreachable'
|
||||
}
|
||||
}
|
||||
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
||||
|
||||
// Prefill a new domain's list with the real '**.<rp-id>' row once its
|
||||
// rp-id is known ('**.x' = the domain apex and all its subdomains over
|
||||
// https, any scheme and port under localhost). The row follows rp-id
|
||||
// edits while it is still the untouched prefilled row; once the admin
|
||||
// edits it, it is left alone. Seeding waits for a complete-looking rp-id
|
||||
// (letters after the final dot) so mid-typing states like 'something.'
|
||||
// don't prefill a broken '**.something'.
|
||||
function looksCompleteDomain(value) {
|
||||
const host = (value || '').trim().replace(/\.$/, '')
|
||||
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
|
||||
}
|
||||
// Tracks the prefilled row so rp-id edits can keep updating it.
|
||||
let seededOrigin = null
|
||||
watch(dialogRpId, rp => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
||||
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
|
||||
const seed = '**.' + rp.trim().replace(/\.$/, '')
|
||||
if (!d.origins.length) {
|
||||
d.origins.push(seed)
|
||||
d.originValidation.push(null)
|
||||
seededOrigin = seed
|
||||
} else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) {
|
||||
d.origins[0] = seed
|
||||
seededOrigin = seed
|
||||
}
|
||||
})
|
||||
|
||||
// --- Row menu: auth host assignment and entry removal ---
|
||||
|
||||
const openMenu = ref(null)
|
||||
|
||||
// The component stays mounted across dialogs; a closed dialog (including
|
||||
// Escape in Modal) must not leave a row menu open
|
||||
watch(() => props.dialog?.type, () => { openMenu.value = null })
|
||||
|
||||
// Close the popup on any click outside it (the toggle button stops
|
||||
// propagation, so it never reaches this listener).
|
||||
function onDocumentClick(e) {
|
||||
if (openMenu.value !== null && !e.target.closest('.row-menu')) openMenu.value = null
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
for (const t of originValidateTimers.values()) clearTimeout(t)
|
||||
originValidateTimers.clear()
|
||||
})
|
||||
|
||||
// Origins-dict key form of an entry (https:// omitted), also used for the
|
||||
// auth_host value.
|
||||
function entryKey(value) {
|
||||
return value?.trim().replace(/^https:\/\//, '').replace(/\/+$/, '') || ''
|
||||
}
|
||||
|
||||
function isAuthHostEntry(origin) {
|
||||
const d = props.dialog?.data
|
||||
const key = entryKey(origin)
|
||||
return !!(key && d?.auth_host && key === d.auth_host)
|
||||
}
|
||||
|
||||
function setAuthHost(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
let key = entryKey(d.origins[i])
|
||||
let added = false
|
||||
const wbase = wildcardBase(key)
|
||||
if (wbase) {
|
||||
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
||||
key = 'auth.' + wbase
|
||||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||
d.origins.push(key)
|
||||
d.originValidation.push(null)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
d.auth_host = key
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
if (added) validateOrigin(d.origins.findIndex(o => entryKey(o) === key))
|
||||
}
|
||||
|
||||
function clearAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (d) d.auth_host = ''
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
}
|
||||
|
||||
// Display order, applied after row-menu actions (never while typing in an
|
||||
// input, to avoid focus loss): auth host first, then the rp-id, then
|
||||
// in-domain entries hierarchically, then related origins.
|
||||
function resortOrigins() {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2
|
||||
const pairs = d.origins.map((o, i) => [o, d.originValidation[i]])
|
||||
pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0]))
|
||||
d.origins = pairs.map(p => p[0])
|
||||
d.originValidation = pairs.map(p => p[1])
|
||||
}
|
||||
|
||||
function onRemoveOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
|
||||
removeOrigin(i)
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-if="dialog.type" @close="$emit('closeDialog')">
|
||||
<template #attached>
|
||||
<div v-if="dialog?.type === 'domain-edit' && (relatedEntries.length || hasDiagnostics)" class="attach-panel" @click.stop>
|
||||
<template v-if="relatedEntries.length">
|
||||
<p class="small muted">
|
||||
Related origins are verified by browsers against
|
||||
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
|
||||
— served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise publish this document there:
|
||||
</p>
|
||||
<pre class="wellknown-doc" title="Click to copy" tabindex="0" @click="copyText(wellKnownJson, 'Well-known document')" @keydown.enter.prevent="copyText(wellKnownJson, 'Well-known document')">{{ wellKnownJson }}</pre>
|
||||
</template>
|
||||
<ul v-if="hasDiagnostics" class="diag-list">
|
||||
<li v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — check for typos in the hostname; a bare '*' or '**' is not allowed, and wildcards only within the domain.</li>
|
||||
<li v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small">Some sites are unreachable — make sure they are routed to this instance.</li>
|
||||
<li v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small">Some sites are reachable but do not serve this domain.</li>
|
||||
<li v-if="relatedEntries.length > 5" class="small error">At most 5 related origins are allowed ({{ relatedEntries.length }} listed) — the save is rejected.</li>
|
||||
<li v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</li>
|
||||
<li v-if="dialog.data.wellKnownCheck === 'validating'" class="small">Checking the published document…</li>
|
||||
<li v-else-if="dialog.data.wellKnownCheck === 'valid'" class="small">✓ The published document lists all related origins.</li>
|
||||
<li v-else-if="dialog.data.wellKnownCheck === 'missing'" class="small error">The published document does not list: {{ (dialog.data.wellKnownMissing || []).join(', ') }}</li>
|
||||
<li v-else-if="dialog.data.wellKnownCheck === 'unreachable'" class="small">Could not fetch the published document to verify it.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<h3 class="modal-title">
|
||||
<template v-if="dialog.type==='org-create'">Create Organization</template>
|
||||
<template v-else-if="dialog.type==='org-update'">Rename Organization</template>
|
||||
@@ -180,8 +483,7 @@ function validateAuthHost() {
|
||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||||
<template v-else-if="dialog.type==='server-config'">Server Options</template>
|
||||
<template v-else-if="dialog.type==='domain-edit'">{{ dialog.data?.isNew ? 'Add Domain' : `Edit Domain: ${dialog.data?.rp_id}` }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
@@ -239,45 +541,54 @@ function validateAuthHost() {
|
||||
<label>Domain Scope
|
||||
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||
</label>
|
||||
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||
<p class="small muted">A domain restricts this permission to that host (any configured domain's rp-id or a subdomain of it). An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='server-config'">
|
||||
<label>Site Branding (rp-name)
|
||||
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
|
||||
<template v-else-if="dialog.type==='domain-edit'">
|
||||
<template v-if="dialog.data.isNew">
|
||||
<label>Domain (rp-id)
|
||||
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
|
||||
</label>
|
||||
<label>Dedicated Authentication Site (auth-host)
|
||||
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
|
||||
<p class="small muted">The domain name passkeys belong to — they work on this domain and its subdomains, and related domains. Cannot be changed later.</p>
|
||||
</template>
|
||||
<label>Display Name (rp-name)
|
||||
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||||
</label>
|
||||
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
|
||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
|
||||
|
||||
<div class="origin-label">
|
||||
Allowed Origins
|
||||
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin">➕</button>
|
||||
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin()" aria-label="Add origin" title="Add origin">➕</button>
|
||||
</div>
|
||||
<div v-if="dialog.data.origins.length" class="origin-list">
|
||||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||
<input
|
||||
ref="originInputs"
|
||||
:value="dialog.data.origins[i]"
|
||||
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
|
||||
@focus="focusOriginStart"
|
||||
@input="e => onOriginInput(i, e)"
|
||||
@blur="onOriginBlur(i)"
|
||||
class="origin-input"
|
||||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||
/>
|
||||
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
||||
<span v-if="isAuthHostEntry(dialog.data.origins[i])" class="key-badge" title="Authentication site — the account and admin interface live here">🔑</span>
|
||||
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="key-badge" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">🔗</span>
|
||||
<div class="row-menu">
|
||||
<button type="button" class="icon-btn" @click.stop="openMenu = openMenu === i ? null : i" aria-label="Origin actions" title="Actions">⋮</button>
|
||||
<div v-if="openMenu === i" class="row-menu-popup">
|
||||
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()"><span class="menu-icon">🔑</span>Remove auth host</button>
|
||||
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)"><span class="menu-icon">🔑</span>Set as auth host</button>
|
||||
<button type="button" @click="onRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
|
||||
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small muted">
|
||||
Only the listed sites may sign in with {{ dialog.data.rp_id }} passkeys. Wildcards may be used: <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain, <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level.<template v-if="relatedEntries.length"> 🔗 means related host requiring WebAuthn ROR setup.</template><template v-if="dialog.data.auth_host"> 🔑 is the dedicated Paskia host for all account management.</template>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@@ -294,38 +605,27 @@ function validateAuthHost() {
|
||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
@click="$emit('closeDialog')"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||||
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
|
||||
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 0; }
|
||||
.oidc-dl dt { font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
|
||||
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
|
||||
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
|
||||
.oidc-groups { cursor: default; }
|
||||
.oidc-group { cursor: pointer; }
|
||||
.oidc-group output { white-space: normal; word-break: break-all; }
|
||||
|
||||
/* Server config origins */
|
||||
/* Domain origins */
|
||||
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
|
||||
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
|
||||
.origin-row .delete-icon { flex-shrink: 0; }
|
||||
.origin-add-btn { font-size: 1.2rem; }
|
||||
.key-badge { flex-shrink: 0; }
|
||||
.row-menu { position: relative; flex-shrink: 0; }
|
||||
.row-menu-popup { position: absolute; right: 0; top: 100%; z-index: 10; display: flex; flex-direction: column; min-width: 9rem; background: var(--color-bg, #fff); border: 1px solid var(--color-border, #ccc); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.row-menu-popup button { display: flex; align-items: center; justify-content: flex-start; gap: 0.45em; text-align: left; padding: var(--space-xs) var(--space-sm); background: none; border: none; cursor: pointer; white-space: nowrap; }
|
||||
.row-menu-popup button:hover:not(:disabled) { background: var(--color-bg-soft, rgba(127,127,127,0.12)); }
|
||||
.row-menu-popup button:disabled { opacity: 0.5; cursor: default; }
|
||||
.row-menu-popup .menu-icon { flex-shrink: 0; width: 1.1em; text-align: center; }
|
||||
.row-menu-popup .delete-menu-icon { filter: saturate(1.4); }
|
||||
|
||||
.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; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-error);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
permissions: Array,
|
||||
domains: Array,
|
||||
isNew: { type: Boolean, default: false },
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
@@ -29,7 +30,17 @@ const clientSecret = ref(null)
|
||||
|
||||
// Computed
|
||||
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
|
||||
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
|
||||
// One discovery URL per domain (the OIDC provider is instance-global;
|
||||
// any configured host works — the RP must use its chosen one consistently)
|
||||
const discoveryUrls = computed(() => {
|
||||
const origins = new Set()
|
||||
for (const d of props.domains || []) {
|
||||
const url = d.site_url && new URL(d.site_url)
|
||||
if (url) origins.add(url.origin)
|
||||
}
|
||||
if (!origins.size) origins.add(new URL(authStore.settings.auth_site_url).origin)
|
||||
return [...origins].sort().map(o => `${o}/.well-known/openid-configuration`)
|
||||
})
|
||||
const iconUrl = computed(() => authSitePath('/favicon.ico'))
|
||||
|
||||
// Groups (permissions) scoped to this client
|
||||
@@ -147,8 +158,14 @@ defineExpose({ focusFirstElement })
|
||||
<span v-else class="small muted">(only stored in hashed form)</span>
|
||||
</dd>
|
||||
|
||||
<dt>Auto Discovery URL</dt>
|
||||
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
|
||||
<dt class="discovery-dt">Auto Discovery URL
|
||||
<span v-if="discoveryUrls.length > 1" class="small muted">Any one — pick the site your users should log in on, and use it consistently.</span>
|
||||
</dt>
|
||||
<dd class="discovery-dd">
|
||||
<span class="discovery-urls">
|
||||
<output v-for="url in discoveryUrls" :key="url" @click="copyText(url, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ url }}</output>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>Icon URL</dt>
|
||||
<dd>
|
||||
@@ -258,6 +275,29 @@ defineExpose({ focusFirstElement })
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discovery-dt {
|
||||
white-space: normal;
|
||||
max-width: 20em;
|
||||
}
|
||||
|
||||
.discovery-dt .small {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.discovery-dd {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.discovery-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { formatDate, originDisplayEntries } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
info: Object,
|
||||
orgs: Array,
|
||||
permissions: Array,
|
||||
oidcClients: Array,
|
||||
domains: Array,
|
||||
currentRpId: { type: String, default: '' },
|
||||
permissionSummary: Object,
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createDomain', 'openDomain', 'deleteDomain', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgActionsRef = ref(null)
|
||||
@@ -37,6 +39,11 @@ function domainDisplay(domain) {
|
||||
return oidcClientNames.value[domain] || domain
|
||||
}
|
||||
|
||||
// Domains display in alphabetical rp-id order.
|
||||
const sortedDomains = computed(() =>
|
||||
[...(props.domains || [])].sort((a, b) => a.rp_id.localeCompare(b.rp_id))
|
||||
)
|
||||
|
||||
// Map OIDC client UUIDs to their group permissions (sorted by scope)
|
||||
const clientGroups = computed(() => {
|
||||
const map = {}
|
||||
@@ -425,14 +432,47 @@ defineExpose({ focusFirstElement })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="isMasterAdmin" class="server-options-section">
|
||||
<div v-if="isMasterAdmin" class="domains-section">
|
||||
<div class="section-header">
|
||||
<h2>Server</h2>
|
||||
<h2>Domains</h2>
|
||||
<p class="section-description">
|
||||
Configure core server settings such as the display name, authentication host, and allowed origins.
|
||||
The domain names (rp-ids) served, along with hosts belonging to them. Each domain has its own passkeys, and each host will only accept passkeys from its own domain. To let several <em>different</em> domain names share the same passkeys, open the domain and configure related domains (WebAuthn Related Origins). Alternatively create entirely separate domains, or combine the two modes. Each domain's own origins may use wildcards; related origins are individual hosts only, at most five per domain.
|
||||
</p>
|
||||
<p class="section-description">
|
||||
Related origins are your choice when you wish to keep existing credentials working on a few alternative domains. Configure separate domains only when there is more separation, or a need for wildcard hosts. Note that users are shared and remote logins remain possible across domains.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="$emit('openServerConfig')">⚙ Server Options</button>
|
||||
<div>
|
||||
<button @click="$emit('createDomain')">+ Add Domain</button>
|
||||
</div>
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain (rp-id)</th>
|
||||
<th>Allowed Origins</th>
|
||||
<th class="center"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!domains || domains.length === 0">
|
||||
<td colspan="3" class="center muted">No domains configured</td>
|
||||
</tr>
|
||||
<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>
|
||||
</div>
|
||||
<div class="perm-id-info">
|
||||
<span class="id-text">{{ domain.rp_id }}</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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -459,7 +499,9 @@ defineExpose({ focusFirstElement })
|
||||
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||
|
||||
/* Server Options Section */
|
||||
.server-options-section { margin-top: var(--space-2xl); }
|
||||
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
/* Domains Section */
|
||||
.domains-section { margin-top: var(--space-2xl); }
|
||||
.domains-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||
.domain-origins { font-family: var(--font-mono, monospace); font-size: 0.85rem; }
|
||||
.domains-section .perm-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -467,6 +467,61 @@ th {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Runtime diagnostics list: 🔸 markers with a hanging indent, so wrapped
|
||||
lines align with the text rather than under the marker */
|
||||
.diag-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.diag-list li {
|
||||
position: relative;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.diag-list li + li {
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.diag-list li::before {
|
||||
content: "🔸";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Dialog attachment panel (runtime diagnostics, related-origin setup):
|
||||
docked on the right of the dialog, so appearing or disappearing never
|
||||
shifts the dialog itself. On narrow screens it hangs below instead. */
|
||||
.attach-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-dialog);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.attach-panel > * + * {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.attach-panel {
|
||||
top: 0;
|
||||
left: calc(100% + 0.75rem);
|
||||
right: auto;
|
||||
/* Never wider than the space right of the centered 500px dialog */
|
||||
width: min(340px, calc(50vw - 286px));
|
||||
max-height: calc(100vh - 3rem);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -576,6 +631,12 @@ th {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Positions attachments (e.g. the diagnostics panel) relative to the
|
||||
dialog; shrink-wraps the panel in the overlay's flex layout */
|
||||
.modal-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-dialog,
|
||||
.modal {
|
||||
background: var(--color-dialog);
|
||||
@@ -804,6 +865,28 @@ th {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.badge-domain {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.domain-enroll-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-subtle);
|
||||
}
|
||||
|
||||
.domain-enroll-notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
.session-meta-info {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
</div>
|
||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="credential.rp_id && settings?.rp_id && credential.rp_id !== settings.rp_id" class="badge badge-domain" :title="`Passkey registered for ${credential.rp_id}`">{{ credential.rp_id }}</span>
|
||||
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
||||
@@ -61,8 +62,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
|
||||
const settings = ref(null)
|
||||
onMounted(async () => { settings.value = await getSettings() })
|
||||
|
||||
const props = defineProps({
|
||||
credentials: { type: Array, default: () => [] },
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<template>
|
||||
<div class="dialog-overlay" @click="$emit('close')">
|
||||
<div class="modal-wrap">
|
||||
<div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="attached" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div v-if="missingDomainPasskey" class="domain-enroll-notice">
|
||||
<p>You don't have a passkey for <strong>{{ rpName }}</strong> ({{ authStore.settings.rp_id }}) yet. Add one to log in here directly.</p>
|
||||
<button @click="addNewCredential" class="btn-primary">Add Passkey for {{ authStore.settings.rp_id }}</button>
|
||||
</div>
|
||||
<CredentialList
|
||||
ref="credentialList"
|
||||
:credentials="credentials"
|
||||
@@ -410,6 +414,11 @@ const hasMultipleSessions = computed(() => Object.keys(sessions.value).length >
|
||||
const credentials = computed(() =>
|
||||
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
)
|
||||
const missingDomainPasskey = computed(() => {
|
||||
const rpId = authStore.settings?.rp_id
|
||||
if (!rpId) return false
|
||||
return !credentials.value.some(c => c.rp_id === rpId)
|
||||
})
|
||||
const useWideLayout = computed(() => {
|
||||
// Check if any single site has more than 8 sessions
|
||||
const groups = {}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<!-- Device info display (shown when 3 words match a request) -->
|
||||
<div v-else-if="deviceInfo" class="device-info">
|
||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||
<p v-if="crossDomainNotice" class="device-meta domain-notice">on <strong>{{ deviceInfo.rp_name || deviceInfo.rp_id }}</strong><template v-if="deviceInfo.rp_name"> ({{ deviceInfo.rp_id }})</template></p>
|
||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
||||
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
@@ -122,6 +123,13 @@ watch(deviceInfo, (newVal) => {
|
||||
emit('deviceInfoVisible', !!newVal)
|
||||
})
|
||||
|
||||
const crossDomainNotice = computed(() => {
|
||||
const info = deviceInfo.value
|
||||
if (!info?.rp_id) return false
|
||||
const ownRpId = settings.value?.rp_id
|
||||
return ownRpId ? info.rp_id !== ownRpId : true
|
||||
})
|
||||
|
||||
const hasInvalidWord = ref(false)
|
||||
const serverError = ref(false)
|
||||
const cursorPos = ref(0)
|
||||
@@ -613,7 +621,9 @@ async function lookupDeviceInfo() {
|
||||
host: res.host,
|
||||
user_agent_pretty: res.user_agent_pretty,
|
||||
client_ip: res.client_ip,
|
||||
action: res.action || 'login'
|
||||
action: res.action || 'login',
|
||||
rp_id: res.rp_id || null,
|
||||
rp_name: res.rp_name || null
|
||||
}
|
||||
lastLookedUpCode = currentCode
|
||||
nextTick(() => { submitBtnRef.value?.focus() })
|
||||
@@ -937,6 +947,12 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.domain-notice {
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -83,8 +83,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else this.currentView = 'profile'
|
||||
},
|
||||
async loadSettings() {
|
||||
this.settings = await getSettings()
|
||||
async loadSettings(force = false) {
|
||||
this.settings = await getSettings(force)
|
||||
},
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
|
||||
@@ -41,3 +41,87 @@ export const hostIP = ip => {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// Display-time ordering of a domain's configured origins (the stored
|
||||
// object is unordered): the auth host first (flagged), then in-domain
|
||||
// entries (exact rp-id, then hierarchical), then related origins — hosts
|
||||
// outside the rp-id domain — hierarchically. An empty origins object
|
||||
// allows nothing and shows as an empty list.
|
||||
|
||||
// Hierarchical origin comparison: split off scheme/port, compare hostnames
|
||||
// label by label from the TLD down, parents before their subdomains and a
|
||||
// wildcard label ('**' any depth, '*' one level — in that order) after all
|
||||
// concrete labels at the same level. Entries on the same host tie-break by
|
||||
// scheme (https first) and numeric port.
|
||||
function originParts(key) {
|
||||
let s = key.toLowerCase().replace(/\/+$/, '')
|
||||
let scheme = ''
|
||||
const sm = s.match(/^([a-z][a-z0-9+.-]*):\/\//)
|
||||
if (sm) { scheme = sm[1]; s = s.slice(sm[0].length) }
|
||||
let port = ''
|
||||
const pm = s.match(/:(\d+)$/)
|
||||
if (pm) { port = pm[1]; s = s.slice(0, -pm[0].length) }
|
||||
const labels = s.split('.').reverse()
|
||||
return { labels, scheme, port }
|
||||
}
|
||||
|
||||
export function compareOrigins(a, b) {
|
||||
const A = originParts(a), B = originParts(b)
|
||||
for (let i = 0; i < Math.max(A.labels.length, B.labels.length); i++) {
|
||||
const la = A.labels[i], lb = B.labels[i]
|
||||
if (la === undefined) return -1
|
||||
if (lb === undefined) return 1
|
||||
if (la === lb) continue
|
||||
const wa = la === '*' || la === '**'
|
||||
const wb = lb === '*' || lb === '**'
|
||||
if (wa && wb) return la === '**' ? -1 : 1
|
||||
if (wa) return 1
|
||||
if (wb) return -1
|
||||
const c = la.localeCompare(lb)
|
||||
if (c) return c
|
||||
}
|
||||
if (A.scheme !== B.scheme) {
|
||||
if (A.scheme === 'https') return -1
|
||||
if (B.scheme === 'https') return 1
|
||||
return A.scheme.localeCompare(B.scheme)
|
||||
}
|
||||
if (A.port && B.port) return Number(A.port) - Number(B.port)
|
||||
return A.port.localeCompare(B.port)
|
||||
}
|
||||
|
||||
// An origins-table entry outside the rp-id domain is a related origin
|
||||
// (WebAuthn ROR). Wildcards ('*.' or '**.') are never related — they are
|
||||
// only valid under the rp-id.
|
||||
function isRelatedKey(rpId, key) {
|
||||
if (key.startsWith('*.') || key.startsWith('**.')) return false
|
||||
try {
|
||||
const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname
|
||||
return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function originDisplayEntries(domain) {
|
||||
const origins = domain.origins || {}
|
||||
const keys = Object.keys(origins)
|
||||
const authKey = keys.find(k => origins[k] !== true && origins[k]?.auth_host)
|
||||
const inDomain = []
|
||||
const related = []
|
||||
for (const k of keys) {
|
||||
if (k === authKey) continue
|
||||
const bucket = isRelatedKey(domain.rp_id, k) ? related : inDomain
|
||||
bucket.push(k)
|
||||
}
|
||||
inDomain.sort((a, b) => {
|
||||
if (a === domain.rp_id) return -1
|
||||
if (b === domain.rp_id) return 1
|
||||
return compareOrigins(a, b)
|
||||
})
|
||||
related.sort(compareOrigins)
|
||||
const rows = []
|
||||
if (authKey) rows.push({ key: authKey, auth: true })
|
||||
for (const k of inDomain) rows.push({ key: k, auth: false })
|
||||
for (const k of related) rows.push({ key: k, auth: false, related: true })
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
let _settingsPromise = null
|
||||
let _settings = null
|
||||
let _requestGen = 0
|
||||
|
||||
export function getSettingsCached() { return _settings }
|
||||
|
||||
export async function getSettings() {
|
||||
export async function getSettings(force = false) {
|
||||
if (force) { _settings = null; _settingsPromise = null; _requestGen++ }
|
||||
if (_settings) return _settings
|
||||
if (_settingsPromise) return _settingsPromise
|
||||
const gen = _requestGen
|
||||
const stale = () => getSettings() // superseded by a force reset: defer to the fresh state
|
||||
_settingsPromise = fetch('/auth/api/settings')
|
||||
.then(r => (r.ok ? r.json() : {}))
|
||||
.then(obj => { _settings = obj || {}; return _settings })
|
||||
.catch(() => { _settings = {}; return _settings })
|
||||
.then(obj => gen === _requestGen ? (_settings = obj || {}) : stale())
|
||||
.catch(() => gen === _requestGen ? (_settings = {}) : stale())
|
||||
return _settingsPromise
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -6,8 +6,12 @@ import { existsSync, renameSync, mkdirSync } from 'node:fs'
|
||||
import sirv from 'sirv'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
// Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/
|
||||
const authHost = process.env.PASKIA_AUTH_HOST
|
||||
// Auth host mode: when set, clients accessing an auth host get /auth/ at / and /auth/admin/ at /admin/
|
||||
// Comma-separated list of bare hostnames (one per domain with a dedicated auth host)
|
||||
const authHosts = (process.env.PASKIA_AUTH_HOST || '')
|
||||
.split(',')
|
||||
.map(h => h.trim().replace(/^https?:\/\//, '').split(':')[0].split('/')[0])
|
||||
.filter(Boolean)
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
appType: 'mpa',
|
||||
@@ -17,6 +21,7 @@ export default defineConfig(({ command }) => ({
|
||||
"/auth/api",
|
||||
"/auth/ws",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/webauthn",
|
||||
// Passphrase links: /auth/word1.word2.word3.word4.word5
|
||||
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
|
||||
// Passphrase links: /word1.word2.word3.word4.word5
|
||||
@@ -25,13 +30,13 @@ export default defineConfig(({ command }) => ({
|
||||
vue(),
|
||||
// Auth host routing: rewrite paths when accessing dedicated auth host
|
||||
// Must run before serve-examples to handle / correctly
|
||||
authHost && {
|
||||
authHosts.length && {
|
||||
name: 'auth-host-routing',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
const host = req.headers.host?.split(':')[0]
|
||||
// Check if request is coming to the auth host
|
||||
if (host === authHost) {
|
||||
if (authHosts.includes(host)) {
|
||||
// Only rewrite specific paths that should map to /auth/*
|
||||
// Rewrite / and /index.html to /auth/
|
||||
if (req.url === '/' || req.url === '/index.html') {
|
||||
@@ -67,7 +72,7 @@ export default defineConfig(({ command }) => ({
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Skip redirect to examples on auth host (handled by auth-host-routing)
|
||||
const host = req.headers.host?.split(':')[0]
|
||||
if (authHost && host === authHost) {
|
||||
if (authHosts.includes(host)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
|
||||
|
||||
## Domains (multi rp-id)
|
||||
|
||||
The OIDC provider is instance-global: one signing key (`oidc.key` in the transaction log) and one client set for the whole instance, usable through every configured domain. Discovery, keys, token and userinfo endpoints resolve the issuer from the request host (domain dispatch), so every configured host is an issuer alias sharing the one key. `Session.issuer` records the issuing origin (scheme included, stamped from the WS Origin) so refresh and back-channel logout produce the right `iss`; `Session.rp_id` records the owning domain for display. `CookieCode` is stamped with the session's rp-id and verified at redemption; `OIDCCode` is not, since the provider is instance-global.
|
||||
|
||||
## Data Models
|
||||
|
||||
**User** — Added: `email`, `preferred_username`
|
||||
|
||||
**Session** — Added: `client_uuid` (None = native, set = OIDC)
|
||||
**Session** — Added: `client_uuid` (None = native, set = OIDC), `issuer` (origin that issued the session), `rp_id` (owning domain, display only)
|
||||
- `key: bytes` — hashed DB key, never stored raw
|
||||
- `secret` → `hash_secret("session", secret)` → DB lookup
|
||||
- OIDC `sid` → `base64url.encode(hash_secret("oidc", session.key))`
|
||||
@@ -15,21 +19,24 @@ OpenID Connect 1.0 provider enabling third-party apps to authenticate users via
|
||||
|
||||
## Auth Codes (In-Memory Only)
|
||||
|
||||
60-second lifetime, auto-cleaned:
|
||||
60-second lifetime, auto-cleaned. Two separate stores keep the OIDC and cookie flows isolated:
|
||||
|
||||
```python
|
||||
from paskia.authcode import AuthCode, OIDC, codes
|
||||
from paskia.authcode import CookieCode, OIDCCode, store_cookie, store_oidc
|
||||
|
||||
class AuthCode(msgspec.Struct):
|
||||
class OIDCCode(msgspec.Struct):
|
||||
session_key: str # Session DB key
|
||||
created: datetime
|
||||
oidc: OIDC | None # Only for OIDC mode
|
||||
redirect_uri, scope: str
|
||||
nonce, code_challenge: str | None # PKCE S256 when provided
|
||||
|
||||
class OIDC(msgspec.Struct):
|
||||
redirect_uri, scope, nonce, code_challenge, code_challenge_method: str
|
||||
class CookieCode(msgspec.Struct):
|
||||
session_key: str
|
||||
created: datetime
|
||||
rp_id: str # domain the code was issued in; checked at redemption
|
||||
```
|
||||
|
||||
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
|
||||
Usage: `code = store_oidc(OIDCCode(...))` → later popped from `oidc_codes` / `cookie_codes`.
|
||||
|
||||
## Authorization Flows
|
||||
|
||||
@@ -89,4 +96,4 @@ Discovery: `backchannel_logout_supported: true`
|
||||
|
||||
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
|
||||
|
||||
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
|
||||
**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/domains.py](paskia/domains.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
|
||||
|
||||
+224
-119
@@ -7,65 +7,56 @@ from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia._version import __version__
|
||||
from paskia.db import legacy
|
||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB, Config
|
||||
from paskia.util import startupbox
|
||||
from paskia.db.structs import DB, Config, DomainConfig
|
||||
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.hostutil import (
|
||||
normalize_auth_host_and_origins,
|
||||
normalize_origin,
|
||||
validate_auth_host,
|
||||
)
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
from paskia.util.runtime import ServeConfig
|
||||
|
||||
EPILOG = """\
|
||||
Example:
|
||||
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
|
||||
Examples:
|
||||
paskia init example.com "Example Corporation"
|
||||
paskia migrate example.com
|
||||
paskia
|
||||
"""
|
||||
|
||||
|
||||
def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
def _split_multi(values: list[str] | None) -> list[str]:
|
||||
"""Split repeatable/comma-separated CLI values into a flat list."""
|
||||
result = []
|
||||
for value in values or []:
|
||||
result.extend(part.strip() for part in value.split(",") if part.strip())
|
||||
return result
|
||||
|
||||
|
||||
def _add_listen_option(p: argparse.ArgumentParser, help_extra: str = "") -> None:
|
||||
p.add_argument(
|
||||
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
|
||||
)
|
||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||
p.add_argument(
|
||||
"--origin",
|
||||
"-l",
|
||||
"--listen",
|
||||
action="append",
|
||||
dest="origins",
|
||||
metavar="URL",
|
||||
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||
metavar="LISTEN",
|
||||
help=(
|
||||
"Endpoint to listen on (default: localhost:4401). "
|
||||
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
|
||||
)
|
||||
p.add_argument(
|
||||
"--auth-host",
|
||||
help=("Dedicated authentication site (optionally with scheme/port)"),
|
||||
)
|
||||
p.add_argument(
|
||||
"--save",
|
||||
action="store_true",
|
||||
help="Save the CLI options to database for future runs.",
|
||||
+ help_extra,
|
||||
)
|
||||
|
||||
|
||||
def _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
|
||||
def _load_stored_config(db_path: Path) -> Config:
|
||||
"""Load the stored Config from disk using Kanta in read-only mode.
|
||||
|
||||
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
|
||||
If the database file does not exist, a default config is returned.
|
||||
Read-only opens never write or migrate the file.
|
||||
"""
|
||||
if not db_path.exists():
|
||||
return Config(rp_id=rp_id)
|
||||
|
||||
kanta = Kanta(
|
||||
str(db_path),
|
||||
DB(config=Config(rp_id=rp_id)),
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = rp_id
|
||||
kanta = Kanta(str(db_path), DB())
|
||||
|
||||
async def _read() -> Config:
|
||||
await kanta.open(readonly=True)
|
||||
@@ -81,6 +72,161 @@ def _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
|
||||
raise SystemExit(f"{e}") from e
|
||||
|
||||
|
||||
def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) -> None:
|
||||
"""Add a domain to an existing database, or update an existing one's
|
||||
rp-name."""
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(db_path), new_db)
|
||||
|
||||
async def _update() -> str:
|
||||
await kanta.open()
|
||||
try:
|
||||
data = kanta.data
|
||||
if rp_id in data.config.domains:
|
||||
if rp_name is None and listen is None:
|
||||
raise SystemExit(f"Domain {rp_id} is already configured.")
|
||||
with kanta.transaction("init:update_domain"):
|
||||
if rp_name is not None:
|
||||
data.config.domains[rp_id].rp_name = rp_name
|
||||
if listen is not None:
|
||||
data.config.listen = listen
|
||||
return f"Updated domain {rp_id}"
|
||||
new = DomainConfig(rp_name=rp_name, origins={f"**.{rp_id}": True})
|
||||
try:
|
||||
validate_config(
|
||||
Config(
|
||||
domains={**data.config.domains, rp_id: new},
|
||||
listen=data.config.listen,
|
||||
)
|
||||
)
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e)) from e
|
||||
with kanta.transaction("init:add_domain"):
|
||||
data.config.domains[rp_id] = new
|
||||
if listen is not None:
|
||||
data.config.listen = listen
|
||||
return f"Added domain {rp_id}"
|
||||
finally:
|
||||
await kanta.close()
|
||||
|
||||
print(f"✅ {asyncio.run(_update())}")
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> None:
|
||||
"""Bootstrap a new paskia.kantadb, or add a domain to an existing one."""
|
||||
rp_id = (args.rp_id or "localhost").strip().lower()
|
||||
rp_name = args.rp_name or None
|
||||
listen = _split_multi(args.listen) or None
|
||||
try:
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e)) from e
|
||||
|
||||
db_path = db_file_path()
|
||||
if db_path.exists():
|
||||
_init_add_domain(db_path, rp_id, rp_name, listen)
|
||||
return
|
||||
if found := legacy.find_legacy_databases():
|
||||
names = ", ".join(str(p) for p in found)
|
||||
raise SystemExit(
|
||||
f"Legacy database(s) found ({names}) — run 'paskia migrate' to "
|
||||
"convert, not 'paskia init'."
|
||||
)
|
||||
|
||||
# Only rp-id and rp-name are bootstrap-time configuration; the new
|
||||
# domain starts with its whole subtree allowed ('**.{rp-id}') and
|
||||
# everything else (origin allow-list, auth host, related domains) is
|
||||
# set up afterwards via the admin interface. The bootstrap rp-name
|
||||
# exists so the very first admin registration ceremony already shows
|
||||
# the correct name.
|
||||
config = Config(
|
||||
domains={rp_id: DomainConfig(rp_name=rp_name, origins={f"**.{rp_id}": True})},
|
||||
listen=listen,
|
||||
)
|
||||
try:
|
||||
validate_config(config)
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e)) from e
|
||||
|
||||
# Create the database; the kanta bootstrap callback seeds it (admin
|
||||
# user, org, permissions, reset token, the OIDC signing key).
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(db_path), new_db)
|
||||
result = {}
|
||||
|
||||
@kanta.bootstrap
|
||||
def _bootstrap(data: DB) -> None:
|
||||
result["passphrase"] = bootstrap(data, config=config)
|
||||
|
||||
async def _create() -> None:
|
||||
async with kanta:
|
||||
pass
|
||||
|
||||
try:
|
||||
asyncio.run(_create())
|
||||
except Exception as e:
|
||||
logging.exception("Failed to create database")
|
||||
db_path.unlink(missing_ok=True)
|
||||
raise SystemExit(f"{e}") from e
|
||||
|
||||
configure_domains(listen=config.listen)
|
||||
registry = build_registry(config)
|
||||
startupbox.print_startup_config(registry, listen=config.listen)
|
||||
log_reset_link(
|
||||
registry.get(rp_id).reset_link_url(result["passphrase"]),
|
||||
"✅ Bootstrap completed!",
|
||||
)
|
||||
|
||||
|
||||
def cmd_migrate(args: argparse.Namespace) -> None:
|
||||
"""Convert a legacy <rp-id>.paskiadb database to paskia.kantadb."""
|
||||
rp_id = legacy.migrate_legacy_database(args.rp_id)
|
||||
print(f"✅ Converted legacy database to {db_file_path()} (domain: {rp_id})")
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> None:
|
||||
"""Open the combined database and serve all configured domains."""
|
||||
db_path = db_file_path()
|
||||
if not db_path.exists():
|
||||
if found := legacy.find_legacy_databases():
|
||||
names = ", ".join(str(p) for p in found)
|
||||
raise SystemExit(
|
||||
f"Database {db_path} not found, but legacy database(s) exist "
|
||||
f"({names}) — run 'paskia migrate' to convert."
|
||||
)
|
||||
raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
|
||||
|
||||
config = _load_stored_config(db_path)
|
||||
|
||||
listen = _split_multi(args.listen) or config.listen
|
||||
configure_domains(listen=listen)
|
||||
try:
|
||||
registry = build_registry(config)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"Invalid stored configuration: {e}") from e
|
||||
# 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)
|
||||
|
||||
# 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,
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
startup_box=None,
|
||||
reload=Path(__file__).parent if DEVMODE else False,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Configure logging to remove the "ERROR:root:" prefix
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
||||
@@ -91,91 +237,50 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=EPILOG,
|
||||
)
|
||||
_add_listen_option(parser)
|
||||
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
action="append",
|
||||
metavar="LISTEN",
|
||||
help=(
|
||||
"Endpoint to listen on (default: localhost:4401). "
|
||||
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
|
||||
),
|
||||
init_parser = argparse.ArgumentParser(
|
||||
prog="paskia init",
|
||||
description="Bootstrap a new paskia.kantadb database in the current "
|
||||
"directory. With an existing database, adds the domain to it instead "
|
||||
"(or updates its rp-name).",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=EPILOG,
|
||||
)
|
||||
add_common_options(parser)
|
||||
init_parser.add_argument(
|
||||
"rp_id",
|
||||
nargs="?",
|
||||
help="Relying Party ID of the initial domain (default: localhost). "
|
||||
"Further domains, origins and auth hosts are added via the admin "
|
||||
"interface — or with another 'paskia init <rp-id>'.",
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"rp_name",
|
||||
nargs="?",
|
||||
help="Relying Party name of the domain (default: same as rp-id). "
|
||||
"Used by the initial admin registration; editable later via admin UI.",
|
||||
)
|
||||
_add_listen_option(init_parser, help_extra=" (stored in the database)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load stored config using a local read-only Kanta instance.
|
||||
# This happens before PASKIA_CONFIG is set, so we must not import
|
||||
# modules that initialize the global database lifecycle.
|
||||
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
|
||||
try:
|
||||
config = _load_stored_config(db_path, rp_id=args.rp_id)
|
||||
except SystemExit as e:
|
||||
print(f"🛑 Paskia {__version__} could not load")
|
||||
sys.exit(str(e))
|
||||
|
||||
# Override stored config with CLI args, or clear with empty string
|
||||
if args.rp_name is not None:
|
||||
config.rp_name = args.rp_name or None
|
||||
if args.auth_host is not None:
|
||||
config.auth_host = args.auth_host or None
|
||||
if args.origins is not None:
|
||||
config.origins = None if args.origins == [""] else args.origins
|
||||
if args.listen is not None:
|
||||
config.listen = None if args.listen == [""] else args.listen
|
||||
|
||||
# Process and normalize auth_host and origins
|
||||
try:
|
||||
validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
|
||||
except ValueError as e:
|
||||
raise SystemExit(str(e))
|
||||
if config.origins:
|
||||
config.origins = [normalize_origin(o) for o in config.origins]
|
||||
config.auth_host, config.origins = normalize_auth_host_and_origins(
|
||||
config.auth_host, config.origins
|
||||
migrate_parser = argparse.ArgumentParser(
|
||||
prog="paskia migrate",
|
||||
description="Convert a legacy <rp-id>.paskiadb database to paskia.kantadb",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
migrate_parser.add_argument(
|
||||
"rp_id",
|
||||
nargs="?",
|
||||
help="rp-id of the legacy database to convert, selecting "
|
||||
"<rp-id>.paskiadb when several legacy candidates exist.",
|
||||
)
|
||||
|
||||
# Parse first endpoint for site_url fallback
|
||||
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
|
||||
port = ep.get("port")
|
||||
|
||||
# Compute site_url and site_path
|
||||
# Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id
|
||||
site_path = "/auth/"
|
||||
if config.auth_host:
|
||||
site_url, site_path = config.auth_host, "/"
|
||||
elif config.origins:
|
||||
site_url = config.origins[0]
|
||||
elif vite_url := os.environ.get("PASKIA_VITE_URL"):
|
||||
site_url = vite_url.rstrip("/") # Devserver
|
||||
elif config.rp_id == "localhost" and port:
|
||||
site_url = f"http://localhost:{port}" # Backend directly if we can
|
||||
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:]))
|
||||
else:
|
||||
site_url = f"https://{config.rp_id}" # Assume external reverse proxy
|
||||
|
||||
# Build runtime configuration for the server
|
||||
runtime = RuntimeConfig(
|
||||
config=config,
|
||||
site_url=site_url,
|
||||
site_path=site_path,
|
||||
save=args.save,
|
||||
)
|
||||
startupbox.print_startup_config(runtime)
|
||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode()
|
||||
|
||||
# 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=config.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
startup_box=None,
|
||||
reload=Path(__file__).parent if DEVMODE else False,
|
||||
)
|
||||
cmd_serve(parser.parse_args(argv))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+10
-1
@@ -24,6 +24,8 @@ class OIDCCode(msgspec.Struct):
|
||||
"""An OIDC authorization code pending token exchange.
|
||||
|
||||
PKCE uses S256 only when provided (verified at token exchange).
|
||||
Codes are redeemable at any host of the instance — the OIDC provider
|
||||
is instance-global.
|
||||
"""
|
||||
|
||||
session_key: str
|
||||
@@ -35,10 +37,17 @@ class OIDCCode(msgspec.Struct):
|
||||
|
||||
|
||||
class CookieCode(msgspec.Struct):
|
||||
"""A cookie exchange code for setting session cookie after WebSocket auth."""
|
||||
"""A cookie exchange code for setting session cookie after WebSocket auth.
|
||||
|
||||
rp_id binds the code to the domain it was issued in; the redemption
|
||||
endpoint (dispatched by Host) must match. This is what allows a
|
||||
remote-auth approver on one domain to mint a code for the requesting
|
||||
device's domain without the code being usable on the wrong domain.
|
||||
"""
|
||||
|
||||
session_key: str
|
||||
created: datetime
|
||||
rp_id: str
|
||||
|
||||
|
||||
# Separate stores for each code type
|
||||
|
||||
+22
-31
@@ -1,20 +1,17 @@
|
||||
"""
|
||||
Bootstrap module for passkey authentication system.
|
||||
|
||||
This module handles initial system setup when a new database is created,
|
||||
including creating default admin user, organization, permissions, and
|
||||
generating a reset link for initial admin setup.
|
||||
|
||||
The actual database seeding is performed by the module-level kanta bootstrap
|
||||
callback defined in :mod:`paskia.db.bootstrap` and registered during
|
||||
:func:`paskia.db.lifecycle.init`.
|
||||
The initial database seeding (admin user, organization, permissions,
|
||||
registration reset token) is performed by ``paskia init`` via
|
||||
:func:`paskia.db.bootstrap.bootstrap`. This module provides the serve-time
|
||||
check that re-prints a registration link when the admin user still has no
|
||||
passkey on any configured domain.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from paskia import authsession, db
|
||||
from paskia import authsession, db, domains
|
||||
from paskia.db.bootstrap import log_reset_link
|
||||
from paskia.db.structs import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,15 +29,15 @@ def _configure_logger() -> None:
|
||||
_configure_logger()
|
||||
|
||||
|
||||
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
return log_reset_link(passphrase, message)
|
||||
|
||||
|
||||
async def check_admin_credentials() -> bool:
|
||||
"""
|
||||
Check if the admin user needs credentials and create a reset link if needed.
|
||||
|
||||
With global users, the admin may hold passkeys under any configured
|
||||
domain — the check passes if the admin has a credential for at least
|
||||
one of them. Otherwise a reset link is printed for the first domain
|
||||
(sorted by rp-id).
|
||||
|
||||
Returns:
|
||||
bool: True if a reset link was created, False if admin already has credentials
|
||||
"""
|
||||
@@ -67,12 +64,15 @@ async def check_admin_credentials() -> bool:
|
||||
if not admin_users:
|
||||
return False
|
||||
|
||||
# Check first admin user for credentials
|
||||
# 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)
|
||||
|
||||
if not admin_user.credential_ids:
|
||||
# Admin exists but has no credentials, create reset link
|
||||
logger.info("⚠️ Admin user has no credentials!")
|
||||
if not any(admin_user.credential_ids_for(rp_id) for rp_id in configured):
|
||||
# Admin exists but has no credential on any domain
|
||||
target = reg.get(configured[0])
|
||||
logger.info("⚠️ Admin user has no credentials on %s!", target.rp_id)
|
||||
|
||||
expiry = authsession.reset_expires()
|
||||
token = db.create_reset_token(
|
||||
@@ -80,7 +80,7 @@ async def check_admin_credentials() -> bool:
|
||||
expiry=expiry,
|
||||
token_type="admin registration",
|
||||
)
|
||||
_log_reset_link(token)
|
||||
log_reset_link(target.reset_link_url(token))
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -89,20 +89,11 @@ async def check_admin_credentials() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def bootstrap_if_needed(config: Config | None = None) -> bool:
|
||||
"""
|
||||
Check if admin needs credentials and create a reset link if needed.
|
||||
|
||||
Database bootstrapping itself is now handled automatically during
|
||||
``db.init()`` via the registered kanta bootstrap callback. This function
|
||||
remains as a post-init hook for credential checks.
|
||||
|
||||
Args:
|
||||
config: Kept for backwards compatibility; config is now applied during
|
||||
``db.init()``.
|
||||
async def bootstrap_if_needed() -> bool:
|
||||
"""Run the serve-time admin credential check.
|
||||
|
||||
Returns:
|
||||
bool: Always returns False (bootstrapping is performed during init).
|
||||
bool: Always returns False (bootstrapping is performed by ``paskia init``).
|
||||
"""
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
|
||||
+10
-6
@@ -25,6 +25,7 @@ from paskia.db.operations import (
|
||||
add_permission_to_role,
|
||||
create_credential,
|
||||
create_credential_session,
|
||||
create_domain,
|
||||
create_oid_client,
|
||||
create_org,
|
||||
create_permission,
|
||||
@@ -32,10 +33,10 @@ from paskia.db.operations import (
|
||||
create_role,
|
||||
create_user,
|
||||
delete_credential,
|
||||
delete_domain,
|
||||
delete_oid_client,
|
||||
delete_org,
|
||||
delete_permission,
|
||||
delete_reset_token,
|
||||
delete_role,
|
||||
delete_session,
|
||||
delete_sessions_for_user,
|
||||
@@ -46,9 +47,8 @@ from paskia.db.operations import (
|
||||
remove_permission_from_org,
|
||||
remove_permission_from_role,
|
||||
reset_oid_client_secret,
|
||||
set_session_host,
|
||||
update_config,
|
||||
update_credential_sign_count,
|
||||
update_domain,
|
||||
update_oid_client,
|
||||
update_org_name,
|
||||
update_permission,
|
||||
@@ -60,9 +60,11 @@ from paskia.db.operations import (
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
OIDC,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
Org,
|
||||
Permission,
|
||||
ResetToken,
|
||||
@@ -84,8 +86,10 @@ __all__ = [
|
||||
"Credential",
|
||||
"DB",
|
||||
"Client",
|
||||
"OIDC",
|
||||
"Org",
|
||||
"Permission",
|
||||
"DomainConfig",
|
||||
"ResetToken",
|
||||
"Role",
|
||||
"Session",
|
||||
@@ -102,13 +106,14 @@ __all__ = [
|
||||
"create_credential_session",
|
||||
"create_org",
|
||||
"create_permission",
|
||||
"create_domain",
|
||||
"create_reset_token",
|
||||
"create_role",
|
||||
"create_user",
|
||||
"delete_credential",
|
||||
"delete_org",
|
||||
"delete_permission",
|
||||
"delete_reset_token",
|
||||
"delete_domain",
|
||||
"delete_role",
|
||||
"delete_session",
|
||||
"delete_sessions_for_user",
|
||||
@@ -117,11 +122,10 @@ __all__ = [
|
||||
"oidc_login",
|
||||
"remove_permission_from_org",
|
||||
"remove_permission_from_role",
|
||||
"set_session_host",
|
||||
"update_config",
|
||||
"update_credential_sign_count",
|
||||
"update_org_name",
|
||||
"update_permission",
|
||||
"update_domain",
|
||||
"update_role_name",
|
||||
"update_session",
|
||||
"update_user_display_name",
|
||||
|
||||
@@ -73,8 +73,3 @@ async def stop_background():
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_background_task = None
|
||||
|
||||
|
||||
# Aliases for backwards compatibility
|
||||
start_cleanup = start_background
|
||||
stop_cleanup = stop_background
|
||||
|
||||
@@ -9,9 +9,8 @@ from datetime import UTC, datetime
|
||||
import uuid7
|
||||
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User
|
||||
from paskia.db.structs import DB, OIDC, Config, Org, Permission, ResetToken, Role, User
|
||||
from paskia.util.crypto import secret_key
|
||||
from paskia.util.hostutil import reset_link_url
|
||||
|
||||
_reset_link_logger = logging.getLogger("paskia.reset_link")
|
||||
|
||||
@@ -34,13 +33,12 @@ ADMIN_RESET_MESSAGE = """
|
||||
"""
|
||||
|
||||
|
||||
def log_reset_link(passphrase: str, message: str | None = None) -> str:
|
||||
def log_reset_link(url: str, message: str | None = None) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
reset_link = reset_link_url(passphrase)
|
||||
if message:
|
||||
_reset_link_logger.info(message)
|
||||
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
||||
return reset_link
|
||||
_reset_link_logger.info(ADMIN_RESET_MESSAGE, url)
|
||||
return url
|
||||
|
||||
|
||||
def bootstrap(
|
||||
@@ -147,8 +145,8 @@ def bootstrap(
|
||||
if config is not None:
|
||||
data.config = config
|
||||
|
||||
# Generate OIDC signing key
|
||||
data.oidc.key = secret_key()
|
||||
# Generate the instance-global OIDC signing key
|
||||
data.oidc = OIDC(key=secret_key())
|
||||
|
||||
# Store all bootstrapped objects in the live data object
|
||||
data.permissions[perm_admin_uuid] = perm_admin
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Legacy database format reader and converter.
|
||||
|
||||
Retains the msgspec structs used by the old ``<rp-id>.paskiadb/main.db``
|
||||
format so existing databases can be opened and converted to the combined
|
||||
``paskia.kantadb`` format. Only the structs whose shape differs from the
|
||||
current schema are redefined here; unchanged structs are imported from
|
||||
``paskia.db.structs``.
|
||||
|
||||
Assumes the on-disk records are in the latest legacy format (schema
|
||||
migrations were discarded together with the old format). This module will
|
||||
be deleted once legacy conversion is no longer supported.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.db.paths import db_file_path, users_root_path
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
OIDC,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class LegacyConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Pre-domains stored configuration (single rp-id per database)."""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None = None
|
||||
auth_host: str | None = None
|
||||
origins: list[str] | None = None
|
||||
listen: list[str] | None = None
|
||||
|
||||
|
||||
class LegacyCredential(msgspec.Struct, dict=True):
|
||||
"""Credential without the rp_id stamp."""
|
||||
|
||||
credential_id: bytes
|
||||
user_uuid: UUID = msgspec.field(name="user")
|
||||
aaguid: UUID
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
created_at: datetime
|
||||
last_used: datetime | None = None
|
||||
last_verified: datetime | None = None
|
||||
|
||||
|
||||
class LegacySession(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Session without the rp_id/issuer stamps."""
|
||||
|
||||
user_uuid: UUID = msgspec.field(name="user")
|
||||
credential_uuid: UUID = msgspec.field(name="credential")
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
validated: datetime
|
||||
client_uuid: UUID | None = msgspec.field(name="client", default=None)
|
||||
|
||||
|
||||
class LegacyDB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
"""Root structure of a legacy single-rp-id database."""
|
||||
|
||||
config: LegacyConfig = msgspec.field(
|
||||
default_factory=lambda: LegacyConfig(rp_id="localhost")
|
||||
)
|
||||
permissions: dict[UUID, Permission] = {}
|
||||
orgs: dict[UUID, Org] = {}
|
||||
roles: dict[UUID, Role] = {}
|
||||
users: dict[UUID, User] = {}
|
||||
credentials: dict[UUID, LegacyCredential] = {}
|
||||
sessions: dict[str, LegacySession] = {}
|
||||
reset_tokens: dict[str, ResetToken] = {}
|
||||
oidc: OIDC = msgspec.field(default_factory=OIDC)
|
||||
|
||||
|
||||
def _read_legacy(path: Path) -> LegacyDB:
|
||||
"""Open a legacy database read-only and return its contents."""
|
||||
kanta = Kanta(str(path), LegacyDB())
|
||||
|
||||
async def _read() -> LegacyDB:
|
||||
await kanta.open(readonly=True)
|
||||
return kanta.data
|
||||
|
||||
return asyncio.run(_read())
|
||||
|
||||
|
||||
def convert_legacy_database(src: Path, dst: Path) -> Config:
|
||||
"""Convert a legacy main.db file into the combined kantadb format.
|
||||
|
||||
Reads the legacy database at ``src`` and writes a fresh database at
|
||||
``dst``. All credentials and sessions are stamped with the legacy
|
||||
database's rp-id; the OIDC provider carries over as-is (it is
|
||||
instance-global).
|
||||
Returns the converted (new-format) configuration.
|
||||
"""
|
||||
old = _read_legacy(src)
|
||||
rp_id = old.config.rp_id
|
||||
|
||||
from paskia.domains import origin_key # noqa: PLC0415 (import cycle)
|
||||
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
for origin in old.config.origins or []:
|
||||
origins[origin_key(origin)] = True
|
||||
if old.config.auth_host:
|
||||
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
||||
if not origins:
|
||||
# Legacy semantics: no origins configured = the whole rp-id domain
|
||||
# allowed. The new format requires explicit entries.
|
||||
origins[f"**.{rp_id}"] = True
|
||||
|
||||
new_config = Config(
|
||||
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
|
||||
listen=old.config.listen,
|
||||
)
|
||||
|
||||
credentials = {
|
||||
uuid: Credential(
|
||||
credential_id=c.credential_id,
|
||||
user_uuid=c.user_uuid,
|
||||
aaguid=c.aaguid,
|
||||
public_key=c.public_key,
|
||||
sign_count=c.sign_count,
|
||||
created_at=c.created_at,
|
||||
rp_id=rp_id,
|
||||
last_used=c.last_used,
|
||||
last_verified=c.last_verified,
|
||||
)
|
||||
for uuid, c in old.credentials.items()
|
||||
}
|
||||
sessions = {
|
||||
key: Session(
|
||||
user_uuid=s.user_uuid,
|
||||
credential_uuid=s.credential_uuid,
|
||||
host=s.host,
|
||||
ip=s.ip,
|
||||
user_agent=s.user_agent,
|
||||
validated=s.validated,
|
||||
client_uuid=s.client_uuid,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
for key, s in old.sessions.items()
|
||||
}
|
||||
|
||||
converted = DB(
|
||||
config=new_config,
|
||||
permissions=old.permissions,
|
||||
orgs=old.orgs,
|
||||
roles=old.roles,
|
||||
users=old.users,
|
||||
credentials=credentials,
|
||||
sessions=sessions,
|
||||
reset_tokens=old.reset_tokens,
|
||||
oidc=old.oidc,
|
||||
)
|
||||
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(dst), new_db)
|
||||
|
||||
@kanta.bootstrap
|
||||
def _seed(data: DB) -> None:
|
||||
data.config = converted.config
|
||||
data.permissions = converted.permissions
|
||||
data.orgs = converted.orgs
|
||||
data.roles = converted.roles
|
||||
data.users = converted.users
|
||||
data.credentials = converted.credentials
|
||||
data.sessions = converted.sessions
|
||||
data.reset_tokens = converted.reset_tokens
|
||||
data.oidc = converted.oidc
|
||||
|
||||
async def _write() -> None:
|
||||
async with kanta:
|
||||
pass
|
||||
|
||||
asyncio.run(_write())
|
||||
return new_config
|
||||
|
||||
|
||||
def find_legacy_databases(cwd: Path | None = None) -> list[Path]:
|
||||
"""Find legacy ``*.paskiadb`` databases in a directory.
|
||||
|
||||
A candidate is either a directory containing ``main.db`` or a legacy
|
||||
single-file database. Empty directories and non-matching files are
|
||||
ignored.
|
||||
"""
|
||||
cwd = cwd or Path.cwd()
|
||||
candidates = []
|
||||
for entry in sorted(cwd.glob("*.paskiadb")):
|
||||
if entry.is_dir():
|
||||
if (entry / "main.db").is_file():
|
||||
candidates.append(entry)
|
||||
elif entry.is_file():
|
||||
candidates.append(entry)
|
||||
return candidates
|
||||
|
||||
|
||||
def migrate_legacy_database(rp_id: str | None = None) -> str:
|
||||
"""Convert a legacy database to ``paskia.kantadb``.
|
||||
|
||||
With ``rp_id``, selects the ``<rp-id>.paskiadb`` candidate by name;
|
||||
without it, exactly one candidate must exist. Returns the migrated
|
||||
domain's rp-id. The converted legacy directory/file is renamed aside
|
||||
to ``<name>.converted-bak`` rather than deleted.
|
||||
|
||||
Raises SystemExit when ``paskia.kantadb`` already exists, when no
|
||||
candidate matches, or when several candidates exist and no ``rp_id``
|
||||
was given to select one.
|
||||
"""
|
||||
target = db_file_path()
|
||||
if target.exists():
|
||||
raise SystemExit(f"Database {target} already exists — nothing to migrate.")
|
||||
candidates = find_legacy_databases()
|
||||
if rp_id is not None:
|
||||
name = f"{rp_id}.paskiadb"
|
||||
matches = [c for c in candidates if c.name == name]
|
||||
if not matches:
|
||||
found = ", ".join(str(c) for c in candidates) or "none"
|
||||
raise SystemExit(
|
||||
f"No legacy database {name} in this directory (candidates: {found})."
|
||||
)
|
||||
src = matches[0]
|
||||
elif not candidates:
|
||||
raise SystemExit("No legacy *.paskiadb database found — nothing to migrate.")
|
||||
elif len(candidates) > 1:
|
||||
names = ", ".join(str(c) for c in candidates)
|
||||
raise SystemExit(
|
||||
f"Multiple legacy databases found ({names}) — select one with "
|
||||
"'paskia migrate <rp-id>'."
|
||||
)
|
||||
else:
|
||||
src = candidates[0]
|
||||
legacy_file = src / "main.db" if src.is_dir() else src
|
||||
config = convert_legacy_database(legacy_file, target)
|
||||
|
||||
# Move persisted user files (avatars) to the new data root
|
||||
legacy_users = src / "users" if src.is_dir() else None
|
||||
if legacy_users is not None and legacy_users.is_dir():
|
||||
target_users = users_root_path(create_root=True)
|
||||
for child in legacy_users.iterdir():
|
||||
shutil.move(str(child), str(target_users / child.name))
|
||||
|
||||
shutil.move(str(src), str(src.with_name(src.name + ".converted-bak")))
|
||||
return next(iter(config.domains))
|
||||
+15
-24
@@ -5,6 +5,7 @@ Database lifecycle: initialization and maintenance.
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -17,24 +18,13 @@ from kanta.exceptions import DatabaseError
|
||||
import paskia.db.operations as _ops
|
||||
from paskia import oidc_notify
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
runtime = runtime_config()
|
||||
if runtime is None:
|
||||
raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle")
|
||||
|
||||
kanta = Kanta(
|
||||
str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)),
|
||||
_ops._db,
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = runtime.config.rp_id
|
||||
# The combined database lives at a fixed CWD-relative path; no runtime
|
||||
# configuration is needed to locate it.
|
||||
kanta = Kanta(str(db_file_path()), _ops._db)
|
||||
_ops._db._store = kanta
|
||||
|
||||
|
||||
@@ -52,7 +42,9 @@ def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
||||
return display_name
|
||||
|
||||
# OIDC clients use "name" instead of "display_name".
|
||||
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
|
||||
oidc_state = state.get("oidc", {})
|
||||
if isinstance(oidc_state, dict):
|
||||
client = oidc_state.get("clients", {}).get(uuid_str)
|
||||
if isinstance(client, dict):
|
||||
name = client.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
@@ -94,6 +86,10 @@ def _resolve_uuid_label(
|
||||
return None
|
||||
|
||||
|
||||
# The OIDC signing key is stored at oidc.key.
|
||||
_OIDC_KEY_PATH = re.compile(r"^oidc\.key$")
|
||||
|
||||
|
||||
@kanta.logfmt
|
||||
def format_log_uuid(
|
||||
value: Any,
|
||||
@@ -105,7 +101,7 @@ def format_log_uuid(
|
||||
# Censor sensitive OIDC 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 (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
|
||||
if _OIDC_KEY_PATH.fullmatch(path) and value != "key":
|
||||
return "<hidden>"
|
||||
|
||||
if not isinstance(value, str):
|
||||
@@ -122,17 +118,12 @@ def terminate(error: DatabaseError) -> None:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
@kanta.bootstrap
|
||||
def bootstrap_db(data: DB) -> None:
|
||||
reset_passphrase = bootstrap(data, config=runtime.config)
|
||||
log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
||||
|
||||
|
||||
async def init():
|
||||
"""Load database from JSONL file using kanta.
|
||||
|
||||
If the database file is empty, the configured bootstrap callback seeds it
|
||||
with default permissions, organization, role, admin user and a reset token.
|
||||
The database must already exist and be initialized (see ``paskia
|
||||
init``); the serve command's startup checks guarantee this before the
|
||||
lifespan runs.
|
||||
"""
|
||||
rootpath = Path(kanta.filename).parent
|
||||
try:
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
Database schema migrations.
|
||||
|
||||
Migrations are applied during database load based on the version field.
|
||||
Each migration should be idempotent and only run when needed.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.util.crypto import secret_key
|
||||
|
||||
|
||||
def migrate_v1(d: dict) -> None:
|
||||
"""Remove Org.created_at fields."""
|
||||
for org_data in d["orgs"].values():
|
||||
org_data.pop("created_at", None)
|
||||
|
||||
|
||||
def migrate_v2(d: dict, kanta: Kanta) -> None:
|
||||
"""Add config field if missing."""
|
||||
if "config" not in d:
|
||||
d["config"] = {"rp_id": kanta.ctx.rp_id}
|
||||
|
||||
|
||||
def migrate_v3(d: dict) -> None:
|
||||
"""Ensure all users have visits field."""
|
||||
for user_data in d["users"].values():
|
||||
user_data.setdefault("visits", 0)
|
||||
|
||||
|
||||
def migrate_v4(d: dict) -> None:
|
||||
"""OpenID Connect support and hardened session keys."""
|
||||
# Session keys changed to hashes, drop old sessions
|
||||
d["sessions"] = {}
|
||||
# Create OIDC structure with a generated new key
|
||||
d["oidc"] = {
|
||||
"clients": {},
|
||||
"key": base64.standard_b64encode(secret_key()).decode(),
|
||||
}
|
||||
|
||||
|
||||
def migrate_v5(d: dict) -> None:
|
||||
"""Convert config.listen from str to list[str] if needed."""
|
||||
listen = d["config"].get("listen")
|
||||
if listen and isinstance(listen, str):
|
||||
d["config"]["listen"] = [listen]
|
||||
+64
-23
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Database for WebAuthn passkey authentication.
|
||||
|
||||
Read operations: Access _db directly, use build_* helpers to get public structs.
|
||||
Read operations: Access _db directly.
|
||||
Context lookup: _db.session_ctx() returns full SessionContext with effective permissions.
|
||||
Write operations: Functions that validate and commit, or raise ValueError.
|
||||
"""
|
||||
@@ -18,9 +18,10 @@ from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
Org,
|
||||
OriginEntry,
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
@@ -37,7 +38,7 @@ _logger = logging.getLogger(__name__)
|
||||
_UNSET = object()
|
||||
|
||||
# Global database instance (empty until init() loads data)
|
||||
_db = DB(config=Config(rp_id="uninitialized.invalid"))
|
||||
_db = DB()
|
||||
|
||||
|
||||
def _store():
|
||||
@@ -76,12 +77,6 @@ def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_config(config: Config) -> None:
|
||||
"""Update the stored configuration."""
|
||||
with _transaction("update_config"):
|
||||
_db.config = config
|
||||
|
||||
|
||||
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Create a new permission."""
|
||||
if perm.uuid in _db.permissions:
|
||||
@@ -462,6 +457,7 @@ def update_session(
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
validated: datetime | None = None,
|
||||
issuer: str | None = None,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
@@ -478,11 +474,8 @@ def update_session(
|
||||
s.user_agent = user_agent
|
||||
if validated is not None:
|
||||
s.validated = validated
|
||||
|
||||
|
||||
def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Set the host for a session (first-time binding)."""
|
||||
update_session(key, host=host, ctx=ctx)
|
||||
if issuer is not None:
|
||||
s.issuer = issuer
|
||||
|
||||
|
||||
def delete_session(
|
||||
@@ -553,14 +546,6 @@ def create_reset_token(
|
||||
return passphrase
|
||||
|
||||
|
||||
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete a reset token."""
|
||||
if key not in _db.reset_tokens:
|
||||
raise ValueError("Reset token not found")
|
||||
with _transaction("delete_reset_token", ctx):
|
||||
_db.reset_tokens[key].delete()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Composite operations (used by app code)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -574,6 +559,7 @@ def login(
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
duration: timedelta = SESSION_LIFETIME,
|
||||
rp_id: str | None = None,
|
||||
) -> str:
|
||||
"""Update user/credential on login and create session in a single transaction.
|
||||
|
||||
@@ -581,7 +567,7 @@ def login(
|
||||
- user.last_seen, user.visits
|
||||
- credential.sign_count, credential.last_used
|
||||
Creates:
|
||||
- new session
|
||||
- new session (stamped with rp_id when provided)
|
||||
|
||||
Returns the generated session token.
|
||||
"""
|
||||
@@ -604,6 +590,7 @@ def login(
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
validated=now,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _transaction("login", user=user_str):
|
||||
@@ -677,6 +664,7 @@ def create_credential_session(
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
validated=now,
|
||||
rp_id=credential.rp_id,
|
||||
)
|
||||
user_str = str(user_uuid)
|
||||
with _transaction("create_credential_session", user=user_str):
|
||||
@@ -703,6 +691,59 @@ def create_credential_session(
|
||||
return token
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Domain operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_domain(
|
||||
rp_id: str, domain: DomainConfig, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
"""Add a new domain (rp-id) to the stored configuration.
|
||||
|
||||
The caller must validate the resulting combined configuration.
|
||||
"""
|
||||
if rp_id in _db.config.domains:
|
||||
raise ValueError(f"Domain {rp_id} already exists")
|
||||
with _transaction("admin:create_domain", ctx):
|
||||
_db.config.domains[rp_id] = domain
|
||||
|
||||
|
||||
def update_domain(
|
||||
rp_id: str,
|
||||
*,
|
||||
rp_name: str | None,
|
||||
origins: dict[str, bool | OriginEntry],
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Replace a domain's rp_name and origins table (wholesale).
|
||||
|
||||
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.
|
||||
"""
|
||||
domain = _db.config.domains.get(rp_id)
|
||||
if domain is None:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
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."""
|
||||
if rp_id not in _db.config.domains:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
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()):
|
||||
raise ValueError(
|
||||
f"Cannot delete domain {rp_id}: credentials still registered under it"
|
||||
)
|
||||
with _transaction("admin:delete_domain", ctx):
|
||||
del _db.config.domains[rp_id]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# OIDC Provider operations
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
+19
-33
@@ -1,47 +1,33 @@
|
||||
from __future__ import annotations
|
||||
"""Filesystem paths for paskia persistence.
|
||||
|
||||
The combined database is a single kanta JSONL file at the fixed
|
||||
CWD-relative path ``paskia.kantadb``. Auxiliary user files (avatars) live
|
||||
under ``paskia.data/``. The deployment is selected by the current working
|
||||
directory; there is deliberately no environment override.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def db_root_path(*, rp_id: str = "localhost") -> Path:
|
||||
"""Return the configured persistence root directory."""
|
||||
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
|
||||
DB_FILENAME = "paskia.kantadb"
|
||||
DATA_DIRNAME = "paskia.data"
|
||||
|
||||
|
||||
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
"""Return the JSONL database file path under the persistence root."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
def db_file_path() -> Path:
|
||||
"""Return the combined database file path."""
|
||||
return Path(DB_FILENAME)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
def data_root_path(create_root: bool = False) -> Path:
|
||||
"""Return the root directory for auxiliary files (avatars etc.)."""
|
||||
root = Path(DATA_DIRNAME)
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "main.db"
|
||||
return root
|
||||
|
||||
|
||||
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
|
||||
def users_root_path(create_root: bool = False) -> Path:
|
||||
"""Return the filesystem root for persisted user files."""
|
||||
root = db_root_path(rp_id=rp_id)
|
||||
|
||||
if root.is_file():
|
||||
_migrate_legacy_db_file(root)
|
||||
|
||||
root = data_root_path(create_root=create_root) / "users"
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return root / "users"
|
||||
|
||||
|
||||
def _migrate_legacy_db_file(legacy_path: Path) -> None:
|
||||
"""Upgrade a legacy single-file database path into a directory root."""
|
||||
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
temp_root.unlink(missing_ok=True)
|
||||
|
||||
temp_root.mkdir(parents=True)
|
||||
legacy_path.replace(temp_root / "main.db")
|
||||
temp_root.rename(legacy_path)
|
||||
return root
|
||||
|
||||
+75
-29
@@ -237,6 +237,10 @@ class User(msgspec.Struct, dict=True, omit_defaults=True, kw_only=True):
|
||||
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
|
||||
return [c.credential_id for c in self.credentials]
|
||||
|
||||
def credential_ids_for(self, rp_id: str) -> list[bytes]:
|
||||
"""Get credential IDs registered under a specific domain's rp-id."""
|
||||
return [c.credential_id for c in self.credentials if c.rp_id == rp_id]
|
||||
|
||||
@property
|
||||
def sessions(self) -> list[Session]:
|
||||
"""Get all sessions for this user."""
|
||||
@@ -290,8 +294,12 @@ class Credential(msgspec.Struct, dict=True):
|
||||
"""Credential (passkey) data structure.
|
||||
|
||||
Mutable fields: sign_count, last_used, last_verified
|
||||
Immutable fields: credential_id, user, aaguid, public_key, created_at
|
||||
Immutable fields: credential_id, user, aaguid, public_key, created_at, rp_id
|
||||
uuid is derived from created_at using uuid7.
|
||||
|
||||
rp_id is the domain the passkey was registered under. With Related Origin
|
||||
Requests it is always the domain's canonical rp-id, regardless of which
|
||||
origin the registration ceremony ran on.
|
||||
"""
|
||||
|
||||
credential_id: bytes # Long binary ID from the authenticator
|
||||
@@ -300,6 +308,7 @@ class Credential(msgspec.Struct, dict=True):
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
created_at: datetime
|
||||
rp_id: str
|
||||
last_used: datetime | None = None
|
||||
last_verified: datetime | None = None
|
||||
|
||||
@@ -341,6 +350,7 @@ class Credential(msgspec.Struct, dict=True):
|
||||
aaguid: UUID,
|
||||
public_key: bytes,
|
||||
sign_count: int,
|
||||
rp_id: str,
|
||||
created_at: datetime | None = None,
|
||||
) -> Credential:
|
||||
"""Create a new Credential with auto-generated uuid7."""
|
||||
@@ -353,6 +363,7 @@ class Credential(msgspec.Struct, dict=True):
|
||||
public_key=public_key,
|
||||
sign_count=sign_count,
|
||||
created_at=now,
|
||||
rp_id=rp_id,
|
||||
last_used=now,
|
||||
last_verified=now,
|
||||
)
|
||||
@@ -363,8 +374,8 @@ class Credential(msgspec.Struct, dict=True):
|
||||
class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Session data structure.
|
||||
|
||||
Mutable fields: validated (updated on session refresh)
|
||||
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
|
||||
Mutable fields: host, ip, user_agent, validated, issuer (update_session)
|
||||
Immutable fields: user_uuid, credential_uuid, client_uuid, rp_id
|
||||
key is the hashed db_key, stored in the dict key, not in the struct.
|
||||
|
||||
If client_uuid is set, this is an OIDC session.
|
||||
@@ -380,6 +391,8 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
user_agent: str
|
||||
validated: datetime
|
||||
client_uuid: UUID | None = msgspec.field(name="client", default=None)
|
||||
rp_id: str | None = None # Owning domain (needed when no request context)
|
||||
issuer: str | None = None # OIDC issuer URL this session was created under
|
||||
|
||||
def __post_init__(self):
|
||||
if not hasattr(self, "key"):
|
||||
@@ -395,14 +408,6 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Get the Credential object for this session."""
|
||||
return db.data().credentials[self.credential_uuid]
|
||||
|
||||
def metadata(self) -> dict:
|
||||
"""Return session metadata for backwards compatibility."""
|
||||
return {
|
||||
"ip": self.ip,
|
||||
"user_agent": self.user_agent,
|
||||
"validated": self.validated.isoformat(),
|
||||
}
|
||||
|
||||
def store(self, last_seen: datetime) -> None:
|
||||
"""Store this session in the database and record a visit.
|
||||
|
||||
@@ -429,11 +434,15 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
user_agent: str,
|
||||
validated: datetime,
|
||||
client: UUID | None = None,
|
||||
rp_id: str | None = None,
|
||||
issuer: str | None = None,
|
||||
) -> Session:
|
||||
"""Create a new Session with the provided key.
|
||||
|
||||
Args:
|
||||
key: The hashed session key (derived from secret via hash_secret)
|
||||
rp_id: Owning domain's rp-id (used when no request context exists)
|
||||
issuer: OIDC issuer URL (scheme + host) for OIDC sessions
|
||||
|
||||
Returns:
|
||||
Session object with key set
|
||||
@@ -452,6 +461,8 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
user_agent=user_agent,
|
||||
validated=validated,
|
||||
client_uuid=client,
|
||||
rp_id=rp_id,
|
||||
issuer=issuer,
|
||||
)
|
||||
session.key = key
|
||||
return session
|
||||
@@ -601,14 +612,50 @@ class OIDC(msgspec.Struct, dict=True):
|
||||
key: bytes | None = None
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
"""Stored configuration for the instance."""
|
||||
class OriginEntry(msgspec.Struct, omit_defaults=True):
|
||||
"""Extra properties of one allowed origin within a domain.
|
||||
|
||||
Stored as the dict value for an origin key; plain ``True`` instead of an
|
||||
object means presence only, nothing more to store.
|
||||
"""
|
||||
|
||||
auth_host: bool = False # This site hosts the account/admin interface
|
||||
|
||||
|
||||
class DomainConfig(msgspec.Struct, omit_defaults=True):
|
||||
"""Configuration for one domain (one WebAuthn rp-id).
|
||||
|
||||
``origins`` is a single table of sites that may sign in with this
|
||||
domain's passkeys, classified by the rp-id: entries within the rp-id
|
||||
domain are in-domain sign-in sites, entries outside it are related
|
||||
origins (WebAuthn Related Origin Requests — individual hosts only,
|
||||
no wildcards). Keys are hosts without the https:// scheme
|
||||
("app.example.com"), wildcard patterns under the rp-id following the
|
||||
shell-glob convention ("**.example.com" — the base domain and its
|
||||
subdomains at any depth; "*.example.com" — exactly one subdomain
|
||||
level; https only, any scheme and port under localhost), or full
|
||||
origins ("http://localhost:8080", "https://app2.com"). An empty dict
|
||||
means nothing is allowed — list sites explicitly. Ordering carries no
|
||||
meaning — display order is decided by the UI.
|
||||
"""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str | None = None
|
||||
auth_host: str | None = None
|
||||
origins: list[str] | None = None
|
||||
listen: list[str] | None = None
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
"""Stored configuration for the instance.
|
||||
|
||||
Domains are keyed by rp-id and shared by the whole administrative
|
||||
instance: organizations and users are global across rp-ids.
|
||||
"""
|
||||
|
||||
domains: dict[str, DomainConfig] = msgspec.field(
|
||||
default_factory=lambda: {
|
||||
"localhost": DomainConfig(origins={"**.localhost": True})
|
||||
}
|
||||
)
|
||||
listen: list[str] | None = None # Process-global listen endpoints
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -619,7 +666,7 @@ class Config(msgspec.Struct, omit_defaults=True):
|
||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
"""In-memory database. Access fields directly for reads."""
|
||||
|
||||
config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost"))
|
||||
config: Config = msgspec.field(default_factory=Config)
|
||||
permissions: dict[UUID, Permission] = {}
|
||||
orgs: dict[UUID, Org] = {}
|
||||
roles: dict[UUID, Role] = {}
|
||||
@@ -627,8 +674,9 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[str, ResetToken] = {}
|
||||
# OIDC provider data
|
||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||
# 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)
|
||||
|
||||
def __post_init__(self):
|
||||
# Optional store reference for non-global DB instances (e.g. tests).
|
||||
@@ -659,7 +707,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
|
||||
Args:
|
||||
session_secret: The session secret (cookie value) - will be hashed for lookup
|
||||
host: Optional host for binding/validation and domain-scoped permissions
|
||||
host: The request host; sessions are host-bound and domain-scoped
|
||||
permissions are filtered by it
|
||||
|
||||
Returns:
|
||||
SessionContext if valid, None if session not found, expired, or host mismatch
|
||||
@@ -675,10 +724,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
if s.client_uuid is not None:
|
||||
return None
|
||||
|
||||
# Validate host matches (sessions are always created with a host)
|
||||
normalized_input = host
|
||||
if s.host != normalized_input:
|
||||
# Session bound to different host
|
||||
# Sessions are host-bound
|
||||
if s.host != host:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -689,8 +736,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
# Effective permissions: role's permissions that the org can grant
|
||||
# Also filter by domain if host is provided
|
||||
# Effective permissions: role's permissions that the org can grant,
|
||||
# filtered by domain restriction
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
|
||||
effective_perms = []
|
||||
@@ -701,8 +748,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
p = self.permissions[perm_uuid]
|
||||
except KeyError:
|
||||
continue
|
||||
# Check domain restriction (normalized_input already has port stripped)
|
||||
if p.domain is not None and p.domain != normalized_input:
|
||||
if p.domain is not None and p.domain != host:
|
||||
continue
|
||||
effective_perms.append(p)
|
||||
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
"""Domain registry: per-rp-id runtime state and host resolution.
|
||||
|
||||
A **domain** is one rp-id with its associated hosts and origins. The
|
||||
registry is built from the stored combined ``Config`` at startup and
|
||||
rebuilt on admin domain changes; request dispatch resolves hosts to
|
||||
domains through it. The database itself is global — only the *current
|
||||
domain* (passkey, site URLs) varies per request, tracked via a
|
||||
contextvar set by the dispatch middleware.
|
||||
|
||||
Each domain's ``origins`` table holds both in-domain sign-in sites and
|
||||
related origins (ROR), classified by the rp-id: entries within the rp-id
|
||||
domain are in-domain, entries outside it are related.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia.db.structs import Config, DomainConfig, OriginEntry
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.constants import DEFAULT_PORT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum number of related (non-subdomain) origins per domain. WebAuthn
|
||||
# Related Origin Requests require browsers to support at least 5 labels.
|
||||
DEFAULT_RELATED_ORIGIN_CAP = 5
|
||||
|
||||
|
||||
def origin_url(key: str) -> str:
|
||||
"""URL form of an origins-table key (https:// is implied); wildcards
|
||||
pass through unchanged."""
|
||||
if hostutil.is_wildcard_pattern(key) or "://" in key:
|
||||
return key
|
||||
return f"https://{key}"
|
||||
|
||||
|
||||
def origin_key(origin: str) -> str:
|
||||
"""Origins-table key for a full origin URL (https:// omitted).
|
||||
|
||||
Keys are canonicalized: lowercased, and bare hosts/wildcards lose any
|
||||
trailing dot.
|
||||
"""
|
||||
key = origin.removeprefix("https://").rstrip("/")
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
prefix = "**." if key.startswith("**.") else "*."
|
||||
return prefix + key[len(prefix) :].rstrip(".").lower()
|
||||
if "://" not in key:
|
||||
key = key.rstrip(".")
|
||||
return key.lower()
|
||||
|
||||
|
||||
def is_related_key(rp_id: str, key: str) -> bool:
|
||||
"""Whether an origins-table key lies outside the rp-id domain (a
|
||||
related origin). Wildcards are never related."""
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
return False
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
return bool(hn) and not hostutil.is_subdomain(hn, rp_id)
|
||||
|
||||
|
||||
def partition_origins(
|
||||
rp_id: str, origins: dict[str, bool | OriginEntry]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Split an origins table into (in-domain keys, related keys)."""
|
||||
in_domain = [k for k in origins if not is_related_key(rp_id, k)]
|
||||
related = [k for k in origins if is_related_key(rp_id, k)]
|
||||
return in_domain, related
|
||||
|
||||
|
||||
def auth_host_url(domain: DomainConfig) -> str | None:
|
||||
"""Full URL of the domain's auth host origin, if one is marked."""
|
||||
for key, props in domain.origins.items():
|
||||
if isinstance(props, OriginEntry) and props.auth_host:
|
||||
return origin_url(key)
|
||||
return None
|
||||
|
||||
|
||||
class Domain:
|
||||
"""Runtime view of one domain: stored config plus derived values."""
|
||||
|
||||
def __init__(self, rp_id: str, config: DomainConfig, site_url: str, site_path: str):
|
||||
in_domain, related = partition_origins(rp_id, config.origins)
|
||||
self.rp_id = rp_id
|
||||
self.config = config
|
||||
self.site_url = site_url
|
||||
self.site_path = site_path
|
||||
self.passkey = Passkey(
|
||||
rp_id=rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=[origin_url(k) for k in in_domain],
|
||||
related_origins=[origin_url(k) for k in related],
|
||||
)
|
||||
|
||||
@property
|
||||
def rp_name(self) -> str:
|
||||
return self.passkey.rp_name
|
||||
|
||||
@property
|
||||
def own_auth_host(self) -> str | None:
|
||||
"""This domain's own auth host as host[:port], if configured."""
|
||||
url = auth_host_url(self.config)
|
||||
return hostutil.auth_host_netloc(url) if url else None
|
||||
|
||||
@property
|
||||
def related_origins(self) -> list[str]:
|
||||
"""Related (cross-domain) origins for ROR, as URLs."""
|
||||
return sorted(self.passkey.related_origins)
|
||||
|
||||
@property
|
||||
def ui_base_path(self) -> str:
|
||||
"""UI base path: site root on an own auth host, /auth/ elsewhere."""
|
||||
return "/" if auth_host_url(self.config) is not None else "/auth/"
|
||||
|
||||
@property
|
||||
def auth_site_url(self) -> str:
|
||||
"""Base URL of this domain's auth site UI."""
|
||||
return self.site_url + self.site_path
|
||||
|
||||
def api_url(self, path: str = "") -> str:
|
||||
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
||||
if not path:
|
||||
return f"{self.site_url}/auth/api/"
|
||||
return f"{self.site_url}/auth/api/{path.lstrip('/')}"
|
||||
|
||||
def reset_link_url(self, token: str) -> str:
|
||||
"""Generate a reset link URL for the given token on this domain."""
|
||||
return f"{self.auth_site_url}{token}"
|
||||
|
||||
|
||||
class DomainRegistry:
|
||||
"""Resolved domains and host lookup tables."""
|
||||
|
||||
def __init__(self, domains: list[Domain]):
|
||||
self._by_rp_id = {d.rp_id: d for d in domains}
|
||||
self._auth_hosts: dict[str, list[Domain]] = {}
|
||||
self._related_hosts: dict[str, Domain] = {}
|
||||
self.warnings: list[str] = []
|
||||
for domain in domains:
|
||||
if own := domain.own_auth_host:
|
||||
key = hostutil.normalize_host(own) or own
|
||||
self._auth_hosts.setdefault(key, []).append(domain)
|
||||
for origin in domain.related_origins:
|
||||
if hostname := hostutil.origin_hostname(origin):
|
||||
# First claimant wins (config order); a related host that
|
||||
# is another domain's rp-id never reaches this map in
|
||||
# resolve() — the owning domain is matched first.
|
||||
self._related_hosts.setdefault(hostname, domain)
|
||||
|
||||
@property
|
||||
def domains(self) -> list[Domain]:
|
||||
"""All domains (unordered — ordering is a display-time affair)."""
|
||||
return list(self._by_rp_id.values())
|
||||
|
||||
def get(self, rp_id: str) -> Domain | None:
|
||||
return self._by_rp_id.get(rp_id)
|
||||
|
||||
def resolve(self, host: str | None) -> Domain | None:
|
||||
"""Resolve a request Host header to a domain.
|
||||
|
||||
Order: exact rp-id → auth host → exact related-origin hostname →
|
||||
longest-suffix rp-id. Unknown hosts return None. When several
|
||||
domains share an auth host, the best suffix match (longest rp-id
|
||||
the host falls under) wins, first configured as tiebreak — so
|
||||
``auth.company.com`` shared by ``company.com`` and ``app2.com``
|
||||
serves ``company.com`` for plain HTTP; WebSocket logins still
|
||||
follow the Origin header to the right domain.
|
||||
"""
|
||||
h = hostutil.normalize_host(host)
|
||||
if not h:
|
||||
return None
|
||||
if domain := self._by_rp_id.get(h):
|
||||
return domain
|
||||
if claimants := self._auth_hosts.get(h):
|
||||
best = None
|
||||
for candidate in claimants:
|
||||
if h.endswith(f".{candidate.rp_id}") and (
|
||||
best is None or len(candidate.rp_id) > len(best.rp_id)
|
||||
):
|
||||
best = candidate
|
||||
return best or claimants[0]
|
||||
if domain := self._related_hosts.get(h):
|
||||
return domain
|
||||
best = None
|
||||
for rp_id, domain in self._by_rp_id.items():
|
||||
if h.endswith(f".{rp_id}") and (
|
||||
best is None or len(rp_id) > len(best.rp_id)
|
||||
):
|
||||
best = domain
|
||||
return best
|
||||
|
||||
|
||||
def validate_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
) -> None:
|
||||
"""Validate a combined configuration cross-domain. Raises ValueError."""
|
||||
if not config.domains:
|
||||
raise ValueError("At least one domain (rp-id) is required")
|
||||
|
||||
auth_hosts: dict[str, str] = {} # normalized host -> owning rp_id
|
||||
related_hosts: dict[str, str] = {} # hostname -> owning rp_id
|
||||
|
||||
for rp_id, domain in config.domains.items():
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
|
||||
domain_auth_host: str | None = None
|
||||
related_count = 0
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if key == "*":
|
||||
raise ValueError(
|
||||
f"Origin '*' is not allowed — list '**.{rp_id}' explicitly"
|
||||
)
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = hostutil.wildcard_base(key)
|
||||
if not base or not hostutil.is_valid_hostname(base):
|
||||
raise ValueError(f"Invalid wildcard origin: '{key}'")
|
||||
if not hostutil.is_subdomain(base, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{key}' is a wildcard outside the rp-id "
|
||||
f"domain '{rp_id}' — related origins must be "
|
||||
"individual hosts"
|
||||
)
|
||||
if is_auth:
|
||||
raise ValueError(f"Wildcard origin '{key}' cannot be the auth host")
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn or not hostutil.is_valid_hostname(hn):
|
||||
raise ValueError(f"Invalid origin: '{key}'")
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
if is_auth:
|
||||
if domain_auth_host is not None:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' marks several origins as the auth "
|
||||
f"host ('{domain_auth_host}' and '{key}') — only one allowed"
|
||||
)
|
||||
domain_auth_host = key
|
||||
ah = hostutil.normalize_host(
|
||||
hostutil.auth_host_netloc(origin_url(key)) or ""
|
||||
)
|
||||
# Several domains may share an auth host to consolidate
|
||||
# logins; resolution picks the best suffix match.
|
||||
auth_hosts.setdefault(ah, rp_id)
|
||||
continue
|
||||
# Related origin (outside the rp-id domain)
|
||||
if is_auth:
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' cannot be the auth host — the "
|
||||
"auth host must be within the rp-id domain"
|
||||
)
|
||||
related_count += 1
|
||||
# A related host may be (or fall inside) another domain's
|
||||
# rp-id: a host that *is* a configured rp-id always serves its
|
||||
# own domain; otherwise the related listing wins dispatch over
|
||||
# suffix matching, so ROR logins from the listed host keep
|
||||
# working.
|
||||
covered_by_rp_id = any(
|
||||
hostutil.is_subdomain(hn, other) for other in config.domains
|
||||
)
|
||||
if hn in related_hosts and not covered_by_rp_id:
|
||||
raise ValueError(
|
||||
f"Related origin host '{hn}' is configured for both "
|
||||
f"'{related_hosts[hn]}' and '{rp_id}'"
|
||||
)
|
||||
related_hosts[hn] = rp_id
|
||||
if related_count > related_origin_cap:
|
||||
raise ValueError(
|
||||
f"Domain '{rp_id}' has {related_count} related origins "
|
||||
f"(maximum {related_origin_cap})"
|
||||
)
|
||||
|
||||
rp_ids = set(config.domains)
|
||||
for hn, owner in auth_hosts.items():
|
||||
if hn in rp_ids:
|
||||
raise ValueError(f"auth-host '{hn}' collides with an rp-id")
|
||||
if hn in related_hosts and related_hosts[hn] != owner:
|
||||
raise ValueError(
|
||||
f"auth-host '{hn}' collides with a related origin of "
|
||||
f"domain '{related_hosts[hn]}'"
|
||||
)
|
||||
|
||||
|
||||
def sanitize_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
) -> tuple[Config, list[str]]:
|
||||
"""Best-effort repair of a stored configuration for serving.
|
||||
|
||||
Serving must never fail because of stored domain config: fixing it is
|
||||
the admin's job via the admin UI, which is reachable only on a running
|
||||
server. Returns a sanitized copy (the stored config is left untouched)
|
||||
plus a warning for every degradation made. The result always passes
|
||||
``validate_config``.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
warnings.append(msg)
|
||||
|
||||
domains: dict[str, DomainConfig] = {}
|
||||
for rp_id, domain in config.domains.items():
|
||||
try:
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
except ValueError as e:
|
||||
warn(f"Domain dropped: {e}")
|
||||
continue
|
||||
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
auth_seen = False
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if not is_auth:
|
||||
props = True # canonicalize junk/empty entries to presence-only
|
||||
if key == "*":
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '*' rewritten as '**.{rp_id}'"
|
||||
+ (" — auth host mark cleared" if is_auth else "")
|
||||
)
|
||||
origins[f"**.{rp_id}"] = True
|
||||
continue
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = hostutil.wildcard_base(key)
|
||||
if not base or not hostutil.is_valid_hostname(base):
|
||||
warn(f"Domain '{rp_id}': invalid wildcard origin '{key}' dropped")
|
||||
continue
|
||||
if not hostutil.is_subdomain(base, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '{key}' is a wildcard "
|
||||
"outside the rp-id domain — dropped (related origins "
|
||||
"must be individual hosts)"
|
||||
)
|
||||
continue
|
||||
if is_auth:
|
||||
warn(
|
||||
f"Domain '{rp_id}': wildcard '{key}' cannot be the "
|
||||
"auth host — mark cleared"
|
||||
)
|
||||
props = True
|
||||
origins[key] = props
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn or not hostutil.is_valid_hostname(hn):
|
||||
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
|
||||
continue
|
||||
if is_auth:
|
||||
if not hostutil.is_subdomain(hn, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' cannot be "
|
||||
"the auth host — mark cleared"
|
||||
)
|
||||
props = True
|
||||
elif auth_seen:
|
||||
warn(
|
||||
f"Domain '{rp_id}': several origins marked as "
|
||||
f"auth host — extra mark on '{key}' cleared"
|
||||
)
|
||||
props = True
|
||||
else:
|
||||
auth_seen = True
|
||||
origins[key] = props
|
||||
|
||||
related = sorted(k for k in origins if is_related_key(rp_id, k))
|
||||
if len(related) > related_origin_cap:
|
||||
warn(
|
||||
f"Domain '{rp_id}': {len(related)} related origins exceed "
|
||||
f"the maximum of {related_origin_cap} — extras dropped"
|
||||
)
|
||||
for key in related[related_origin_cap:]:
|
||||
del origins[key]
|
||||
|
||||
domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins)
|
||||
|
||||
if not domains:
|
||||
raise ValueError("No servable domain in the stored configuration")
|
||||
|
||||
# Cross-domain conflicts: an auth host equal to an rp-id is dead config
|
||||
# (the rp-id always wins dispatch) — clear the mark. Sharing one auth
|
||||
# host between domains is allowed (login consolidation); resolution
|
||||
# picks the best suffix match. Related origins may point at or inside
|
||||
# other domains' rp-ids: a host that *is* a configured rp-id serves its
|
||||
# own domain; otherwise the related listing wins dispatch over suffix
|
||||
# matching.
|
||||
rp_ids = set(domains)
|
||||
seen_auth_hosts: dict[str, str] = {}
|
||||
for rp_id, domain in domains.items():
|
||||
for key, props in domain.origins.items():
|
||||
if not isinstance(props, OriginEntry) or not props.auth_host:
|
||||
continue
|
||||
hn = hostutil.normalize_host(
|
||||
hostutil.auth_host_netloc(origin_url(key)) or ""
|
||||
)
|
||||
if hn in rp_ids:
|
||||
warn(
|
||||
f"Domain '{rp_id}': auth host '{hn}' collides with "
|
||||
"an rp-id — mark cleared"
|
||||
)
|
||||
domain.origins[key] = True
|
||||
elif hn:
|
||||
seen_auth_hosts.setdefault(hn, rp_id)
|
||||
|
||||
seen_related: dict[str, str] = {}
|
||||
for rp_id, domain in domains.items():
|
||||
drop = []
|
||||
for key in domain.origins:
|
||||
if not is_related_key(rp_id, key):
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if any(hostutil.is_subdomain(hn, o) for o in rp_ids):
|
||||
continue # covered by a configured rp-id
|
||||
if hn in seen_auth_hosts:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is the "
|
||||
f"auth host of '{seen_auth_hosts[hn]}' — dropped"
|
||||
)
|
||||
drop.append(key)
|
||||
elif hn in seen_related:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is also "
|
||||
f"used by '{seen_related[hn]}' — dropped (first domain wins)"
|
||||
)
|
||||
drop.append(key)
|
||||
else:
|
||||
seen_related[hn] = rp_id
|
||||
for key in drop:
|
||||
del domain.origins[key]
|
||||
|
||||
return Config(domains=domains, listen=config.listen), warnings
|
||||
|
||||
|
||||
def _derive_site(
|
||||
rp_id: str, domain: DomainConfig, *, listen_port: int | None, vite_url: str | None
|
||||
) -> tuple[str, str]:
|
||||
"""Compute a domain's site_url and site_path.
|
||||
|
||||
Priority: auth host > exact rp-id origin key > first concrete in-domain
|
||||
origin key (sorted) > PASKIA_VITE_URL (localhost domain only) >
|
||||
http://localhost:port (localhost domain) > https://rp-id.
|
||||
"""
|
||||
if auth := auth_host_url(domain):
|
||||
return auth, "/"
|
||||
if rp_id in domain.origins:
|
||||
return origin_url(rp_id), "/auth/"
|
||||
concrete = sorted(
|
||||
k
|
||||
for k in domain.origins
|
||||
if not hostutil.is_wildcard_pattern(k) and not is_related_key(rp_id, k)
|
||||
)
|
||||
if concrete:
|
||||
return origin_url(concrete[0]), "/auth/"
|
||||
if rp_id == "localhost":
|
||||
if vite_url:
|
||||
return vite_url.rstrip("/"), "/auth/"
|
||||
if listen_port:
|
||||
return f"http://localhost:{listen_port}", "/auth/"
|
||||
return f"https://{rp_id}", "/auth/"
|
||||
|
||||
|
||||
_registry: DomainRegistry | None = None
|
||||
_listen: list[str] | None = None
|
||||
|
||||
|
||||
def configure(*, listen: list[str] | None = None) -> None:
|
||||
"""Record process-global serve parameters for site URL derivation."""
|
||||
global _listen
|
||||
_listen = listen
|
||||
|
||||
|
||||
def build(config: Config) -> DomainRegistry:
|
||||
"""Build a registry from a stored configuration.
|
||||
|
||||
The config is sanitized best-effort (serving must not fail on stored
|
||||
config problems — the admin UI fixes them on a running server);
|
||||
warnings are logged and exposed on the registry.
|
||||
"""
|
||||
config, warnings = sanitize_config(config)
|
||||
validate_config(config) # sanitize guarantees this; a raise means a bug
|
||||
endpoint = next(iter(parse_endpoints(_listen, DEFAULT_PORT)), {})
|
||||
vite_url = os.environ.get("PASKIA_VITE_URL")
|
||||
domains = [
|
||||
Domain(
|
||||
rp_id,
|
||||
dc,
|
||||
*_derive_site(
|
||||
rp_id, dc, listen_port=endpoint.get("port"), vite_url=vite_url
|
||||
),
|
||||
)
|
||||
for rp_id, dc in config.domains.items()
|
||||
]
|
||||
registry = DomainRegistry(domains)
|
||||
registry.warnings = warnings
|
||||
for warning in warnings:
|
||||
logger.warning("Config: %s", warning)
|
||||
return registry
|
||||
|
||||
|
||||
def init_registry(config: Config) -> DomainRegistry:
|
||||
"""Build and install the global registry from a combined configuration."""
|
||||
global _registry
|
||||
_registry = build(config)
|
||||
return _registry
|
||||
|
||||
|
||||
def registry() -> DomainRegistry:
|
||||
"""Return the global registry (must be initialized)."""
|
||||
if _registry is None:
|
||||
raise RuntimeError("Domain registry is not initialized")
|
||||
return _registry
|
||||
|
||||
|
||||
_current_domain: contextvars.ContextVar[Domain | None] = contextvars.ContextVar(
|
||||
"paskia_current_domain", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_current_domain(domain: Domain | None) -> contextvars.Token:
|
||||
return _current_domain.set(domain)
|
||||
|
||||
|
||||
def reset_current_domain(token: contextvars.Token) -> None:
|
||||
_current_domain.reset(token)
|
||||
|
||||
|
||||
def current_domain() -> Domain:
|
||||
"""Return the request's domain.
|
||||
|
||||
Without request context (background jobs, CLI), the single configured
|
||||
domain is returned; with several domains a request context is required.
|
||||
"""
|
||||
domain = _current_domain.get()
|
||||
if domain is not None:
|
||||
return domain
|
||||
reg = registry()
|
||||
if len(reg.domains) == 1:
|
||||
return reg.domains[0]
|
||||
raise RuntimeError("No current domain: request context required")
|
||||
@@ -5,11 +5,11 @@ from fastapi import FastAPI, Request
|
||||
from paskia import db
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin import (
|
||||
domains,
|
||||
oidc_clients,
|
||||
orgs,
|
||||
permissions,
|
||||
roles,
|
||||
server_config,
|
||||
users,
|
||||
)
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
@@ -38,7 +38,7 @@ app.mount("/orgs", orgs.app)
|
||||
app.mount("/roles", roles.app)
|
||||
app.mount("/users", users.app)
|
||||
app.mount("/permissions", permissions.app)
|
||||
app.mount("/server-config", server_config.app)
|
||||
app.mount("/domains", domains.app)
|
||||
|
||||
|
||||
def master_admin(ctx) -> bool:
|
||||
@@ -94,7 +94,7 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
|
||||
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
|
||||
|
||||
# OIDC Clients (master admin only)
|
||||
# OIDC Clients (master admin only) — the instance-global provider
|
||||
oidc_clients_dict = {}
|
||||
if master_admin(ctx):
|
||||
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Domain (rp-id) management API — master admin only.
|
||||
|
||||
Each domain is one rp-id with its own rp-name and an origins table of
|
||||
sign-in sites: entries within the rp-id domain are in-domain sites (one of
|
||||
which may be marked as the auth host), entries outside it are related
|
||||
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.
|
||||
"""
|
||||
|
||||
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.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.apistructs import ApiDomain
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
def _domain_to_api(domain: domains.Domain) -> ApiDomain:
|
||||
return ApiDomain(
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
origins=domain.config.origins,
|
||||
site_url=domain.site_url,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
auth_host=domain.own_auth_host,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]:
|
||||
"""Normalize an origins object from the admin UI (raises on malformed).
|
||||
|
||||
Keys arrive as bare hosts, wildcard patterns, or full origins; they are
|
||||
stored as origins-table keys (https:// omitted). In-domain vs. related
|
||||
classification is derived from the rp-id at validation time.
|
||||
"""
|
||||
out: dict[str, bool | OriginEntry] = {}
|
||||
for raw_key, raw_props in (values or {}).items():
|
||||
key = raw_key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if key != "*" and not hostutil.is_wildcard_pattern(key):
|
||||
key = domains.origin_key(hostutil.normalize_origin(key))
|
||||
is_auth = raw_props is not True and bool((raw_props or {}).get("auth_host"))
|
||||
out[key] = OriginEntry(auth_host=True) if is_auth else True
|
||||
return out
|
||||
|
||||
|
||||
def _rebuild_registry() -> None:
|
||||
"""Rebuild the runtime domain registry from the stored configuration."""
|
||||
domains.init_registry(db.data().config)
|
||||
|
||||
|
||||
def _check_not_locking_self_out(
|
||||
request: Request,
|
||||
rp_id: str,
|
||||
domain: DomainConfig,
|
||||
) -> None:
|
||||
"""Refuse domain changes that lock the admin out of their current host.
|
||||
|
||||
Applies when the admin edits the domain they are currently using and the
|
||||
new config has no auth host (with an auth host, ceremonies move there
|
||||
and it is always allowed). The admin's current host must remain able to
|
||||
run passkey ceremonies under the new config.
|
||||
"""
|
||||
current: domains.Domain = request.state.domain
|
||||
if rp_id != current.rp_id or domains.auth_host_url(domain):
|
||||
return
|
||||
raw_host = (request.headers.get("host") or "").rstrip(".")
|
||||
if not raw_host:
|
||||
return
|
||||
in_domain, related = domains.partition_origins(rp_id, domain.origins)
|
||||
probe = Passkey(
|
||||
rp_id=rp_id,
|
||||
origins=[domains.origin_url(k) for k in in_domain],
|
||||
related_origins=[domains.origin_url(k) for k in related],
|
||||
)
|
||||
for scheme in ("https", "http"):
|
||||
try:
|
||||
probe.validate_origin(f"{scheme}://{raw_host}")
|
||||
return # Current host still works — no lockout
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"This change would lock you out: '{raw_host}' could no longer "
|
||||
f"run passkey ceremonies for domain '{rp_id}'. Add it to the "
|
||||
"allowed origins (or mark an auth host) before saving."
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def admin_list_domains(request: Request, auth=AUTH_COOKIE):
|
||||
"""List all domains with derived URLs (master admin only)."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
registry = domains.registry()
|
||||
return MsgspecResponse([_domain_to_api(domain) for domain in registry.domains])
|
||||
|
||||
|
||||
@app.post("/")
|
||||
async def admin_create_domain(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Add a new domain (master admin only, recent authentication required)."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
|
||||
rp_id = (payload.get("rp_id") or "").strip().lower()
|
||||
if not rp_id:
|
||||
raise ValueError("rp_id is required")
|
||||
new = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
# Validate the would-be combined configuration before persisting
|
||||
domains.validate_config(
|
||||
Config(domains={**config.domains, rp_id: new}, listen=config.listen)
|
||||
)
|
||||
|
||||
db.create_domain(rp_id, new, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/{rp_id}")
|
||||
async def admin_update_domain(
|
||||
rp_id: str,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update a domain's rp_name, origins and related origins (replaced
|
||||
wholesale).
|
||||
|
||||
The rp-id itself is immutable: credentials are stamped with it.
|
||||
"""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
|
||||
config = db.data().config
|
||||
if rp_id not in config.domains:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
|
||||
updated = DomainConfig(
|
||||
rp_name=(payload.get("rp_name") or "").strip() or None,
|
||||
origins=_normalize_origins_map(payload.get("origins")),
|
||||
)
|
||||
would_be = Config(
|
||||
domains={k: updated if k == rp_id else v for k, v in config.domains.items()},
|
||||
listen=config.listen,
|
||||
)
|
||||
domains.validate_config(would_be)
|
||||
_check_not_locking_self_out(request, rp_id, updated)
|
||||
|
||||
db.update_domain(
|
||||
rp_id,
|
||||
rp_name=updated.rp_name,
|
||||
origins=updated.origins,
|
||||
ctx=ctx,
|
||||
)
|
||||
_rebuild_registry()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/{rp_id}")
|
||||
async def admin_delete_domain(
|
||||
rp_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Delete a domain (refused for the last domain or while credentials remain)."""
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
current: domains.Domain = request.state.domain
|
||||
if rp_id == current.rp_id:
|
||||
raise ValueError(
|
||||
"Cannot delete the domain you are currently using — authenticate "
|
||||
"on another domain first"
|
||||
)
|
||||
db.delete_domain(rp_id, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
return {"status": "ok"}
|
||||
@@ -137,13 +137,11 @@ async def admin_remove_org_permission(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
|
||||
|
||||
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
|
||||
# Guard rail: prevent removing auth:admin from your own org (lockout)
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
|
||||
# Check if any other org grants auth:admin that we're a member of
|
||||
# (we only know our current org, so this effectively means we can't remove it from our own org)
|
||||
if perm is None:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
if perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
|
||||
raise ValueError(
|
||||
"Cannot remove auth:admin from your own organization. "
|
||||
"This would lock you out of admin access."
|
||||
|
||||
@@ -4,10 +4,10 @@ from fastapi import Body, FastAPI, Query, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.db import Permission as PermDC
|
||||
from paskia.domains import registry
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import hostutil, permutil, querysafe
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -16,7 +16,12 @@ install_error_handlers(app)
|
||||
|
||||
|
||||
def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
|
||||
"""Validate that domain is a configured domain host or an OIDC client UUID.
|
||||
|
||||
Accepted: any domain's rp-id or its subdomain, a related-origin hostname
|
||||
of any domain, or the UUID of an OIDC client (used for the
|
||||
groups claim).
|
||||
"""
|
||||
if domain is None:
|
||||
return
|
||||
|
||||
@@ -28,11 +33,11 @@ def _validate_permission_domain(domain: str | None) -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
rp_id = passkey.rp_id
|
||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||
reg = registry()
|
||||
if reg.resolve(domain) is not None:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
|
||||
f"Domain '{domain}' must belong to a configured domain or be an OIDC client UUID"
|
||||
)
|
||||
|
||||
|
||||
@@ -161,11 +166,14 @@ async def admin_update_permission(
|
||||
|
||||
# Get existing permission
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if perm is None:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
|
||||
# Update fields that were provided
|
||||
# Update fields that were provided (omitted domain keeps the existing
|
||||
# restriction; an explicit empty domain clears it)
|
||||
new_scope = scope if scope is not None else perm.scope
|
||||
new_display_name = display_name if display_name is not None else perm.display_name
|
||||
domain_value = domain if domain else None
|
||||
domain_value = perm.domain if domain is None else domain or None
|
||||
|
||||
# Sanity check: prevent changing the auth:admin permission scope
|
||||
if perm.scope == "auth:admin" and new_scope != "auth:admin":
|
||||
@@ -206,6 +214,8 @@ async def admin_delete_permission(
|
||||
|
||||
# Get the permission to check its scope
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if perm is None:
|
||||
raise ValueError(f"Permission {permission_uuid} not found")
|
||||
|
||||
# Sanity check: prevent deleting critical permissions if it would lock out admin
|
||||
if perm.scope == "auth:admin":
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.structs import Config
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.runtime import update_runtime_config
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def admin_get_server_config(
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Get current server configuration (master admin only)."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
pk = passkey
|
||||
config = db.data().config
|
||||
return {
|
||||
"rp_name": pk.rp_name,
|
||||
"auth_host": config.auth_host or "",
|
||||
"origins": list(pk.allowed_origins) if pk.allowed_origins else [],
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/")
|
||||
async def admin_update_server_config(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update server configuration (master admin only).
|
||||
|
||||
Updates rp_name, auth_host, and origins in both the runtime Passkey
|
||||
instance and the persisted database config.
|
||||
"""
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
|
||||
)
|
||||
config = db.data().config
|
||||
pk = passkey
|
||||
|
||||
rp_name = payload.get("rp_name", "").strip() or None
|
||||
auth_host = payload.get("auth_host", "").strip() or None
|
||||
raw_origins = payload.get("origins", [])
|
||||
origins = [
|
||||
hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip()
|
||||
] or None
|
||||
|
||||
# Normalize auth_host and origins (matching CLI startup behavior)
|
||||
if auth_host:
|
||||
try:
|
||||
hostutil.validate_auth_host(auth_host, config.rp_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins)
|
||||
|
||||
# Validate origins against the current rp_id
|
||||
if origins:
|
||||
for o in origins:
|
||||
Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises
|
||||
|
||||
# Update runtime Passkey instance
|
||||
pk.rp_name = rp_name or config.rp_id
|
||||
pk.allowed_origins = set(origins) if origins else None
|
||||
|
||||
# Persist to database
|
||||
new_config = Config(
|
||||
rp_id=config.rp_id,
|
||||
rp_name=rp_name,
|
||||
auth_host=auth_host,
|
||||
origins=origins,
|
||||
listen=config.listen,
|
||||
)
|
||||
db.update_config(new_config)
|
||||
update_runtime_config(new_config)
|
||||
return {"status": "ok"}
|
||||
@@ -5,6 +5,7 @@ from fastapi import Body, FastAPI, HTTPException, Request
|
||||
from paskia import aaguid as aaguid_mod
|
||||
from paskia import db
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.domains import current_domain
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
@@ -122,7 +123,7 @@ async def admin_create_user_registration_link(
|
||||
token_type=token_type,
|
||||
ctx=ctx,
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
url = current_domain().reset_link_url(token)
|
||||
return MsgspecResponse(
|
||||
ApiCreateLinkResponse(
|
||||
url=url,
|
||||
|
||||
+10
-10
@@ -17,10 +17,10 @@ from fastapi.security import HTTPBearer
|
||||
from paskia import authcode, db
|
||||
from paskia._version import __version__
|
||||
from paskia.authsession import EXPIRES, get_reset, session_ctx
|
||||
from paskia.domains import current_domain
|
||||
from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
||||
from paskia.util.apistructs import (
|
||||
ApiCheckUserResponse,
|
||||
@@ -300,15 +300,15 @@ async def forward_authentication(
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
pk = global_passkey
|
||||
base_path = hostutil.ui_base_path()
|
||||
domain = current_domain()
|
||||
return MsgspecResponse(
|
||||
ApiSettings(
|
||||
rp_id=pk.rp_id,
|
||||
rp_name=pk.rp_name,
|
||||
ui_base_path=base_path,
|
||||
auth_host=hostutil.dedicated_auth_host(),
|
||||
auth_site_url=hostutil.auth_site_url(),
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
ui_base_path=domain.ui_base_path,
|
||||
auth_host=domain.own_auth_host,
|
||||
own_auth_host=domain.own_auth_host,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
session_cookie=AUTH_COOKIE_NAME,
|
||||
version=__version__,
|
||||
),
|
||||
@@ -399,7 +399,6 @@ async def api_set_session(
|
||||
if not auth or not auth.credentials:
|
||||
raise HTTPException(400, "Bearer token required")
|
||||
|
||||
# Verify host is provided
|
||||
host = hostutil.normalize_host(request.headers.get("host", ""))
|
||||
if not host:
|
||||
raise HTTPException(400, "Host header required")
|
||||
@@ -407,10 +406,11 @@ async def api_set_session(
|
||||
a = authcode.consume_cookie(auth.credentials)
|
||||
if not a:
|
||||
raise HTTPException(401, "Code expired or already used")
|
||||
if a.rp_id != current_domain().rp_id:
|
||||
raise HTTPException(401, "Code was issued for a different domain")
|
||||
|
||||
secret = a.session_key
|
||||
|
||||
# Verify the session exists
|
||||
ctx = session_ctx(secret, host)
|
||||
if not ctx:
|
||||
raise HTTPException(401, f"Session not found on {host}")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
|
||||
@@ -65,15 +66,19 @@ def should_redirect_auth_path_to_root(path: str) -> bool:
|
||||
return bool(token and "/" not in token and passphrase.is_well_formed(token))
|
||||
|
||||
|
||||
def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response:
|
||||
def redirect_to_root_on_auth_host(request: Request, host: str, path: str) -> Response:
|
||||
"""Create a redirect response to root path on the same host."""
|
||||
new_path = path[5:] or "/"
|
||||
return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307)
|
||||
return RedirectResponse(f"{request.url.scheme}://{host}{new_path}", 307)
|
||||
|
||||
|
||||
async def redirect_middleware(request: Request, call_next):
|
||||
"""Middleware to handle auth host redirects."""
|
||||
cfg = hostutil.dedicated_auth_host()
|
||||
"""Middleware to handle auth host redirects.
|
||||
|
||||
Only the current domain's *own* auth host triggers redirects; a domain
|
||||
without one serves its UI under /auth/ on its own hosts.
|
||||
"""
|
||||
cfg = current_domain().own_auth_host
|
||||
if not cfg:
|
||||
return await call_next(request)
|
||||
|
||||
@@ -91,7 +96,7 @@ async def redirect_middleware(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
return redirect_to_auth_host(request, cfg, path)
|
||||
else:
|
||||
# On auth host: force UI endpoints at root
|
||||
# On auth host: force UI endpoints at root (cfg keeps any port)
|
||||
if should_redirect_auth_path_to_root(path):
|
||||
return redirect_to_root_on_auth_host(request, cur, path)
|
||||
return redirect_to_root_on_auth_host(request, cfg, path)
|
||||
return await call_next(request)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""ASGI dispatch middleware: resolve the request Host to a domain.
|
||||
|
||||
Every HTTP request and WebSocket connection is dispatched to exactly one
|
||||
domain, resolved from the Host header via the domain registry. The resolved
|
||||
domain is exposed as ``request.state.domain`` and through the
|
||||
:func:`paskia.domains.current_domain` contextvar, which endpoint code uses
|
||||
for all domain-dependent behavior (passkey configuration, site URLs).
|
||||
|
||||
Unknown hosts are rejected before routing:
|
||||
|
||||
- HTTP: ``421 Misdirected Request``
|
||||
- WebSocket: closed pre-accept with code 1008
|
||||
|
||||
For WebSocket connections the Origin header selects the domain when it
|
||||
belongs to a different domain than the Host — a related-origin page using
|
||||
the domain's auth host. A cross-domain connection is only allowed when
|
||||
the Host is the origin domain's own auth host; otherwise the connection
|
||||
is closed pre-accept. When the Origin is missing or unknown the Host
|
||||
domain applies and endpoint-side origin validation decides.
|
||||
"""
|
||||
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from paskia import domains
|
||||
from paskia.util import hostutil
|
||||
|
||||
_WS_CLOSE_POLICY_VIOLATION = 1008
|
||||
|
||||
|
||||
def _header(scope: dict, name: str) -> str | None:
|
||||
"""Return the first value of a lowercased ASGI header name."""
|
||||
key = name.encode()
|
||||
for header, value in scope.get("headers", []):
|
||||
if header == key:
|
||||
return value.decode()
|
||||
return None
|
||||
|
||||
|
||||
class DispatchMiddleware:
|
||||
"""Pure ASGI middleware dispatching each connection to its domain."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
await self._http(scope, receive, send)
|
||||
elif scope["type"] == "websocket":
|
||||
await self._websocket(scope, receive, send)
|
||||
else:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
async def _http(self, scope, receive, send):
|
||||
domain = domains.registry().resolve(_header(scope, "host"))
|
||||
if domain is None:
|
||||
response = PlainTextResponse("Unknown host", status_code=421)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self._dispatch(scope, receive, send, domain)
|
||||
|
||||
async def _websocket(self, scope, receive, send):
|
||||
registry = domains.registry()
|
||||
host = _header(scope, "host")
|
||||
host_domain = registry.resolve(host)
|
||||
if host_domain is None:
|
||||
await send({"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION})
|
||||
return
|
||||
|
||||
domain = host_domain
|
||||
origin = _header(scope, "origin")
|
||||
origin_host = hostutil.origin_hostname(origin) if origin else None
|
||||
origin_domain = registry.resolve(origin_host) if origin_host else None
|
||||
if origin_domain is not None and origin_domain is not host_domain:
|
||||
# Cross-domain connection: only via the origin domain's own auth host.
|
||||
own = origin_domain.own_auth_host
|
||||
if not own or hostutil.normalize_host(host) != hostutil.normalize_host(own):
|
||||
await send(
|
||||
{"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION}
|
||||
)
|
||||
return
|
||||
domain = origin_domain
|
||||
await self._dispatch(scope, receive, send, domain)
|
||||
|
||||
async def _dispatch(self, scope, receive, send, domain: domains.Domain):
|
||||
scope.setdefault("state", {})["domain"] = domain
|
||||
token = domains.set_current_domain(domain)
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
domains.reset_current_domain(token)
|
||||
+34
-16
@@ -1,27 +1,26 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from kanta.logging import configure_logging as configure_kanta_logging
|
||||
|
||||
from paskia import authcode, db, remoteauth
|
||||
from paskia import authcode, db, domains, remoteauth
|
||||
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.admin.adminapp import adminapp
|
||||
from paskia.fastapi.dispatch import DispatchMiddleware
|
||||
|
||||
# Import frontend instance
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase, vitedev
|
||||
from paskia.util import passphrase, vitedev
|
||||
from paskia.util.constants import DEVMODE
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
from paskia.util.runtime import serve_config
|
||||
|
||||
# Configure custom logging
|
||||
configure_kanta_logging()
|
||||
@@ -32,19 +31,22 @@ _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
"""Application lifespan to ensure globals (DB, passkey) are initialized in each process.
|
||||
"""Application lifespan: open the combined database and build the domain registry.
|
||||
|
||||
Configuration is passed via PASKIA_CONFIG JSON env variable (set by the CLI entrypoint)
|
||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||
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.
|
||||
"""
|
||||
runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
|
||||
cfg = serve_config()
|
||||
domains.configure(listen=cfg.listen if cfg else None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||
)
|
||||
async with kanta:
|
||||
try:
|
||||
domains.init_registry(db.data().config)
|
||||
await remoteauth.init()
|
||||
await authcode.start()
|
||||
except ValueError as e:
|
||||
@@ -52,11 +54,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
# Bootstrap and persist config now that the full DB is loaded
|
||||
await bootstrap_if_needed(config=runtime.config)
|
||||
if runtime.save:
|
||||
db.update_config(runtime.config)
|
||||
|
||||
await bootstrap_if_needed()
|
||||
await frontend.load()
|
||||
await start_background()
|
||||
yield
|
||||
@@ -79,6 +77,10 @@ app = FastAPI(
|
||||
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
||||
app.middleware("http")(auth_host.redirect_middleware)
|
||||
|
||||
# Domain dispatch must be the outermost application middleware: everything
|
||||
# below it (including the auth-host redirects) uses the current domain.
|
||||
app.add_middleware(DispatchMiddleware)
|
||||
|
||||
app.mount("/auth/api/admin/", admin.app)
|
||||
app.mount("/auth/api/", api.app)
|
||||
app.mount("/auth/ws/", ws.app)
|
||||
@@ -124,6 +126,20 @@ async def openid_configuration(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/.well-known/webauthn")
|
||||
async def webauthn_related_origins(request: Request):
|
||||
"""WebAuthn Related Origin Requests discovery document.
|
||||
|
||||
Served on the domain's rp-id site; lists the domain's related origins
|
||||
(other domains) that may assert this rp-id. 404 when the domain has no
|
||||
related origins.
|
||||
"""
|
||||
related = request.state.domain.related_origins
|
||||
if not related:
|
||||
raise HTTPException(status_code=404)
|
||||
return {"origins": related}
|
||||
|
||||
|
||||
@app.get("/auth/restricted/iframe")
|
||||
@app.get("/auth/restricted/oidc")
|
||||
async def restricted_view(request: Request):
|
||||
@@ -149,7 +165,9 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
@app.get("/admin", include_in_schema=False)
|
||||
@app.get("/auth/admin", include_in_schema=False)
|
||||
async def admin_root_redirect():
|
||||
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
|
||||
return RedirectResponse(
|
||||
f"{domains.current_domain().ui_base_path}admin/", status_code=307
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin/", include_in_schema=False)
|
||||
|
||||
+13
-10
@@ -21,7 +21,7 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia.db.structs import Session
|
||||
from paskia.db.structs import OIDC, Session
|
||||
from paskia.util import avatar, oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
@@ -30,6 +30,11 @@ _logger = logging.getLogger(__name__)
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def _provider() -> OIDC:
|
||||
"""Return the instance-global OIDC provider state."""
|
||||
return db.data().oidc
|
||||
|
||||
|
||||
@app.get("/keys")
|
||||
async def keys():
|
||||
"""JSON Web Key Set for token verification."""
|
||||
@@ -148,7 +153,7 @@ async def token(
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||
|
||||
client = db.data().oidc.clients.get(client_uuid)
|
||||
client = _provider().clients.get(client_uuid)
|
||||
if not client or not client.verify_secret(client_secret):
|
||||
return JSONResponse({"error": "invalid_client"}, status_code=401)
|
||||
|
||||
@@ -260,9 +265,6 @@ async def _handle_refresh_token(
|
||||
- Validates session exists and belongs to client
|
||||
- Extends session expiry (24h sliding window)
|
||||
- Issues new access_token and id_token
|
||||
|
||||
Note: ip and user_agent are NOT updated because the refresh request
|
||||
comes from the OIDC client's backend, not the end user's browser.
|
||||
"""
|
||||
if not refresh_token_value:
|
||||
return JSONResponse(
|
||||
@@ -297,6 +299,7 @@ async def _handle_refresh_token(
|
||||
db.update_session(
|
||||
session.key,
|
||||
validated=now,
|
||||
issuer=_get_issuer(request),
|
||||
)
|
||||
|
||||
_logger.info("OIDC session refreshed: %s", session.key)
|
||||
@@ -361,7 +364,7 @@ def _build_token_response(
|
||||
name=user.display_name,
|
||||
preferred_username=user.preferred_username,
|
||||
email=user.email,
|
||||
picture=avatar.current_avatar_url(user.uuid),
|
||||
picture=avatar.avatar_url(user.uuid),
|
||||
groups=groups or None,
|
||||
auth_time=auth_time,
|
||||
)
|
||||
@@ -416,7 +419,7 @@ async def userinfo(
|
||||
except ValueError:
|
||||
raise HTTPException(401, "Invalid token (invalid aud format)")
|
||||
|
||||
if not db.data().oidc.clients.get(client_uuid):
|
||||
if not _provider().clients.get(client_uuid):
|
||||
raise HTTPException(401, "Invalid token (unknown client)")
|
||||
|
||||
# Get user
|
||||
@@ -449,7 +452,7 @@ async def userinfo(
|
||||
response["name"] = user.display_name
|
||||
if user.preferred_username:
|
||||
response["preferred_username"] = user.preferred_username
|
||||
picture = avatar.current_avatar_url(user.uuid)
|
||||
picture = avatar.avatar_url(user.uuid)
|
||||
if picture:
|
||||
response["picture"] = picture
|
||||
|
||||
@@ -504,7 +507,7 @@ async def backchannel_logout(
|
||||
if aud:
|
||||
try:
|
||||
client_uuid = UUID(aud)
|
||||
if not db.data().oidc.clients.get(client_uuid):
|
||||
if not _provider().clients.get(client_uuid):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
@@ -556,7 +559,7 @@ async def backchannel_logout(
|
||||
{"error": "invalid_request", "error_description": "Invalid sub claim"},
|
||||
status_code=400,
|
||||
)
|
||||
# Find and delete matching sessions
|
||||
# Find and delete matching OIDC sessions for this user/client
|
||||
sessions_to_delete = [
|
||||
s
|
||||
for s in db.data().sessions.values()
|
||||
|
||||
@@ -6,7 +6,7 @@ wants to log in and another device (authenticating) provides the passkey.
|
||||
|
||||
Endpoints:
|
||||
- /request: Called by the device wanting to be authenticated
|
||||
- /pair: Called by the authenticating device to complete the request
|
||||
- /permit: Called by the authenticating device to complete the request
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -19,6 +19,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from paskia import authcode, db, remoteauth
|
||||
from paskia.authcode import CookieCode
|
||||
from paskia.authsession import expires
|
||||
from paskia.domains import current_domain, registry
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_and_login
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
@@ -94,6 +95,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
host=host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
rp_id=current_domain().rp_id,
|
||||
action=action,
|
||||
)
|
||||
|
||||
@@ -333,10 +335,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
)
|
||||
|
||||
# Create exchange code for the session (don't expose raw secret)
|
||||
# Stamped with the *requesting* device's domain: it redeems the
|
||||
# code on its own host, which dispatches to that domain.
|
||||
exchange_code = authcode.store_cookie(
|
||||
CookieCode(
|
||||
session_key=secret,
|
||||
created=datetime.now(UTC),
|
||||
rp_id=request.rp_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -440,11 +445,19 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
|
||||
request.action = locked_action # Update local copy with locked value
|
||||
|
||||
# Send device info to the authenticating device
|
||||
# Send device info to the authenticating device, including the
|
||||
# requesting device's domain (may differ from the approver's)
|
||||
requesting_domain = registry().get(request.rp_id)
|
||||
await ws.send_json(
|
||||
{
|
||||
"status": "found",
|
||||
"host": request.host,
|
||||
"rp_id": request.rp_id,
|
||||
"rp_name": (
|
||||
requesting_domain.rp_name
|
||||
if requesting_domain
|
||||
else request.rp_id
|
||||
),
|
||||
"user_agent_pretty": useragent.compact_user_agent(
|
||||
request.user_agent
|
||||
),
|
||||
|
||||
@@ -4,8 +4,6 @@ FastAPI-specific session management for WebAuthn authentication.
|
||||
This module provides FastAPI-specific session management functionality:
|
||||
- Extracting client information from FastAPI requests
|
||||
- Setting and clearing HTTP-only cookies via FastAPI Response objects
|
||||
|
||||
Generic session management functions have been moved to authsession.py
|
||||
"""
|
||||
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
|
||||
@@ -17,10 +17,11 @@ from paskia.authsession import (
|
||||
expires,
|
||||
session_ctx,
|
||||
)
|
||||
from paskia.domains import current_domain
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import avatar, hostutil
|
||||
from paskia.util import avatar
|
||||
from paskia.util.apistructs import ApiCreateLinkResponse
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -291,7 +292,7 @@ async def api_create_link(
|
||||
token_type="device addition",
|
||||
ctx=ctx,
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
url = current_domain().reset_link_url(token)
|
||||
return MsgspecResponse(
|
||||
ApiCreateLinkResponse(
|
||||
message="Registration link generated successfully",
|
||||
|
||||
@@ -9,6 +9,7 @@ from paskia import authcode, db
|
||||
from paskia.authcode import CookieCode, OIDCCode
|
||||
from paskia.authsession import get_reset, session_ctx
|
||||
from paskia.db.structs import Session
|
||||
from paskia.domains import current_domain
|
||||
from paskia.fastapi import authz, remote
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import (
|
||||
@@ -17,7 +18,6 @@ from paskia.fastapi.wschat import (
|
||||
register_chat,
|
||||
)
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
@@ -28,6 +28,7 @@ def create_exchange_code(session_key: str) -> str:
|
||||
cookie_code = CookieCode(
|
||||
session_key=session_key,
|
||||
created=now,
|
||||
rp_id=current_domain().rp_id,
|
||||
)
|
||||
return authcode.store_cookie(cookie_code)
|
||||
|
||||
@@ -55,10 +56,11 @@ async def websocket_register_add(
|
||||
"""
|
||||
origin = validate_origin(ws)
|
||||
host = hostutil.normalize_host(origin.split("://", 1)[1])
|
||||
domain = current_domain()
|
||||
if reset is not None:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError(
|
||||
f"The reset link for {passkey.rp_name} is invalid or has expired"
|
||||
f"The reset link for {domain.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = get_reset(reset)
|
||||
user_uuid = s.user_uuid
|
||||
@@ -75,7 +77,7 @@ async def websocket_register_add(
|
||||
stripped = name.strip()
|
||||
if stripped:
|
||||
user_name = stripped
|
||||
credential_ids = user.credential_ids or None
|
||||
credential_ids = user.credential_ids_for(domain.rp_id) or None
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
|
||||
@@ -123,6 +125,7 @@ async def websocket_authenticate(
|
||||
):
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
domain = current_domain()
|
||||
|
||||
# OIDC mode: validate client before auth
|
||||
oidc_client = None
|
||||
@@ -204,8 +207,6 @@ async def websocket_authenticate(
|
||||
cred, new_sign_count = await authenticate_chat(ws)
|
||||
|
||||
# Get metadata for session
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
metadata = infodict(ws, "oidc_auth")
|
||||
|
||||
@@ -223,6 +224,8 @@ async def websocket_authenticate(
|
||||
user_agent=metadata["user_agent"],
|
||||
validated=now,
|
||||
client=oidc_client.uuid,
|
||||
rp_id=domain.rp_id,
|
||||
issuer=origin,
|
||||
)
|
||||
db.oidc_login(
|
||||
session=session,
|
||||
|
||||
@@ -9,9 +9,9 @@ from fastapi import WebSocket
|
||||
from paskia import db
|
||||
from paskia.authsession import session_ctx
|
||||
from paskia.db import Credential, SessionContext
|
||||
from paskia.domains import current_domain, registry
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wsutil import validate_origin
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import hostutil
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ async def register_chat(
|
||||
credential_ids: list[bytes] | None = None,
|
||||
):
|
||||
"""Run WebAuthn registration flow and return the verified credential."""
|
||||
passkey = current_domain().passkey
|
||||
options, challenge = passkey.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
@@ -42,6 +43,8 @@ async def authenticate_chat(
|
||||
Returns:
|
||||
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
||||
"""
|
||||
domain = current_domain()
|
||||
passkey = domain.passkey
|
||||
origin = validate_origin(ws)
|
||||
options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
@@ -51,7 +54,7 @@ async def authenticate_chat(
|
||||
(
|
||||
c
|
||||
for c in db.data().credentials.values()
|
||||
if c.credential_id == authcred.raw_id
|
||||
if c.credential_id == authcred.raw_id and c.rp_id == domain.rp_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -77,22 +80,20 @@ async def authenticate_and_login(
|
||||
Args:
|
||||
ws: The WebSocket connection (used for WebAuthn and origin validation)
|
||||
auth: Existing session cookie for re-auth credential restriction
|
||||
session_host: Override host for the new session (defaults to ws origin)
|
||||
session_host: Override host for the new session (defaults to ws origin);
|
||||
must belong to a configured domain
|
||||
session_ip: Override IP for the new session (defaults to ws client IP)
|
||||
session_user_agent: Override user-agent for the new session (defaults to ws headers)
|
||||
|
||||
Returns:
|
||||
Tuple of (SessionContext for the authenticated session, session secret)
|
||||
"""
|
||||
domain = current_domain()
|
||||
origin = validate_origin(ws)
|
||||
host = origin.split("://", 1)[1]
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
hostname = normalized_host.split(":")[0]
|
||||
rp_id = passkey.rp_id
|
||||
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
metadata = infodict(ws, "auth")
|
||||
|
||||
# Get credential IDs if restricting to a user's credentials
|
||||
@@ -100,7 +101,7 @@ async def authenticate_and_login(
|
||||
if auth:
|
||||
existing_ctx = session_ctx(auth, host)
|
||||
if existing_ctx:
|
||||
credential_ids = existing_ctx.user.credential_ids or None
|
||||
credential_ids = existing_ctx.user.credential_ids_for(domain.rp_id) or None
|
||||
|
||||
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
|
||||
|
||||
@@ -112,12 +113,17 @@ async def authenticate_and_login(
|
||||
)
|
||||
if not login_host:
|
||||
raise ValueError("Host required for session creation")
|
||||
if session_host is not None and registry().resolve(login_host) is None:
|
||||
raise ValueError(f"Host '{login_host}' does not belong to a configured domain")
|
||||
login_ip = session_ip if session_ip is not None else metadata["ip"]
|
||||
login_user_agent = (
|
||||
session_user_agent if session_user_agent is not None else metadata["user_agent"]
|
||||
)
|
||||
|
||||
# Create session and update user/credential
|
||||
# Create session and update user/credential; stamp it with the domain of
|
||||
# the session's host (in remote flows the connection domain is the
|
||||
# approver's, but the session belongs to the requesting device's domain)
|
||||
login_domain = registry().resolve(login_host) or domain
|
||||
secret = db.login(
|
||||
user_uuid=cred.user_uuid,
|
||||
credential_uuid=cred.uuid,
|
||||
@@ -125,6 +131,7 @@ async def authenticate_and_login(
|
||||
host=login_host,
|
||||
ip=login_ip,
|
||||
user_agent=login_user_agent,
|
||||
rp_id=login_domain.rp_id,
|
||||
)
|
||||
|
||||
# Fetch and return the full session context (using the same host the session was created with)
|
||||
|
||||
@@ -5,13 +5,11 @@ Shared WebSocket utilities for FastAPI endpoints.
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
import base64url
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||
|
||||
from paskia.domains import current_domain
|
||||
from paskia.fastapi import authz
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import pow
|
||||
|
||||
|
||||
def websocket_error_handler(func):
|
||||
@@ -40,52 +38,13 @@ def websocket_error_handler(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
async def require_pow(ws: WebSocket, work: int | None = None) -> None:
|
||||
"""Send a PoW challenge and verify the client's solution.
|
||||
|
||||
Sends: {"pow": {"challenge": "<base64>", "work": 10}}
|
||||
Expects: {"pow": "<base64-solution>"}
|
||||
|
||||
Args:
|
||||
ws: WebSocket connection
|
||||
work: PoW difficulty level (default: pow.DEFAULT_WORK)
|
||||
|
||||
Raises:
|
||||
ValueError: If the PoW solution is invalid
|
||||
"""
|
||||
challenge = pow.generate_challenge()
|
||||
if work is None:
|
||||
work = pow.DEFAULT_WORK
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"pow": {
|
||||
"challenge": base64url.enc(challenge),
|
||||
"work": work,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
response = await ws.receive_json()
|
||||
solution_b64 = response.get("pow")
|
||||
if not solution_b64:
|
||||
raise ValueError("PoW solution required")
|
||||
|
||||
try:
|
||||
solution = base64url.dec(solution_b64)
|
||||
except Exception:
|
||||
raise ValueError("Invalid PoW solution encoding")
|
||||
|
||||
pow.verify_pow(challenge, solution, work)
|
||||
|
||||
|
||||
def validate_origin(ws: WebSocket) -> str:
|
||||
"""Extract and validate origin from WebSocket request headers.
|
||||
|
||||
Raises:
|
||||
ValueError: If origin header is missing or not in allowed list
|
||||
ValueError: If origin header is missing or not allowed in the current domain
|
||||
"""
|
||||
origin = ws.headers.get("origin")
|
||||
if not origin:
|
||||
raise ValueError("Origin header is required for WebSocket connections")
|
||||
return passkey.validate_origin(origin)
|
||||
return current_domain().passkey.validate_origin(origin)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Global Passkey instance configured from PASKIA_CONFIG.
|
||||
|
||||
The Passkey instance is created at import time using the runtime configuration
|
||||
passed via the ``PASKIA_CONFIG`` environment variable. Other runtime setup
|
||||
(remote auth, auth codes, bootstrap checks) is performed explicitly by the
|
||||
FastAPI lifespan once the database is open.
|
||||
"""
|
||||
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import runtime
|
||||
|
||||
runtime = runtime.config()
|
||||
if runtime is None:
|
||||
raise RuntimeError("PASKIA_CONFIG must be defined before importing paskia.globals")
|
||||
|
||||
passkey = Passkey(
|
||||
rp_id=runtime.config.rp_id,
|
||||
rp_name=runtime.config.rp_name,
|
||||
origins=runtime.config.origins,
|
||||
)
|
||||
+18
-16
@@ -3,6 +3,10 @@ OIDC Back-Channel Logout notifications.
|
||||
|
||||
When sessions are deleted (logout, admin, expiry), this module notifies
|
||||
any OIDC clients that have a backchannel_logout_uri configured.
|
||||
|
||||
Notifications run without request context, so the issuer comes from the
|
||||
session itself (``Session.issuer``, stamped at session creation/refresh);
|
||||
the signing key is instance-global.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -13,7 +17,6 @@ import httpx
|
||||
|
||||
from paskia import db
|
||||
from paskia.util import oidjwt
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,16 +24,10 @@ _logger = logging.getLogger(__name__)
|
||||
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
|
||||
def _issuer() -> str:
|
||||
"""Derive issuer URL from config (same base as discovery document)."""
|
||||
cfg = runtime_config()
|
||||
return cfg.site_url if cfg else "https://localhost"
|
||||
|
||||
|
||||
def _collect_oidc_sessions(
|
||||
session_keys: list[str],
|
||||
) -> list[tuple[str, str, UUID, UUID | None]]:
|
||||
"""Collect (backchannel_logout_uri, sid, client_uuid, user_uuid) for OIDC sessions.
|
||||
) -> list[tuple[str, str, str, UUID, UUID | None]]:
|
||||
"""Collect (logout_uri, issuer, sid, client_uuid, user_uuid).
|
||||
|
||||
Must be called before the sessions are deleted from the database.
|
||||
Returns only sessions whose client has a backchannel_logout_uri configured.
|
||||
@@ -44,9 +41,15 @@ def _collect_oidc_sessions(
|
||||
client = data.oidc.clients.get(session.client_uuid)
|
||||
if not client or not client.backchannel_logout_uri:
|
||||
continue
|
||||
sid = session.key
|
||||
issuer = session.issuer or f"https://{session.host}"
|
||||
notifications.append(
|
||||
(client.backchannel_logout_uri, sid, session.client_uuid, session.user_uuid)
|
||||
(
|
||||
client.backchannel_logout_uri,
|
||||
issuer,
|
||||
session.key,
|
||||
session.client_uuid,
|
||||
session.user_uuid,
|
||||
)
|
||||
)
|
||||
return notifications
|
||||
|
||||
@@ -77,21 +80,20 @@ async def _send_logout_token(
|
||||
|
||||
|
||||
async def notify(
|
||||
notifications: list[tuple[str, str, UUID, UUID | None]],
|
||||
notifications: list[tuple[str, str, str, UUID, UUID | None]],
|
||||
) -> None:
|
||||
"""Send back-channel logout tokens to all collected endpoints.
|
||||
|
||||
Args:
|
||||
notifications: list of (backchannel_logout_uri, sid, client_uuid, user_uuid)
|
||||
as returned by _collect_oidc_sessions.
|
||||
notifications: list of (backchannel_logout_uri, issuer, sid,
|
||||
client_uuid, user_uuid) as returned by _collect_oidc_sessions.
|
||||
"""
|
||||
if not notifications:
|
||||
return
|
||||
|
||||
issuer = _issuer()
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
tasks = []
|
||||
for uri, sid, client_uuid, user_uuid in notifications:
|
||||
for uri, issuer, sid, client_uuid, user_uuid in notifications:
|
||||
token = oidjwt.create_logout_token(
|
||||
issuer=issuer,
|
||||
audience=str(client_uuid),
|
||||
|
||||
@@ -39,6 +39,7 @@ class RemoteAuthRequest:
|
||||
host: str # The host where the session should be created
|
||||
ip: str # IP of the requesting device
|
||||
user_agent: str # User agent of the requesting device
|
||||
rp_id: str # Domain of the requesting device (session/exchange codes are stamped with it)
|
||||
action: str = "login" # "login" or "register"
|
||||
locked: bool = False # True once the authenticating device has entered the code
|
||||
# Callback to notify the requesting device when auth completes
|
||||
@@ -113,6 +114,7 @@ class RemoteAuthManager:
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
rp_id: str,
|
||||
action: str = "login",
|
||||
) -> tuple[str, datetime]:
|
||||
"""Create a new remote auth request.
|
||||
@@ -143,6 +145,7 @@ class RemoteAuthManager:
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
rp_id=rp_id,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
+104
-40
@@ -8,8 +8,6 @@ This module provides a unified interface for WebAuthn operations including:
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from webauthn import (
|
||||
@@ -36,7 +34,8 @@ from webauthn.helpers.structs import (
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from paskia.db import Credential
|
||||
from paskia.db.structs import Credential
|
||||
from paskia.util import hostutil
|
||||
|
||||
|
||||
class Passkey:
|
||||
@@ -47,6 +46,7 @@ class Passkey:
|
||||
rp_id: str,
|
||||
rp_name: str | None = None,
|
||||
origins: list[str] | None = None,
|
||||
related_origins: list[str] | None = None,
|
||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||
):
|
||||
"""
|
||||
@@ -55,59 +55,120 @@ class Passkey:
|
||||
Args:
|
||||
rp_id: Your security domain (e.g. "example.com")
|
||||
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
||||
origins: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
|
||||
Each must be a subdomain or same as rp_id. If not provided, any subdomain of rp_id is allowed.
|
||||
origins: Allow-list of sign-in site origins within the rp-id domain
|
||||
(e.g. ["https://app.example.com"]); wildcard patterns
|
||||
follow the shell-glob convention: "**.example.com" matches
|
||||
the base domain and its subdomains at any depth, while
|
||||
"*.example.com" matches exactly one subdomain level —
|
||||
over https only, except under localhost
|
||||
("**.localhost"), which matches any scheme and any port.
|
||||
Exact entries match scheme, host and port. An empty list
|
||||
(the default) allows nothing — pass ["**.{rp-id}"] to
|
||||
allow the whole domain.
|
||||
related_origins: Origins on unrelated domains that may assert this
|
||||
rp-id (WebAuthn Related Origin Requests). Always additive.
|
||||
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||
|
||||
Raises:
|
||||
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
|
||||
ValueError: If rp_id is not a valid domain, an origin is malformed,
|
||||
an allow-list origin is outside the rp-id domain, or a
|
||||
related origin is inside it.
|
||||
"""
|
||||
self.rp_id = rp_id
|
||||
self._validate_rp_id(rp_id)
|
||||
hostutil.validate_rp_id(rp_id)
|
||||
self.rp_name = rp_name or rp_id
|
||||
self.allowed_origins: set[str] | None = None
|
||||
if origins:
|
||||
# Validate and deduplicate origins into a set for O(1) lookups
|
||||
for o in origins:
|
||||
self._validate_origin(o, rp_id)
|
||||
self.allowed_origins = set(origins)
|
||||
self.allowed_origins: set[str] = set()
|
||||
for o in origins or []:
|
||||
if hostutil.is_wildcard_pattern(o):
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
if hostname and not hostutil.is_valid_hostname(hostname):
|
||||
raise ValueError(f"Origin '{o}' has a malformed hostname")
|
||||
else:
|
||||
self._validate_origin_url(o)
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
if not hostname or not hostutil.is_subdomain(hostname, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{o}' is outside the rp-id domain '{rp_id}' — "
|
||||
"pass it as a related origin instead"
|
||||
)
|
||||
self.allowed_origins.add(o)
|
||||
self.related_origins: set[str] = set()
|
||||
for o in related_origins or []:
|
||||
if hostutil.is_wildcard_pattern(o):
|
||||
raise ValueError(
|
||||
f"Related origin '{o}' is a wildcard — related origins "
|
||||
"(ROR) must be listed individually"
|
||||
)
|
||||
self._validate_origin_url(o)
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
if hostutil.is_subdomain(hostname, rp_id):
|
||||
raise ValueError(
|
||||
f"Related origin '{o}' is within the rp-id domain '{rp_id}' — "
|
||||
"subdomains need no related origin entry"
|
||||
)
|
||||
self.related_origins.add(o)
|
||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||
COSEAlgorithmIdentifier.EDDSA,
|
||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
||||
]
|
||||
|
||||
def _validate_rp_id(self, rp_id: str) -> None:
|
||||
"""Validate that rp_id is a valid domain name."""
|
||||
if not rp_id:
|
||||
raise ValueError("rp_id cannot be empty")
|
||||
# Allow localhost, or domain-like strings
|
||||
if rp_id == "localhost":
|
||||
return
|
||||
# Regex for valid domain: letters, digits, hyphens, dots, but not starting/ending with hyphen, etc.
|
||||
# Simplified: alphanumeric, dots, hyphens
|
||||
if not re.match(
|
||||
r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
|
||||
rp_id,
|
||||
):
|
||||
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
|
||||
|
||||
def _validate_origin(self, origin: str, rp_id: str) -> None:
|
||||
"""Validate an origin URL against the rp_id."""
|
||||
hostname = urlparse(origin).hostname
|
||||
@staticmethod
|
||||
def _validate_origin_url(origin: str) -> None:
|
||||
"""Validate that an origin URL is well-formed (has a valid hostname)."""
|
||||
hostname = hostutil.origin_hostname(origin)
|
||||
if not hostname:
|
||||
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
||||
if not hostutil.is_valid_hostname(hostname):
|
||||
raise ValueError(f"Invalid origin URL: malformed hostname in '{origin}'")
|
||||
|
||||
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
||||
return
|
||||
def _origin_in_subtree(self, origin: str) -> bool:
|
||||
"""Check whether an origin's hostname is the rp-id or its subdomain."""
|
||||
hostname = hostutil.origin_hostname(origin)
|
||||
return bool(hostname) and hostutil.is_subdomain(hostname, self.rp_id)
|
||||
|
||||
raise ValueError(
|
||||
f"Origin domain '{hostname}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
||||
def _allowlisted(self, origin: str) -> bool:
|
||||
"""Check an in-domain origin against the allow-list.
|
||||
|
||||
An entry matches exactly. A wildcard pattern matches hostnames
|
||||
under its base: '**.example.com' covers the base domain itself and
|
||||
subdomains at any depth, while '*.example.com' covers exactly one
|
||||
subdomain level (neither the apex nor deeper levels) — the
|
||||
shell-glob convention, analogous to permission scope wildcards.
|
||||
Wildcards match over https only, except under localhost
|
||||
('**.localhost'), which matches any scheme and any port.
|
||||
"""
|
||||
if origin in self.allowed_origins:
|
||||
return True
|
||||
hostname = hostutil.origin_hostname(origin)
|
||||
for entry in self.allowed_origins:
|
||||
base = hostutil.wildcard_base(entry)
|
||||
if not base or not hostname:
|
||||
continue
|
||||
if entry.startswith("**."):
|
||||
matched = hostutil.is_subdomain(hostname, base)
|
||||
else:
|
||||
# Exactly one subdomain level below the base
|
||||
matched = (
|
||||
hostname.endswith(f".{base}")
|
||||
and "." not in hostname[: -len(base) - 1]
|
||||
)
|
||||
if not matched:
|
||||
continue
|
||||
if hostutil.is_subdomain(base, "localhost"):
|
||||
return True # localhost: any scheme, any port
|
||||
if origin.startswith("https://"):
|
||||
return True # Wildcard patterns match https origins only
|
||||
return False
|
||||
|
||||
def validate_origin(self, origin: str) -> str:
|
||||
"""Validate that origin is allowed and return it.
|
||||
|
||||
An in-domain origin (rp-id or subdomain) must match a listed origin
|
||||
or wildcard pattern. An origin outside the rp-id domain is valid
|
||||
only when explicitly listed as a related origin (Related Origin
|
||||
Requests).
|
||||
|
||||
Args:
|
||||
origin: The origin URL to validate (from WebSocket request header)
|
||||
|
||||
@@ -115,13 +176,15 @@ class Passkey:
|
||||
The validated origin URL
|
||||
|
||||
Raises:
|
||||
ValueError: If origin is not in the allowed list (when origins are configured)
|
||||
or if origin is not a valid subdomain of rp_id
|
||||
ValueError: If origin is not allowed
|
||||
"""
|
||||
self._validate_origin(origin, self.rp_id)
|
||||
if self.allowed_origins is not None and origin not in self.allowed_origins:
|
||||
raise ValueError(f"Origin '{origin}' is not in the allowed origins list")
|
||||
self._validate_origin_url(origin)
|
||||
if self._origin_in_subtree(origin):
|
||||
if self._allowlisted(origin):
|
||||
return origin
|
||||
elif origin in self.related_origins:
|
||||
return origin
|
||||
raise ValueError(f"Origin '{origin}' is not allowed for rp_id '{self.rp_id}'")
|
||||
|
||||
### Registration Methods ###
|
||||
|
||||
@@ -197,6 +260,7 @@ class Passkey:
|
||||
aaguid=UUID(registration.aaguid),
|
||||
public_key=registration.credential_public_key,
|
||||
sign_count=registration.sign_count,
|
||||
rp_id=self.rp_id,
|
||||
)
|
||||
|
||||
### Authentication Methods ###
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""API response utilities using msgspec for JSON serialization.
|
||||
|
||||
msgspec handles UUID and datetime conversion automatically.
|
||||
API structs inherit from db structs with kw_only=True to add uuid/key fields.
|
||||
Some API structs inherit from db structs with kw_only=True to add uuid/key
|
||||
fields; others are standalone response shapes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,7 +13,7 @@ from uuid import UUID
|
||||
import msgspec
|
||||
|
||||
from paskia import db
|
||||
from paskia.db.structs import Credential, Org, Permission, Role, User
|
||||
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
|
||||
from paskia.util import useragent
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -161,17 +162,40 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
||||
|
||||
|
||||
class ApiSettings(msgspec.Struct):
|
||||
"""Settings response struct."""
|
||||
"""Settings response struct (per the domain the request was dispatched to).
|
||||
|
||||
auth_host is the domain's own dedicated auth host (None when the
|
||||
domain has none); own_auth_host is the same value, kept as a separate
|
||||
field for clients that switched to it.
|
||||
"""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str
|
||||
ui_base_path: str
|
||||
auth_host: str | None
|
||||
own_auth_host: str | None
|
||||
auth_site_url: str
|
||||
session_cookie: str
|
||||
version: str
|
||||
|
||||
|
||||
class ApiDomain(msgspec.Struct):
|
||||
"""Domain entry in the admin domain list response.
|
||||
|
||||
origins mirrors the stored configuration: an object keyed by host or
|
||||
wildcard pattern (https:// omitted), values True or an object with
|
||||
extra properties (auth_host). Entries outside the rp-id domain are
|
||||
related origins.
|
||||
"""
|
||||
|
||||
rp_id: str
|
||||
rp_name: str
|
||||
origins: dict[str, bool | OriginEntry]
|
||||
site_url: str
|
||||
auth_site_url: str
|
||||
auth_host: str | None
|
||||
|
||||
|
||||
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
|
||||
"""Token info response struct."""
|
||||
|
||||
|
||||
+3
-18
@@ -10,24 +10,14 @@ from uuid import UUID
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from paskia.db.paths import users_root_path
|
||||
from paskia.util import hostutil
|
||||
from paskia.domains import current_domain
|
||||
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def media_root() -> Path:
|
||||
"""Return the filesystem root for auxiliary media files."""
|
||||
return users_root_path(create_root=True)
|
||||
|
||||
|
||||
def avatars_root() -> Path:
|
||||
"""Return the filesystem root for stored avatar images."""
|
||||
return media_root()
|
||||
|
||||
|
||||
def avatar_path(user_uuid: UUID) -> Path:
|
||||
"""Return the avatar file path for a user."""
|
||||
return avatars_root() / str(user_uuid) / "profile.webp"
|
||||
return users_root_path(create_root=True) / str(user_uuid) / "profile.webp"
|
||||
|
||||
|
||||
def avatar_public_path(user_uuid: UUID) -> str:
|
||||
@@ -46,12 +36,7 @@ def avatar_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the absolute public avatar URL for a user, or None."""
|
||||
if not avatar_path(user_uuid).is_file():
|
||||
return None
|
||||
return hostutil.api_url(f"user/{user_uuid}/profile.webp")
|
||||
|
||||
|
||||
def current_avatar_url(user_uuid: UUID) -> str | None:
|
||||
"""Return the current absolute avatar URL for a user UUID."""
|
||||
return avatar_url(user_uuid)
|
||||
return current_domain().api_url(f"user/{user_uuid}/profile.webp")
|
||||
|
||||
|
||||
def remove_avatar_file(user_uuid: UUID) -> None:
|
||||
|
||||
+53
-83
@@ -1,65 +1,69 @@
|
||||
"""Utilities for determining the auth UI host and base URLs."""
|
||||
"""Utilities for host/origin normalization and validation."""
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
_RP_ID_RE = re.compile(
|
||||
r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
|
||||
)
|
||||
|
||||
|
||||
def _cfg():
|
||||
return runtime_config()
|
||||
def validate_rp_id(rp_id: str) -> None:
|
||||
"""Validate that rp_id is a valid domain name (or localhost)."""
|
||||
if not rp_id:
|
||||
raise ValueError("rp_id cannot be empty")
|
||||
if rp_id == "localhost":
|
||||
return
|
||||
if not _RP_ID_RE.match(rp_id):
|
||||
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
|
||||
|
||||
|
||||
def is_root_mode() -> bool:
|
||||
cfg = _cfg()
|
||||
return cfg is not None and cfg.config.auth_host is not None
|
||||
def is_valid_hostname(hostname: str) -> bool:
|
||||
"""Check hostname shape: dot-separated alphanumeric/hyphen labels —
|
||||
no empty labels, so no leading/trailing or double dots ('.localhost',
|
||||
'localhost.', 'a..b.com' are all malformed)."""
|
||||
return bool(_RP_ID_RE.match(hostname))
|
||||
|
||||
|
||||
def dedicated_auth_host() -> str | None:
|
||||
"""Return configured auth_host netloc, or None."""
|
||||
cfg = _cfg()
|
||||
auth_host = cfg.config.auth_host if cfg else None
|
||||
if not auth_host:
|
||||
def is_wildcard_pattern(value: str) -> bool:
|
||||
"""Check whether an origins entry is a wildcard pattern like
|
||||
'*.example.com' (one subdomain level) or '**.example.com' (the base
|
||||
domain and any depth of subdomains)."""
|
||||
return value.startswith("*.") or value.startswith("**.")
|
||||
|
||||
|
||||
def wildcard_base(pattern: str) -> str | None:
|
||||
"""Base domain of a wildcard pattern; None if not a wildcard."""
|
||||
if pattern.startswith("**."):
|
||||
return pattern[3:].rstrip(".") or None
|
||||
if pattern.startswith("*."):
|
||||
return pattern[2:].rstrip(".") or None
|
||||
return None
|
||||
|
||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||
return parsed.netloc or parsed.path or None
|
||||
|
||||
|
||||
def ui_base_path() -> str:
|
||||
return "/" if is_root_mode() else "/auth/"
|
||||
|
||||
|
||||
def api_url(path: str = "") -> str:
|
||||
"""Return an absolute URL under the canonical /auth/api/ prefix."""
|
||||
cfg = _cfg()
|
||||
base = cfg.site_url if cfg else "https://localhost"
|
||||
if not path:
|
||||
return f"{base}/auth/api/"
|
||||
normalized = path.lstrip("/")
|
||||
return f"{base}/auth/api/{normalized}"
|
||||
|
||||
|
||||
def auth_site_url() -> str:
|
||||
"""Return the base URL for the auth site UI (computed at startup)."""
|
||||
cfg = _cfg()
|
||||
if cfg:
|
||||
return cfg.site_url + cfg.site_path
|
||||
return "https://localhost/auth/"
|
||||
|
||||
|
||||
def reset_link_url(token: str) -> str:
|
||||
"""Generate a reset link URL for the given token."""
|
||||
return f"{auth_site_url()}{token}"
|
||||
|
||||
|
||||
def normalize_origin(origin: str) -> str:
|
||||
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes."""
|
||||
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
|
||||
|
||||
Wildcard patterns ('*.example.com', '**.example.com') pass through
|
||||
unchanged — they are allow-list entries, not concrete origins.
|
||||
"""
|
||||
if is_wildcard_pattern(origin):
|
||||
return origin.rstrip("/.")
|
||||
if "://" not in origin:
|
||||
return f"https://{origin}"
|
||||
return origin.rstrip("/")
|
||||
|
||||
|
||||
def origin_hostname(origin: str) -> str | None:
|
||||
"""Extract the lowercase hostname from an origin URL, if well-formed.
|
||||
|
||||
For wildcard patterns the base domain is returned.
|
||||
"""
|
||||
if base := wildcard_base(origin):
|
||||
return base.lower()
|
||||
return urlparse(origin).hostname
|
||||
|
||||
|
||||
def is_subdomain(sub: str, domain: str) -> bool:
|
||||
"""Check if sub is a subdomain of domain (or equal)."""
|
||||
sub_parts = sub.lower().split(".")
|
||||
@@ -69,48 +73,14 @@ def is_subdomain(sub: str, domain: str) -> bool:
|
||||
return sub_parts[-len(domain_parts) :] == domain_parts
|
||||
|
||||
|
||||
def validate_auth_host(auth_host: str, rp_id: str) -> None:
|
||||
"""Validate that auth_host is a subdomain of rp_id.
|
||||
|
||||
Raises ValueError on invalid auth_host.
|
||||
"""
|
||||
def auth_host_netloc(auth_host: str) -> str | None:
|
||||
"""Return the host[:port] part of a configured auth host URL."""
|
||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||
host = parsed.hostname or parsed.path
|
||||
if not host:
|
||||
raise ValueError(f"Invalid auth-host: '{auth_host}'")
|
||||
if not is_subdomain(host, rp_id):
|
||||
raise ValueError(
|
||||
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
|
||||
)
|
||||
|
||||
|
||||
def normalize_auth_host_and_origins(
|
||||
auth_host: str | None, origins: list[str] | None
|
||||
) -> tuple[str | None, list[str] | None]:
|
||||
"""Normalize auth_host and origins, matching CLI startup behavior.
|
||||
|
||||
- Adds https:// to auth_host if no scheme present, strips trailing slashes
|
||||
- Validates auth_host is a well-formed subdomain (caller provides rp_id via validate_auth_host)
|
||||
- Inserts auth_host as first origin if both are specified and not already present
|
||||
- Deduplicates origins while preserving order
|
||||
"""
|
||||
if auth_host:
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
auth_host = auth_host.rstrip("/")
|
||||
if origins is not None and auth_host not in origins:
|
||||
origins.insert(0, auth_host)
|
||||
if origins:
|
||||
origins = list(dict.fromkeys(origins))
|
||||
return auth_host, origins
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
clear_config_cache()
|
||||
return parsed.netloc or parsed.path or None
|
||||
|
||||
|
||||
def normalize_host(raw_host: str | None) -> str | None:
|
||||
"""Normalize a Host header, stripping port numbers for consistent matching."""
|
||||
"""Normalize a Host header, stripping port numbers and trailing dots."""
|
||||
if not raw_host:
|
||||
return None
|
||||
candidate = raw_host.strip()
|
||||
@@ -127,7 +97,7 @@ def normalize_host(raw_host: str | None) -> str | None:
|
||||
else:
|
||||
# Strip port from host:port
|
||||
netloc = netloc.rsplit(":", 1)[0]
|
||||
return netloc.lower() or None
|
||||
return netloc.lower().rstrip(".") or None
|
||||
|
||||
|
||||
def format_endpoint(ep: dict) -> str:
|
||||
|
||||
+33
-36
@@ -1,5 +1,8 @@
|
||||
"""
|
||||
OIDC JWT utilities for signing ID tokens and serving JWKS.
|
||||
|
||||
The OIDC provider is instance-global: a single signing key serves all
|
||||
domains, with each request Host acting as an issuer alias.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -18,46 +21,44 @@ from paskia.util.crypto import (
|
||||
secret_key,
|
||||
)
|
||||
|
||||
# JWT signing key (loaded on first use)
|
||||
_private_key = None
|
||||
_public_key = None
|
||||
_kid: str | None = None
|
||||
# JWT signing key (loaded on first use): (private, public, kid)
|
||||
_key: tuple[object, object, str] | None = None
|
||||
|
||||
|
||||
def _load_or_generate_key() -> None:
|
||||
"""Load existing Ed25519 key or generate a new one."""
|
||||
global _private_key, _public_key, _kid
|
||||
|
||||
def _load_or_generate_key() -> tuple[object, object, str]:
|
||||
"""Load the Ed25519 signing key or generate and store a new one."""
|
||||
data = db.data()
|
||||
provider = data.oidc
|
||||
store = data._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
if data.oidc.key is not None:
|
||||
_private_key = public_key_from_secret(data.oidc.key)
|
||||
if provider.key is not None:
|
||||
private_key = public_key_from_secret(provider.key)
|
||||
else:
|
||||
raw_key = secret_key()
|
||||
with store.transaction("oidc_key"):
|
||||
data.oidc.key = raw_key
|
||||
_private_key = public_key_from_secret(raw_key)
|
||||
provider.key = raw_key
|
||||
private_key = public_key_from_secret(raw_key)
|
||||
|
||||
_public_key = _private_key.public_key()
|
||||
public_key = private_key.public_key()
|
||||
# Generate kid from public key fingerprint
|
||||
pub_der = get_public_key_der(_private_key)
|
||||
_kid = generate_kid(pub_der)
|
||||
kid = generate_kid(get_public_key_der(private_key))
|
||||
return private_key, public_key, kid
|
||||
|
||||
|
||||
def _ensure_key() -> None:
|
||||
"""Ensure key is loaded."""
|
||||
if _private_key is None:
|
||||
_load_or_generate_key()
|
||||
def _ensure_key() -> tuple[object, object, str]:
|
||||
"""Ensure the signing key is loaded and return (private, public, kid)."""
|
||||
global _key
|
||||
if _key is None:
|
||||
_key = _load_or_generate_key()
|
||||
return _key
|
||||
|
||||
|
||||
def get_jwks() -> dict:
|
||||
"""Get JWKS (JSON Web Key Set) for public key verification."""
|
||||
_ensure_key()
|
||||
assert _public_key is not None
|
||||
private_key, _, kid = _ensure_key()
|
||||
# Ed25519 public key is 32 bytes raw
|
||||
pub_bytes = get_public_key_raw(_private_key)
|
||||
pub_bytes = get_public_key_raw(private_key)
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
@@ -65,7 +66,7 @@ def get_jwks() -> dict:
|
||||
"crv": "Ed25519",
|
||||
"use": "sig",
|
||||
"alg": "EdDSA",
|
||||
"kid": _kid,
|
||||
"kid": kid,
|
||||
"x": urlsafe_b64encode(pub_bytes).rstrip(b"=").decode("ascii"),
|
||||
}
|
||||
]
|
||||
@@ -105,8 +106,7 @@ def create_id_token(
|
||||
Returns:
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
private_key, _, kid = _ensure_key()
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
@@ -132,7 +132,7 @@ def create_id_token(
|
||||
if auth_time:
|
||||
payload["auth_time"] = int(auth_time.timestamp())
|
||||
|
||||
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
|
||||
return jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": kid})
|
||||
|
||||
|
||||
def create_access_token(
|
||||
@@ -154,8 +154,7 @@ def create_access_token(
|
||||
Returns:
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
private_key, _, kid = _ensure_key()
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
@@ -165,7 +164,7 @@ def create_access_token(
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(seconds=expires_in)).timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
|
||||
return jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": kid})
|
||||
|
||||
|
||||
def decode_access_token(
|
||||
@@ -181,13 +180,12 @@ def decode_access_token(
|
||||
Returns:
|
||||
Decoded payload or None if invalid
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _public_key is not None
|
||||
_, public_key, _ = _ensure_key()
|
||||
try:
|
||||
if audience is not None:
|
||||
return jwt.decode(
|
||||
token,
|
||||
_public_key,
|
||||
public_key,
|
||||
algorithms=["EdDSA"],
|
||||
issuer=issuer,
|
||||
audience=audience,
|
||||
@@ -195,7 +193,7 @@ def decode_access_token(
|
||||
|
||||
return jwt.decode(
|
||||
token,
|
||||
_public_key,
|
||||
public_key,
|
||||
algorithms=["EdDSA"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
@@ -224,8 +222,7 @@ def create_logout_token(
|
||||
Returns:
|
||||
Signed JWT string
|
||||
"""
|
||||
_ensure_key()
|
||||
assert _private_key is not None
|
||||
private_key, _, kid = _ensure_key()
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
@@ -241,4 +238,4 @@ def create_logout_token(
|
||||
payload["sid"] = sid
|
||||
if sub:
|
||||
payload["sub"] = str(sub)
|
||||
return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})
|
||||
return jwt.encode(payload, private_key, algorithm="EdDSA", headers={"kid": kid})
|
||||
|
||||
+20
-59
@@ -1,75 +1,36 @@
|
||||
"""Runtime configuration utilities."""
|
||||
"""Runtime serve configuration (process-global parameters only).
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
import msgspec
|
||||
|
||||
from paskia.db.structs import Config
|
||||
|
||||
class ServeConfig(msgspec.Struct):
|
||||
"""Process-global serve parameters."""
|
||||
|
||||
class RuntimeConfig(msgspec.Struct):
|
||||
"""Runtime configuration for the Paskia authentication server.
|
||||
|
||||
Wraps the db Config (CLI/stored settings) with computed runtime fields.
|
||||
Serialized to PASKIA_CONFIG env var as JSON via msgspec.
|
||||
"""
|
||||
|
||||
config: Config # CLI/stored configuration to persist
|
||||
site_url: str # Base URL without trailing path (e.g. https://example.com)
|
||||
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
|
||||
save: bool = False # Whether to persist config to database
|
||||
listen: list[str] | None = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_config() -> RuntimeConfig | None:
|
||||
"""Load RuntimeConfig from PASKIA_CONFIG env var."""
|
||||
config_json = os.getenv("PASKIA_CONFIG")
|
||||
if not config_json:
|
||||
def _load() -> ServeConfig | None:
|
||||
raw = os.getenv("PASKIA_CONFIG")
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
|
||||
return msgspec.json.decode(raw.encode(), type=ServeConfig)
|
||||
|
||||
|
||||
def config() -> RuntimeConfig | None:
|
||||
"""Return cached runtime config loaded from PASKIA_CONFIG."""
|
||||
return _load_config()
|
||||
def serve_config() -> ServeConfig | None:
|
||||
"""Return cached serve configuration loaded from PASKIA_CONFIG."""
|
||||
return _load()
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""Clear cached runtime config; next config() call reloads from env."""
|
||||
_load_config.cache_clear()
|
||||
|
||||
|
||||
def update_runtime_config(new_config: Config) -> None:
|
||||
"""Update the runtime configuration with a new Config and refresh the cache."""
|
||||
current_runtime = config()
|
||||
if not current_runtime:
|
||||
return # No runtime config to update
|
||||
|
||||
# Recompute site_url and site_path based on new config
|
||||
old_auth_host = current_runtime.config.auth_host
|
||||
if new_config.auth_host:
|
||||
site_url, site_path = new_config.auth_host, "/"
|
||||
else:
|
||||
site_path = "/auth/"
|
||||
# Never derive site_url from a just-removed auth host
|
||||
origins = [o for o in (new_config.origins or []) if o != old_auth_host]
|
||||
if origins:
|
||||
site_url = origins[0]
|
||||
elif current_runtime.site_url != old_auth_host:
|
||||
# Keep current site_url if it wasn't derived from the removed auth host
|
||||
site_url = current_runtime.site_url
|
||||
else:
|
||||
site_url = f"https://{new_config.rp_id}"
|
||||
|
||||
new_runtime = RuntimeConfig(
|
||||
config=new_config,
|
||||
site_url=site_url,
|
||||
site_path=site_path,
|
||||
save=current_runtime.save,
|
||||
)
|
||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode()
|
||||
|
||||
# Clear the cache so next access loads the updated config
|
||||
clear_config_cache()
|
||||
def clear_cache() -> None:
|
||||
"""Clear cached serve configuration; next serve_config() reloads."""
|
||||
_load.cache_clear()
|
||||
|
||||
+31
-24
@@ -14,7 +14,10 @@ from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.hostutil import format_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.util.runtime import RuntimeConfig
|
||||
from paskia.domains import DomainRegistry
|
||||
|
||||
from paskia.db.structs import OriginEntry
|
||||
from paskia.domains import is_related_key, origin_url
|
||||
|
||||
BOX_WIDTH = 60 # Inner width (excluding box chars)
|
||||
|
||||
@@ -48,14 +51,18 @@ def bottom() -> str:
|
||||
return "┗" + "━" * (BOX_WIDTH + 2) + "┛\n"
|
||||
|
||||
|
||||
def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||
"""Print server configuration on startup."""
|
||||
def print_startup_config(
|
||||
registry: DomainRegistry, listen: list[str] | None = None
|
||||
) -> None:
|
||||
"""Print server configuration on startup (one section per domain)."""
|
||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||
y = YELLOW # Bright golden yellow for main body
|
||||
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
|
||||
w = BRIGHT_WHITE # Bold white for URL
|
||||
r = RESET
|
||||
|
||||
domains = sorted(registry.domains, key=lambda d: d.rp_id)
|
||||
|
||||
lines = [top()]
|
||||
lines.append(line(f" {b}▄▄▄▄▄{r}"))
|
||||
lines.append(line(f"{b}█{y} {b}█{r} Paskia " + __version__))
|
||||
@@ -63,43 +70,43 @@ def print_startup_config(runtime: RuntimeConfig) -> None:
|
||||
lines.append(
|
||||
line(
|
||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r} {w}"
|
||||
+ runtime.site_url
|
||||
+ runtime.site_path
|
||||
+ domains[0].site_url
|
||||
+ domains[0].site_path
|
||||
+ r
|
||||
)
|
||||
)
|
||||
lines.append(line(f" {y}▀▀▀▀▀{r}"))
|
||||
|
||||
# Format auth host section
|
||||
if runtime.config.auth_host:
|
||||
lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
|
||||
|
||||
# Show frontend URL if in dev mode
|
||||
if DEVMODE:
|
||||
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
|
||||
|
||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
||||
|
||||
endpoints = list(parse_endpoints(runtime.config.listen, DEFAULT_PORT))
|
||||
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]
|
||||
lines.append(line(f"Backend: {' '.join(parts)}"))
|
||||
|
||||
# Relying Party line (omit name if same as id)
|
||||
rp_id = runtime.config.rp_id
|
||||
rp_name = runtime.config.rp_name
|
||||
suffix = f" ({rp_name})" if rp_name and rp_name != rp_id else ""
|
||||
lines.append(line(f"Relying Party: {rp_id}{suffix}"))
|
||||
|
||||
# Format origins section
|
||||
allowed = runtime.config.origins
|
||||
if allowed:
|
||||
lines.append(line("Permitted Origins:"))
|
||||
for origin in sorted(allowed):
|
||||
lines.append(line(f" - {origin}"))
|
||||
else:
|
||||
lines.append(line(f"Origin: {rp_id} and all subdomains allowed"))
|
||||
for domain in domains:
|
||||
# Domain line (omit name if same as id)
|
||||
rp_name = domain.rp_name
|
||||
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
|
||||
header = "Domain: " if len(domains) > 1 else "Relying Party: "
|
||||
lines.append(line(f"{header}{domain.rp_id}{suffix}"))
|
||||
if len(domains) > 1:
|
||||
lines.append(line(f" URL: {domain.site_url}{domain.site_path}"))
|
||||
for key, props in sorted(domain.config.origins.items()):
|
||||
marker = (
|
||||
" (auth host)"
|
||||
if isinstance(props, OriginEntry) and props.auth_host
|
||||
else ""
|
||||
)
|
||||
label = "Related:" if is_related_key(domain.rp_id, key) else "Origin:"
|
||||
lines.append(line(f" {label:<14}{origin_url(key)}{marker}"))
|
||||
if not domain.config.origins:
|
||||
lines.append(line(" Origins: (none configured)"))
|
||||
|
||||
lines.append(bottom())
|
||||
stderr.write("".join(lines))
|
||||
|
||||
+52
-25
@@ -6,6 +6,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,9 @@ from urllib.parse import urlparse
|
||||
|
||||
import tracerite
|
||||
|
||||
from paskia.db.legacy import find_legacy_databases
|
||||
from paskia.db.paths import db_file_path
|
||||
|
||||
# Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from devutil import ( # noqa: E402
|
||||
@@ -135,6 +139,41 @@ async def run_caddy(
|
||||
return proc
|
||||
|
||||
|
||||
def _split_multi(values: list[str] | None) -> list[str]:
|
||||
"""Split repeatable/comma-separated CLI values into a flat list."""
|
||||
result = []
|
||||
for value in values or []:
|
||||
result.extend(part.strip() for part in value.split(",") if part.strip())
|
||||
return result
|
||||
|
||||
|
||||
def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) -> None:
|
||||
"""Bootstrap paskia.kantadb via 'paskia init' when no database exists.
|
||||
|
||||
Domain options are init-only; 'paskia' (serve) reads all configuration
|
||||
from the database. A legacy *.paskiadb database must be converted with
|
||||
'paskia migrate' first.
|
||||
"""
|
||||
if db_file_path().exists():
|
||||
return
|
||||
if find_legacy_databases():
|
||||
raise SystemExit(
|
||||
"Legacy *.paskiadb database found — run 'paskia migrate' to "
|
||||
"convert it before starting the dev server."
|
||||
)
|
||||
|
||||
for i, rp_id in enumerate(rp_ids):
|
||||
cmd = [sys.executable, "-m", "paskia", "init", rp_id]
|
||||
if i == 0:
|
||||
if args.rp_name:
|
||||
cmd.append(args.rp_name)
|
||||
cmd.append(f"--listen={listen}")
|
||||
logger.info(">>> paskia init %s", rp_id)
|
||||
proc = subprocess.run(cmd, check=False) # noqa: S603
|
||||
if proc.returncode != 0:
|
||||
raise SystemExit(proc.returncode)
|
||||
|
||||
|
||||
async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
"""Run the development server with all components."""
|
||||
reporoot = Path(__file__).parent.parent
|
||||
@@ -146,39 +185,23 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
|
||||
backurl, paskia = setup_cli("paskia", args.backend, DEFAULT_DEV_PORT)
|
||||
|
||||
# Build paskia command with options
|
||||
paskia.extend(["--rp-id", args.rp_id])
|
||||
if args.auth_host:
|
||||
paskia.extend(["--auth-host", args.auth_host])
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
paskia.extend(["--origin", origin])
|
||||
rp_ids = _split_multi(args.rp_id) or ["localhost"]
|
||||
ensure_database(rp_ids, args, listen=backurl.removeprefix("http://"))
|
||||
|
||||
# Serve: no domain options — all configuration lives in the database
|
||||
paskia.extend(remaining)
|
||||
|
||||
# Set environment for subprocesses
|
||||
os.environ["PASKIA_VITE_URL"] = viteurl
|
||||
os.environ["PASKIA_BACKEND_URL"] = backurl
|
||||
os.environ["PASKIA_DEV"] = "1"
|
||||
if args.auth_host:
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
# Start Caddy first if requested (needs to bind ports)
|
||||
if args.caddy:
|
||||
caddy_origins = []
|
||||
if args.auth_host:
|
||||
auth_host = args.auth_host
|
||||
if "://" not in auth_host:
|
||||
auth_host = f"https://{auth_host}"
|
||||
caddy_origins.append(auth_host)
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
if args.origins:
|
||||
for origin in args.origins:
|
||||
if "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
caddy_origins.append(origin)
|
||||
if not caddy_origins:
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
for rp_id in rp_ids:
|
||||
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)
|
||||
@@ -209,11 +232,15 @@ def main():
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
|
||||
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
|
||||
parser.add_argument(
|
||||
"--origin", action="append", dest="origins", help="Allowed origin(s)"
|
||||
"--rp-id",
|
||||
action="append",
|
||||
help="Relying Party ID(s) for first-run bootstrap (default: localhost). "
|
||||
"Repeatable and comma-separated.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rp-name", help="Relying Party name of the first domain (bootstrap only)"
|
||||
)
|
||||
parser.add_argument("--auth-host", help="Dedicated auth host")
|
||||
args, remaining = parser.parse_known_args()
|
||||
|
||||
with suppress(KeyboardInterrupt):
|
||||
|
||||
+37
-51
@@ -12,12 +12,12 @@ in the database to test authenticated endpoints.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
@@ -25,22 +25,8 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from kanta import Kanta
|
||||
|
||||
# Keep runtime initialization invariant aligned with production:
|
||||
# db.lifecycle requires PASKIA_CONFIG at import time.
|
||||
os.environ.setdefault(
|
||||
"PASKIA_CONFIG",
|
||||
json.dumps(
|
||||
{
|
||||
"config": {"rp_id": "localhost", "rp_name": "localhost"},
|
||||
"site_url": "http://localhost:4401",
|
||||
"site_path": "/auth/",
|
||||
"save": False,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import globals as paskia_globals
|
||||
from paskia import domains
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import (
|
||||
@@ -56,12 +42,15 @@ from paskia.db import (
|
||||
)
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.operations import DB
|
||||
from paskia.db.structs import Session
|
||||
from paskia.db.structs import Config, DomainConfig, Session
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import avatar
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
TEST_RP_ID = "localhost"
|
||||
TEST_LISTEN = ["localhost:4401"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
@@ -71,6 +60,19 @@ def event_loop():
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _avatar_tmp_root(tmp_path, monkeypatch):
|
||||
"""Redirect avatar storage to a per-test temporary directory."""
|
||||
root = tmp_path / "users"
|
||||
|
||||
def users_root(create_root: bool = False) -> Path:
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
monkeypatch.setattr(avatar, "users_root_path", users_root)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_db() -> AsyncGenerator[DB]:
|
||||
"""Create a temporary JSONL database for testing using kanta.
|
||||
@@ -79,15 +81,11 @@ async def test_db() -> AsyncGenerator[DB]:
|
||||
- auth:admin and auth:org:admin permissions
|
||||
- A default organization with Administration role
|
||||
- An admin user with the Administration role
|
||||
- The localhost domain configuration (with its OIDC provider)
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||
db = DB()
|
||||
kanta = Kanta(
|
||||
f.name,
|
||||
db,
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = "test.example.com"
|
||||
kanta = Kanta(f.name, db)
|
||||
|
||||
# Register bootstrap callback so kanta seeds the empty DB during open()
|
||||
@kanta.bootstrap(action="bootstrap")
|
||||
@@ -96,6 +94,11 @@ async def test_db() -> AsyncGenerator[DB]:
|
||||
data,
|
||||
org_name="Test Organization",
|
||||
admin_name="Test Admin",
|
||||
config=Config(
|
||||
domains={
|
||||
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True})
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
await kanta.open()
|
||||
@@ -107,25 +110,10 @@ async def test_db() -> AsyncGenerator[DB]:
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def passkey_instance() -> Passkey:
|
||||
"""Override the module-level passkey instance for testing."""
|
||||
pk = Passkey(
|
||||
rp_id="localhost",
|
||||
rp_name="Test RP",
|
||||
origins=["http://localhost:4401"],
|
||||
)
|
||||
original = {
|
||||
"rp_id": paskia_globals.passkey.rp_id,
|
||||
"rp_name": paskia_globals.passkey.rp_name,
|
||||
"allowed_origins": paskia_globals.passkey.allowed_origins,
|
||||
}
|
||||
paskia_globals.passkey.rp_id = pk.rp_id
|
||||
paskia_globals.passkey.rp_name = pk.rp_name
|
||||
paskia_globals.passkey.allowed_origins = pk.allowed_origins
|
||||
yield pk
|
||||
paskia_globals.passkey.rp_id = original["rp_id"]
|
||||
paskia_globals.passkey.rp_name = original["rp_name"]
|
||||
paskia_globals.passkey.allowed_origins = original["allowed_origins"]
|
||||
async def domain_registry(test_db: DB) -> domains.DomainRegistry:
|
||||
"""Install the domain registry built from the test database config."""
|
||||
domains.configure(listen=TEST_LISTEN)
|
||||
return domains.init_registry(test_db.config)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -192,6 +180,7 @@ async def test_credential(test_db: DB, test_user: User) -> Credential:
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id=TEST_RP_ID,
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -206,6 +195,7 @@ async def regular_credential(test_db: DB, regular_user: User) -> Credential:
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id=TEST_RP_ID,
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -247,15 +237,9 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential)
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def client(
|
||||
test_db: DB, passkey_instance: Passkey
|
||||
test_db: DB, domain_registry: domains.DomainRegistry
|
||||
) -> AsyncGenerator[httpx.AsyncClient]:
|
||||
"""Create an async test client for the FastAPI app.
|
||||
|
||||
Note: We import the app inside the fixture to ensure globals are
|
||||
initialized first.
|
||||
"""
|
||||
# Import app after globals are set
|
||||
|
||||
"""Create an async test client for the FastAPI app."""
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
@@ -283,6 +267,7 @@ def create_test_session(
|
||||
ip: str = "127.0.0.1",
|
||||
user_agent: str = "pytest",
|
||||
duration: timedelta | None = None,
|
||||
rp_id: str = TEST_RP_ID,
|
||||
) -> tuple[str, str]:
|
||||
"""Create a test session. Returns (key, token) tuple.
|
||||
|
||||
@@ -309,6 +294,7 @@ def create_test_session(
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
validated=now,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
if session.key in ops_db._db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
|
||||
+303
-53
@@ -22,7 +22,7 @@ import pytest
|
||||
import pytest_asyncio
|
||||
import uuid7
|
||||
|
||||
from paskia import db
|
||||
from paskia import db, domains
|
||||
from paskia.db import (
|
||||
Credential,
|
||||
Org,
|
||||
@@ -37,10 +37,7 @@ from paskia.db import (
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.operations import DB
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.crypto import hash_secret
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
|
||||
# -------------------- Additional Fixtures --------------------
|
||||
@@ -91,6 +88,7 @@ async def second_org_credential(test_db: DB, second_org_user: User) -> Credentia
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id="localhost",
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -145,6 +143,7 @@ async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential:
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id="localhost",
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -253,8 +252,6 @@ class TestAdminOrganizations:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin org payload should include canonical avatar URLs for listed users."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -948,8 +945,6 @@ class TestAdminUsersInOrg:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin should be able to upload avatar for a managed user."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -1794,38 +1789,60 @@ class TestOrgAdminAuthExceptions:
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestServerConfig:
|
||||
"""Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates."""
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def restore_runtime_config(self):
|
||||
"""Restore PASKIA_CONFIG env and cache after a test mutates runtime."""
|
||||
original = os.environ["PASKIA_CONFIG"]
|
||||
yield
|
||||
os.environ["PASKIA_CONFIG"] = original
|
||||
clear_config_cache()
|
||||
class TestDomains:
|
||||
"""Tests for the domain management API (/auth/api/admin/domains/)."""
|
||||
|
||||
async def _set_auth_host(self, client, session_token, test_user, test_credential):
|
||||
"""Configure an auth host via PATCH, as the admin UI would."""
|
||||
"""Configure an auth host on the localhost domain, as the admin UI would."""
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"auth_host": "auth.localhost",
|
||||
"origins": ["auth.localhost", "localhost"],
|
||||
"origins": {
|
||||
"auth.localhost": {"auth_host": True},
|
||||
"localhost": True,
|
||||
},
|
||||
},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.data().config.auth_host == "https://auth.localhost"
|
||||
assert hostutil.dedicated_auth_host() == "auth.localhost"
|
||||
assert hostutil.auth_site_url() == "https://auth.localhost/"
|
||||
domain_cfg = db.data().config.domains["localhost"]
|
||||
assert domains.auth_host_url(domain_cfg) == "https://auth.localhost"
|
||||
domain = domains.registry().get("localhost")
|
||||
assert domain.own_auth_host == "auth.localhost"
|
||||
assert domain.auth_site_url == "https://auth.localhost/"
|
||||
# Session for requests coming from the auth host (sessions are host-bound)
|
||||
_, token = create_test_session(
|
||||
test_user.uuid, test_credential.uuid, host="auth.localhost"
|
||||
)
|
||||
return {**auth_headers(token), "Host": "auth.localhost"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_domains(self, client: httpx.AsyncClient, session_token: str):
|
||||
r = await client.get(
|
||||
"/auth/api/admin/domains/",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert len(data) == 1
|
||||
domain = data[0]
|
||||
assert domain["rp_id"] == "localhost"
|
||||
assert domain["origins"] == {"**.localhost": True}
|
||||
assert "related" not in domain
|
||||
assert domain["auth_host"] is None
|
||||
assert domain["site_url"] == "http://localhost:4401"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domains_require_master_admin(
|
||||
self, client: httpx.AsyncClient, regular_session_token: str
|
||||
):
|
||||
r = await client.get(
|
||||
"/auth/api/admin/domains/",
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_auth_host_updates_runtime(
|
||||
self,
|
||||
@@ -1833,41 +1850,41 @@ class TestServerConfig:
|
||||
session_token: str,
|
||||
test_user,
|
||||
test_credential,
|
||||
restore_runtime_config,
|
||||
):
|
||||
"""Removing auth_host must clear it from runtime config and URLs."""
|
||||
"""Removing the auth host mark must clear it from runtime config and URLs."""
|
||||
headers = await self._set_auth_host(
|
||||
client, session_token, test_user, test_credential
|
||||
)
|
||||
|
||||
# The dialog still lists the old auth host among origins, so it is sent back
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"auth_host": "",
|
||||
"origins": ["auth.localhost", "localhost"],
|
||||
"origins": {"auth.localhost": True, "localhost": True},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.data().config.auth_host is None
|
||||
domain_cfg = db.data().config.domains["localhost"]
|
||||
assert domains.auth_host_url(domain_cfg) is None
|
||||
|
||||
rt = runtime_config()
|
||||
assert rt.config.auth_host is None
|
||||
assert rt.site_path == "/auth/"
|
||||
assert "auth.localhost" not in rt.site_url
|
||||
assert hostutil.dedicated_auth_host() is None
|
||||
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||
domain = domains.registry().get("localhost")
|
||||
assert domain.own_auth_host is None
|
||||
assert domain.ui_base_path == "/auth/"
|
||||
# Site URL derivation is stateless: with the auth host mark removed,
|
||||
# the exact rp-id origin becomes the site URL.
|
||||
assert domain.auth_site_url == "https://localhost/auth/"
|
||||
|
||||
# GET and settings reflect the cleared state
|
||||
r = await client.get(
|
||||
"/auth/api/admin/server-config/",
|
||||
"/auth/api/admin/domains/",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.json()["auth_host"] == ""
|
||||
assert r.json()[0]["origins"] == {"auth.localhost": True, "localhost": True}
|
||||
r = await client.get("/auth/api/settings")
|
||||
assert r.json()["auth_host"] is None
|
||||
assert r.json()["own_auth_host"] is None
|
||||
assert r.json()["ui_base_path"] == "/auth/"
|
||||
|
||||
# Middleware no longer redirects to the removed auth host
|
||||
@@ -1879,28 +1896,261 @@ class TestServerConfig:
|
||||
assert "auth.localhost" not in r.headers.get("location", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_auth_host_without_origins_falls_back_to_rp_id(
|
||||
async def test_remove_auth_host_without_origins_falls_back(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
):
|
||||
"""Emptying a domain's origins table must not keep the removed auth
|
||||
host in derived URLs. Only possible on a domain other than the one
|
||||
in use — the lockout guard refuses it there."""
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"origins": {
|
||||
"auth.example.com": {"auth_host": True},
|
||||
"app.example.com": True,
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
domain = domains.registry().get("example.com")
|
||||
assert domain.own_auth_host == "auth.example.com"
|
||||
assert "auth.example.com" in domain.site_url
|
||||
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/example.com",
|
||||
json={"rp_name": "", "origins": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
domain = domains.registry().get("example.com")
|
||||
assert domain.own_auth_host is None
|
||||
assert domain.ui_base_path == "/auth/"
|
||||
assert "auth.example.com" not in domain.site_url
|
||||
assert "auth.example.com" not in domain.auth_site_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_delete_domain(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"rp_name": "Example",
|
||||
"origins": {"app.example.com": True, "unrelated-site.com": True},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = await client.get("/auth/api/admin/domains/", headers=headers)
|
||||
domains_list = {domain["rp_id"]: domain for domain in r.json()}
|
||||
assert set(domains_list) == {"localhost", "example.com"}
|
||||
created = domains_list["example.com"]
|
||||
assert created["rp_name"] == "Example"
|
||||
# In-domain and related origins live in one table; classification
|
||||
# is derived from the rp-id
|
||||
assert created["origins"] == {
|
||||
"app.example.com": True,
|
||||
"unrelated-site.com": True,
|
||||
}
|
||||
|
||||
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "example.com" not in db.data().config.domains
|
||||
assert domains.registry().get("example.com") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_domain_validation(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
|
||||
# rp_id is required
|
||||
r = await client.post("/auth/api/admin/domains/", json={}, headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Duplicate rp-id
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "localhost"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Invalid rp-id
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "not a domain!"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# An auth host must be within the rp-id domain
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"origins": {"auth.other.com": {"auth_host": True}},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Related origin host may not collide across domains
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "example.com", "origins": {"shared-app.com": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "other.com", "origins": {"shared-app.com": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Cross-domain entries are related origins — accepted in the same table
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "another.com", "origins": {"elsewhere.com": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# Plain '*' is rejected — wildcards must be explicit ('**.another.com')
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "star.com", "origins": {"*": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_domain_guards(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_credential
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
|
||||
# Cannot delete the last domain
|
||||
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Unknown domain
|
||||
r = await client.delete("/auth/api/admin/domains/nope.com", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
# A domain with credentials still registered under it cannot be deleted
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
cred = Credential.create(
|
||||
credential_id=secrets.token_bytes(32),
|
||||
user=test_credential.user_uuid,
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=secrets.token_bytes(64),
|
||||
sign_count=0,
|
||||
rp_id="example.com",
|
||||
)
|
||||
create_credential(cred)
|
||||
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_domain_refuses_self_lockout(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""An allow-list excluding the admin's current host is refused."""
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
|
||||
# Allow-list without the current host and no auth host → lockout
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={"rp_name": "", "origins": {"auth.localhost": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "lock you out" in r.text
|
||||
|
||||
# Emptying the origins table entirely is likewise a lockout
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={"rp_name": "", "origins": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "lock you out" in r.text
|
||||
|
||||
# Allow-list including the current host is fine
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={"rp_name": "", "origins": {"localhost:4401": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# An allow-list without the current host is also fine when an auth
|
||||
# host is set: ceremonies move there (and it is always allowed).
|
||||
# Done last: with an auth host set, the API here routes differently.
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"origins": {"auth.localhost": {"auth_host": True}},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_current_domain_refused(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Deleting the domain in use is refused even if it has no credentials
|
||||
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
|
||||
assert r.status_code == 400
|
||||
assert "currently using" in r.text
|
||||
# Deleting another domain while authenticated here is fine
|
||||
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_cross_domain_auth_host_fallback(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
test_credential,
|
||||
restore_runtime_config,
|
||||
):
|
||||
"""With no origins left, site_url must not keep the removed auth host."""
|
||||
"""A domain without its own auth host reports none — there is no
|
||||
cross-domain fallback to another domain's auth host."""
|
||||
headers = await self._set_auth_host(
|
||||
client, session_token, test_user, test_credential
|
||||
)
|
||||
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
json={"rp_name": "", "auth_host": "", "origins": []},
|
||||
headers=headers,
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.status_code == 200
|
||||
|
||||
rt = runtime_config()
|
||||
assert rt.config.auth_host is None
|
||||
assert rt.site_path == "/auth/"
|
||||
assert "auth.localhost" not in rt.site_url
|
||||
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||
# Settings on the example.com host report no auth host
|
||||
r = await client.get("/auth/api/settings", headers={"Host": "example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["rp_id"] == "example.com"
|
||||
assert r.json()["auth_host"] is None
|
||||
assert r.json()["own_auth_host"] is None
|
||||
|
||||
# The localhost domain still reports its own auth host
|
||||
r = await client.get("/auth/api/settings", headers={"Host": "auth.localhost"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["auth_host"] == "auth.localhost"
|
||||
assert r.json()["own_auth_host"] == "auth.localhost"
|
||||
|
||||
+17
-18
@@ -18,12 +18,12 @@ from uuid import UUID
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia import authcode, db, domains
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import delete_session
|
||||
from paskia.db.structs import Client
|
||||
from paskia.db.structs import Client, Config, DomainConfig, OriginEntry
|
||||
from paskia.fastapi.api import _REFRESH_INTERVAL
|
||||
from paskia.util import avatar, hostutil, oidjwt, permutil
|
||||
from paskia.util import avatar, oidjwt, permutil
|
||||
from paskia.util.crypto import hash_secret
|
||||
from paskia.util.passphrase import generate
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
@@ -42,7 +42,7 @@ class TestSettingsEndpoint:
|
||||
assert "rp_name" in data
|
||||
assert "session_cookie" in data
|
||||
assert data["rp_id"] == "localhost"
|
||||
assert data["rp_name"] == "Test RP"
|
||||
assert data["rp_name"] == "localhost"
|
||||
assert data["session_cookie"] == "__Host-paskia"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -69,16 +69,20 @@ class TestAvatarUrls:
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
|
||||
db_root = tmp_path / "test-avatar-db.paskiadb"
|
||||
monkeypatch.setenv("PASKIA_DB", str(db_root))
|
||||
monkeypatch.setattr(
|
||||
hostutil,
|
||||
"api_url",
|
||||
lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}",
|
||||
domains.configure(listen=None)
|
||||
domains.init_registry(
|
||||
Config(
|
||||
domains={
|
||||
"zi.fi": DomainConfig(
|
||||
origins={"auth.zi.fi": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||
path = db_root / "users" / str(test_uuid) / "profile.webp"
|
||||
# The autouse avatar fixture redirects storage to tmp_path / "users"
|
||||
user_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||
path = tmp_path / "users" / str(user_uuid) / "profile.webp"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"RIFF1234WEBP")
|
||||
|
||||
@@ -646,8 +650,6 @@ class TestUserInfoEndpoint:
|
||||
monkeypatch,
|
||||
):
|
||||
"""User info should include the canonical avatar URL when present."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -676,8 +678,6 @@ class TestUserInfoEndpoint:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar route should honor If-None-Match for unchanged avatars."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -714,8 +714,6 @@ class TestOidcUserInfoEndpoint:
|
||||
monkeypatch,
|
||||
):
|
||||
"""OIDC userinfo should expose picture when profile scope is granted."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -776,6 +774,7 @@ class TestSetSessionEndpoint:
|
||||
authcode.CookieCode(
|
||||
session_key=session_token,
|
||||
created=datetime.now(UTC),
|
||||
rp_id="localhost",
|
||||
)
|
||||
)
|
||||
response = await client.post(
|
||||
|
||||
+228
-96
@@ -1,4 +1,10 @@
|
||||
"""Tests for the CLI entry point in paskia/__main__.py."""
|
||||
"""Tests for the CLI entry point in paskia/__main__.py.
|
||||
|
||||
The CLI is split into ``paskia init`` (create the combined paskia.kantadb
|
||||
with the initial domain(s)), ``paskia migrate`` (convert a legacy
|
||||
``<rp-id>.paskiadb`` database), and bare ``paskia`` (serve the stored
|
||||
domains; never migrates).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,141 +12,245 @@ import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.__main__ import main
|
||||
from paskia.__main__ import _load_stored_config, main
|
||||
from paskia.db import legacy
|
||||
from paskia.db.structs import DB, Config
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
from paskia.util.runtime import ServeConfig, clear_cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_run(monkeypatch):
|
||||
"""Run the CLI main() with the given args and return the RuntimeConfig."""
|
||||
def run_cli(monkeypatch, tmp_path):
|
||||
"""Run the CLI main() in a temporary working directory.
|
||||
|
||||
def _run(*args: str, db_root: str | None = None) -> Any:
|
||||
Returns a callable; server.run and the startup box are stubbed out.
|
||||
The returned dict records the server.run invocation (if any).
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
calls: dict = {}
|
||||
monkeypatch.setattr(
|
||||
"fastapi_vue.server.run",
|
||||
lambda app, **kw: calls.update({"app": app, **kw}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"paskia.util.startupbox.print_startup_config", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr("logging.basicConfig", lambda **kw: None)
|
||||
# Isolate environment mutations (PASKIA_CONFIG) from other tests
|
||||
env = os.environ.copy()
|
||||
if db_root is not None:
|
||||
env["PASKIA_DB"] = db_root
|
||||
env.pop("PASKIA_CONFIG", None)
|
||||
env.pop("PASKIA_VITE_URL", None)
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
|
||||
def _run(*args: str) -> dict:
|
||||
monkeypatch.setattr(sys, "argv", ["paskia", *args])
|
||||
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
"paskia.util.startupbox.print_startup_config", lambda _rt: None
|
||||
)
|
||||
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None)
|
||||
|
||||
clear_config_cache()
|
||||
clear_cache()
|
||||
try:
|
||||
main()
|
||||
runtime = runtime_config()
|
||||
clear_config_cache()
|
||||
return runtime
|
||||
finally:
|
||||
clear_cache()
|
||||
return calls
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
async def _write_config(db_path: Path, config: Config) -> None:
|
||||
"""Write a Config into a JSONL database file using Kanta.
|
||||
def stored_config(tmp_path: Path) -> Config:
|
||||
"""Read back the stored combined configuration."""
|
||||
return _load_stored_config(tmp_path / "paskia.kantadb")
|
||||
|
||||
The initial root uses a different rp_id so the stored diff includes the
|
||||
target rp_id (required because Config omits defaults when diffing).
|
||||
"""
|
||||
kanta = Kanta(
|
||||
str(db_path),
|
||||
DB(config=Config(rp_id="uninitialized.invalid")),
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = config.rp_id
|
||||
|
||||
def write_legacy_db(root: Path, config: legacy.LegacyConfig) -> Path:
|
||||
"""Create a legacy-format database directory <rp-id>.paskiadb/main.db."""
|
||||
src_dir = root / f"{config.rp_id}.paskiadb"
|
||||
src_dir.mkdir()
|
||||
db_file = src_dir / "main.db"
|
||||
|
||||
async def _write() -> None:
|
||||
kanta = Kanta(str(db_file), legacy.LegacyDB())
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:write_config"):
|
||||
with kanta.transaction("test:seed"):
|
||||
kanta.data.config = config
|
||||
await kanta.close()
|
||||
|
||||
|
||||
def write_config(db_path: Path, config: Config) -> None:
|
||||
"""Synchronous wrapper for _write_config."""
|
||||
asyncio.run(_write_config(db_path, config))
|
||||
asyncio.run(_write())
|
||||
return src_dir
|
||||
|
||||
|
||||
def test_cli_defaults(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
|
||||
def test_init_defaults(run_cli, tmp_path):
|
||||
run_cli("init")
|
||||
|
||||
assert runtime.config.rp_id == "localhost"
|
||||
assert runtime.config.rp_name is None
|
||||
assert runtime.config.auth_host is None
|
||||
assert runtime.config.origins is None
|
||||
assert runtime.site_url == "http://localhost:4401"
|
||||
assert runtime.site_path == "/auth/"
|
||||
assert runtime.save is False
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["localhost"]
|
||||
assert config.domains["localhost"].rp_name is None
|
||||
assert config.domains["localhost"].origins == {"**.localhost": True}
|
||||
assert config.listen is None
|
||||
|
||||
|
||||
def test_cli_explicit_options(cli_run):
|
||||
runtime = cli_run(
|
||||
"--rp-id",
|
||||
"example.com",
|
||||
"--rp-name",
|
||||
"Example Corp",
|
||||
"--auth-host",
|
||||
"auth.example.com",
|
||||
"--origin",
|
||||
"https://app.example.com",
|
||||
)
|
||||
def test_init_full_options(run_cli, tmp_path):
|
||||
run_cli("init", "example.com", "Example Corp", "--listen", "4402")
|
||||
|
||||
assert runtime.config.rp_id == "example.com"
|
||||
assert runtime.config.rp_name == "Example Corp"
|
||||
assert runtime.config.auth_host == "https://auth.example.com"
|
||||
assert runtime.config.origins == [
|
||||
"https://auth.example.com",
|
||||
"https://app.example.com",
|
||||
]
|
||||
assert runtime.site_url == "https://auth.example.com"
|
||||
assert runtime.site_path == "/"
|
||||
config = stored_config(tmp_path)
|
||||
domain = config.domains["example.com"]
|
||||
assert domain.rp_name == "Example Corp"
|
||||
assert domain.origins == {"**.example.com": True}
|
||||
assert config.listen == ["4402"]
|
||||
|
||||
|
||||
def test_cli_loads_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(
|
||||
db_path,
|
||||
Config(
|
||||
rp_id="example.com",
|
||||
rp_name="Stored Name",
|
||||
origins=["https://stored.example.com"],
|
||||
),
|
||||
)
|
||||
runtime = cli_run("--rp-id", "example.com", db_root=tmp)
|
||||
def test_init_adds_domains_to_existing_database(run_cli, tmp_path):
|
||||
"""Further rp-ids are added by repeating init; no comma separation."""
|
||||
run_cli("init", "company.com")
|
||||
run_cli("init", "app.com")
|
||||
run_cli("init", "pro.com", "Pro Corp")
|
||||
|
||||
assert runtime.config.rp_name == "Stored Name"
|
||||
assert runtime.config.origins == ["https://stored.example.com"]
|
||||
assert runtime.site_url == "https://stored.example.com"
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["company.com", "app.com", "pro.com"]
|
||||
assert config.domains["pro.com"].rp_name == "Pro Corp"
|
||||
|
||||
|
||||
def test_cli_overrides_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name"))
|
||||
runtime = cli_run(
|
||||
"--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp
|
||||
)
|
||||
|
||||
assert runtime.config.rp_name == "Overridden"
|
||||
def test_init_seeds_one_global_oidc_key(run_cli, tmp_path):
|
||||
"""OIDC is instance-global: init seeds a single signing key."""
|
||||
run_cli("init", "company.com")
|
||||
run_cli("init", "app.com")
|
||||
assert converted_oidc_key(tmp_path) is not None
|
||||
|
||||
|
||||
def test_cli_save_flag(cli_run):
|
||||
runtime = cli_run("--save")
|
||||
assert runtime.save is True
|
||||
def converted_oidc_key(tmp_path):
|
||||
async def _read():
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(tmp_path / "paskia.kantadb"), new_db)
|
||||
await kanta.open(readonly=True)
|
||||
try:
|
||||
return kanta.data.oidc.key
|
||||
finally:
|
||||
await kanta.close()
|
||||
|
||||
return asyncio.run(_read())
|
||||
|
||||
|
||||
def test_cli_invalid_auth_host(cli_run):
|
||||
def test_init_updates_rp_name_of_existing_domain(run_cli, tmp_path):
|
||||
run_cli("init", "example.com", "Old Name")
|
||||
run_cli("init", "example.com", "New Name")
|
||||
assert stored_config(tmp_path).domains["example.com"].rp_name == "New Name"
|
||||
|
||||
|
||||
def test_init_noop_on_existing_domain(run_cli):
|
||||
run_cli("init")
|
||||
with pytest.raises(SystemExit, match="already configured"):
|
||||
run_cli("init")
|
||||
|
||||
|
||||
def test_init_refuses_legacy_database(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
|
||||
with pytest.raises(SystemExit):
|
||||
cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org")
|
||||
run_cli("init")
|
||||
|
||||
|
||||
def test_init_rejects_removed_options(run_cli):
|
||||
"""Origins and auth hosts are admin-interface configuration, not init's."""
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli("init", "example.com", "--auth-host", "auth.example.com")
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli("init", "--origin", "https://app.example.com")
|
||||
|
||||
|
||||
def test_serve_requires_database(run_cli):
|
||||
with pytest.raises(SystemExit, match="paskia init"):
|
||||
run_cli()
|
||||
|
||||
|
||||
def test_serve_uses_stored_config(run_cli, tmp_path):
|
||||
run_cli("init", "example.com", "Stored Name")
|
||||
calls = run_cli()
|
||||
|
||||
assert calls["app"] == "paskia.fastapi.mainapp:app"
|
||||
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
|
||||
|
||||
|
||||
def test_serve_listen_override_not_persisted(run_cli, tmp_path):
|
||||
run_cli("init", "--listen", "4402")
|
||||
calls = run_cli("--listen", "4403")
|
||||
|
||||
assert calls["listen"] == ["4403"]
|
||||
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
|
||||
assert serve.listen == ["4403"]
|
||||
# Stored config keeps the original listen value
|
||||
assert stored_config(tmp_path).listen == ["4402"]
|
||||
|
||||
|
||||
def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
|
||||
with pytest.raises(SystemExit, match="paskia migrate"):
|
||||
run_cli()
|
||||
|
||||
|
||||
def test_migrate_converts_legacy_database(run_cli, tmp_path):
|
||||
src_dir = write_legacy_db(
|
||||
tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Legacy Name")
|
||||
)
|
||||
# Persisted user files move to the new data root
|
||||
avatar = src_dir / "users" / "019c6831-84cf-7b88-b66c-c8165890b7c5"
|
||||
avatar.mkdir(parents=True)
|
||||
(avatar / "profile.webp").write_bytes(b"RIFF1234WEBP")
|
||||
|
||||
run_cli("migrate")
|
||||
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["example.com"]
|
||||
assert config.domains["example.com"].rp_name == "Legacy Name"
|
||||
# Legacy directory renamed aside, user files moved over
|
||||
assert not src_dir.exists()
|
||||
assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir()
|
||||
assert (
|
||||
tmp_path
|
||||
/ "paskia.data"
|
||||
/ "users"
|
||||
/ "019c6831-84cf-7b88-b66c-c8165890b7c5"
|
||||
/ "profile.webp"
|
||||
).read_bytes() == b"RIFF1234WEBP"
|
||||
|
||||
|
||||
def test_migrate_multiple_legacy_databases_require_rp_id(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
|
||||
with pytest.raises(SystemExit, match="paskia migrate"):
|
||||
run_cli("migrate")
|
||||
|
||||
|
||||
def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
|
||||
|
||||
run_cli("migrate", "two.com")
|
||||
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["two.com"]
|
||||
# The other candidate is left in place
|
||||
assert (tmp_path / "one.com.paskiadb").is_dir()
|
||||
assert (tmp_path / "two.com.paskiadb.converted-bak").is_dir()
|
||||
|
||||
|
||||
def test_migrate_unknown_rp_id(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
|
||||
with pytest.raises(SystemExit, match="nope.com.paskiadb"):
|
||||
run_cli("migrate", "nope.com")
|
||||
|
||||
|
||||
def test_migrate_refuses_existing_database(run_cli):
|
||||
run_cli("init")
|
||||
with pytest.raises(SystemExit, match="already exists"):
|
||||
run_cli("migrate")
|
||||
|
||||
|
||||
def test_migrate_without_legacy_database(run_cli):
|
||||
with pytest.raises(SystemExit, match="No legacy"):
|
||||
run_cli("migrate")
|
||||
|
||||
|
||||
def test_cli_help():
|
||||
@@ -152,3 +262,25 @@ def test_cli_help():
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Paskia authentication server" in result.stdout
|
||||
|
||||
|
||||
def test_cli_init_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "paskia", "init", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Bootstrap" in result.stdout
|
||||
|
||||
|
||||
def test_cli_migrate_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "paskia", "migrate", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Convert" in result.stdout
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
"""Tests for the multi-domain machinery: registry resolution, config
|
||||
validation, ASGI dispatch, domain binding of auth codes, legacy database
|
||||
conversion, log censoring and bootstrap caveats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia import authcode, domains
|
||||
from paskia.bootstrap import check_admin_credentials
|
||||
from paskia.db import create_credential
|
||||
from paskia.db.legacy import (
|
||||
LegacyConfig,
|
||||
LegacyCredential,
|
||||
LegacyDB,
|
||||
LegacySession,
|
||||
convert_legacy_database,
|
||||
)
|
||||
from paskia.db.lifecycle import format_log_uuid
|
||||
from paskia.db.operations import DB
|
||||
from paskia.db.structs import (
|
||||
OIDC,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
OriginEntry,
|
||||
Session,
|
||||
)
|
||||
from paskia.fastapi.dispatch import DispatchMiddleware
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Registry construction helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_registry(configs: dict[str, DomainConfig]) -> domains.DomainRegistry:
|
||||
"""Build and install a registry from domain configs (listen unset)."""
|
||||
domains.configure(listen=None)
|
||||
return domains.init_registry(Config(domains=configs))
|
||||
|
||||
|
||||
ROR_CONFIG = Config(
|
||||
domains={
|
||||
"company.com": DomainConfig(
|
||||
rp_name="Company",
|
||||
origins={
|
||||
"auth.company.com": OriginEntry(auth_host=True),
|
||||
"app.com": True, # related origin (outside the rp-id domain)
|
||||
},
|
||||
),
|
||||
"pro.com": DomainConfig(rp_name="Pro", origins={"**.pro.com": True}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StubApp:
|
||||
"""ASGI app recording the scope it was called with."""
|
||||
|
||||
def __init__(self):
|
||||
self.scope = None
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
self.scope = scope
|
||||
|
||||
|
||||
async def drive_ws(middleware: DispatchMiddleware, headers: list[tuple[bytes, bytes]]):
|
||||
"""Run a websocket scope through the middleware, capturing sent messages."""
|
||||
|
||||
async def receive():
|
||||
return {"type": "websocket.connect"}
|
||||
|
||||
sent = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
stub = middleware.app
|
||||
await middleware(
|
||||
{"type": "websocket", "headers": headers, "path": "/"}, receive, send
|
||||
)
|
||||
return stub, sent
|
||||
|
||||
|
||||
async def drive_http(
|
||||
middleware: DispatchMiddleware, headers: list[tuple[bytes, bytes]]
|
||||
):
|
||||
"""Run an http scope through the middleware, capturing sent messages."""
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
sent = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
stub = middleware.app
|
||||
await middleware(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": headers,
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"query_string": b"",
|
||||
},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
return stub, sent
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Host resolution
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_exact_rp_id(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("pro.com").rp_id == "pro.com"
|
||||
assert reg.resolve("company.com").rp_id == "company.com"
|
||||
|
||||
def test_auth_host_and_related_origin(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("auth.company.com").rp_id == "company.com"
|
||||
assert reg.resolve("app.com").rp_id == "company.com"
|
||||
|
||||
def test_subdomain_suffix_longest_match(self):
|
||||
reg = build_registry(
|
||||
{"example.com": DomainConfig(), "sub.example.com": DomainConfig()}
|
||||
)
|
||||
assert reg.resolve("www.example.com").rp_id == "example.com"
|
||||
assert reg.resolve("api.sub.example.com").rp_id == "sub.example.com"
|
||||
|
||||
def test_port_and_trailing_dot_normalized(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("pro.com:8443").rp_id == "pro.com"
|
||||
assert reg.resolve("app.com.").rp_id == "company.com"
|
||||
|
||||
def test_unknown_host(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("evil.com") is None
|
||||
assert reg.resolve("") is None
|
||||
assert reg.resolve(None) is None
|
||||
|
||||
def test_auth_host_is_per_domain_no_fallback(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.get("company.com").own_auth_host == "auth.company.com"
|
||||
# pro.com has no own auth host and there is no cross-domain fallback
|
||||
assert reg.get("pro.com").own_auth_host is None
|
||||
|
||||
def test_shared_auth_host_resolves_best_suffix(self):
|
||||
"""Domains may share an auth host (nested rp-ids); the longest
|
||||
rp-id suffix match wins, first configured as tiebreak."""
|
||||
reg = build_registry(
|
||||
{
|
||||
"com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"company.com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
}
|
||||
)
|
||||
assert reg.resolve("auth.company.com").rp_id == "company.com"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Cross-domain configuration validation
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
def test_valid(self):
|
||||
domains.validate_config(ROR_CONFIG)
|
||||
|
||||
def test_empty_origins_table_is_valid(self):
|
||||
"""No origins at all: nothing of the domain is allowed, but the
|
||||
configuration itself is legal (e.g. a related-only domain)."""
|
||||
domains.validate_config(Config(domains={"a.com": DomainConfig()}))
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"b.com": True})})
|
||||
)
|
||||
|
||||
def test_related_origin_cap(self):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"company.com": DomainConfig(
|
||||
origins={f"app{i}.com": True for i in range(5)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="related origins"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"company.com": DomainConfig(
|
||||
origins={f"app{i}.com": True for i in range(6)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_star_origin_rejected(self):
|
||||
"""Plain '*' suggests 'anything goes' — the wildcard must be
|
||||
explicit and under the rp-id."""
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*": True})})
|
||||
)
|
||||
|
||||
def test_malformed_origin_hostname_rejected(self):
|
||||
"""No empty hostname labels — leading, trailing and double dots
|
||||
are invalid, in concrete entries and wildcard bases alike."""
|
||||
for key in (".a.com", "a..com", "a.com.", "http://.a.com:8080"):
|
||||
with pytest.raises(ValueError, match="Invalid origin"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={key: True})})
|
||||
)
|
||||
for key in ("*..a.com", "**..a.com"):
|
||||
with pytest.raises(ValueError, match="Invalid wildcard origin"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={key: True})})
|
||||
)
|
||||
|
||||
def test_subdomain_entry_is_in_domain(self):
|
||||
"""An entry within the rp-id domain is an ordinary in-domain
|
||||
sign-in site, never a related origin."""
|
||||
config = Config(domains={"a.com": DomainConfig(origins={"app.a.com": True})})
|
||||
domains.validate_config(config)
|
||||
reg = build_registry(config.domains)
|
||||
assert reg.get("a.com").related_origins == []
|
||||
|
||||
def test_wildcard_outside_rp_id_rejected(self):
|
||||
"""Related origins are individual hosts; wildcards must stay within
|
||||
the rp-id domain. Both wildcard forms are accepted in-domain."""
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})})
|
||||
)
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"**.a.com": True})})
|
||||
)
|
||||
with pytest.raises(ValueError, match="wildcard outside the rp-id"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"**.b.com": True})})
|
||||
)
|
||||
|
||||
def test_wildcard_auth_host_rejected(self):
|
||||
with pytest.raises(ValueError, match="cannot be the auth host"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"*.a.com": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_related_auth_host_rejected(self):
|
||||
"""The auth host is always in-domain; a related origin cannot
|
||||
carry the mark."""
|
||||
with pytest.raises(ValueError, match="cannot be the auth host"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.b.com": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_several_auth_hosts_rejected(self):
|
||||
with pytest.raises(ValueError, match="several origins as the auth host"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={
|
||||
"auth.a.com": OriginEntry(auth_host=True),
|
||||
"login.a.com": OriginEntry(auth_host=True),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_auth_host_collision(self):
|
||||
with pytest.raises(ValueError, match="collides with a related origin"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.a.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"b.com": DomainConfig(origins={"auth.a.com": True}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_related_origin_may_fall_inside_other_domain(self):
|
||||
"""A related origin at/inside another domain's rp-id is allowed.
|
||||
The related listing wins dispatch over suffix matching (a host that
|
||||
*is* a configured rp-id always serves its own domain)."""
|
||||
config = Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"b.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
domains.validate_config(config)
|
||||
reg = build_registry(config.domains)
|
||||
assert reg.resolve("app.b.com").rp_id == "a.com"
|
||||
assert reg.resolve("b.com").rp_id == "b.com"
|
||||
|
||||
def test_related_origin_shared_when_covered_by_rp_id(self):
|
||||
"""Two domains may list the same related host when it falls inside
|
||||
a configured rp-id; otherwise the collision is rejected."""
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"c.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"b.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="configured for both"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"shared.com": True}),
|
||||
"c.com": DomainConfig(origins={"shared.com": True}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_auth_host_must_not_collide_with_rp_id(self):
|
||||
with pytest.raises(ValueError, match="collides with an rp-id"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"b.a.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"b.a.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Best-effort serving: stored config sanitization
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanitizeConfig:
|
||||
"""Serving never fails on stored config problems; it degrades + warns."""
|
||||
|
||||
def test_cross_domain_origin_stays_as_related(self):
|
||||
"""An out-of-domain entry simply IS a related origin — no repair
|
||||
needed, no warning."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"localhost": DomainConfig(origins={"example.com": True})})
|
||||
)
|
||||
assert config.domains["localhost"].origins == {"example.com": True}
|
||||
assert not warnings
|
||||
domains.validate_config(config) # sanitized config is strict-clean
|
||||
|
||||
def test_star_origin_rewritten_explicit(self):
|
||||
"""Branch-era '*' shorthand is rewritten to '**.{rp-id}'; an auth
|
||||
mark on it is cleared."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*": True})})
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"**.a.com": True}
|
||||
assert any("**." in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"*": OriginEntry(auth_host=True)})
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"**.a.com": True}
|
||||
assert any("mark cleared" in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
def test_malformed_origin_dropped(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"https://": True})})
|
||||
)
|
||||
assert config.domains["a.com"].origins == {}
|
||||
assert warnings
|
||||
|
||||
def test_malformed_hostname_dropped(self):
|
||||
"""Empty hostname labels (leading/trailing/double dots) are dropped,
|
||||
from concrete entries and wildcard bases alike."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={
|
||||
".a.com": True,
|
||||
"a.com.": True,
|
||||
"**.a..com": True,
|
||||
"ok.a.com": True,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
assert list(config.domains["a.com"].origins) == ["ok.a.com"]
|
||||
assert len(warnings) == 3
|
||||
domains.validate_config(config) # sanitized config is strict-clean
|
||||
|
||||
def test_invalid_rp_id_domain_dropped(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"not a domain!": DomainConfig(), "ok.com": DomainConfig()})
|
||||
)
|
||||
assert list(config.domains) == ["ok.com"]
|
||||
assert any("dropped" in w for w in warnings)
|
||||
|
||||
def test_wildcard_outside_rp_id_dropped(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})})
|
||||
)
|
||||
assert config.domains["a.com"].origins == {}
|
||||
assert any("wildcard" in w for w in warnings)
|
||||
domains.validate_config(config) # sanitized config is strict-clean
|
||||
|
||||
def test_cap_exceeded_truncated(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={f"app{i}.com": True for i in range(6)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
assert len(config.domains["a.com"].origins) == 5
|
||||
assert any("maximum" in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
def test_related_auth_host_mark_cleared(self):
|
||||
"""An auth mark on a related (out-of-domain) origin is cleared."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.b.com": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"auth.b.com": True}
|
||||
assert any("cannot be the auth host" in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
def test_auth_host_colliding_with_rp_id_cleared(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.a.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"auth.a.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"auth.a.com": True}
|
||||
assert any("collides" in w for w in warnings)
|
||||
|
||||
def test_related_inside_other_domain_kept(self):
|
||||
"""A related origin falling inside another domain's rp-id is kept."""
|
||||
config, _ = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"b.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"app.b.com": True}
|
||||
|
||||
def test_related_claimed_twice_first_wins(self):
|
||||
"""Two non-owner domains claiming one related host: first wins."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"shared.com": True}),
|
||||
"b.com": DomainConfig(origins={"shared.com": True}),
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"shared.com": True}
|
||||
assert config.domains["b.com"].origins == {}
|
||||
assert any("first domain wins" in w for w in warnings)
|
||||
|
||||
def test_no_domains_is_fatal(self):
|
||||
with pytest.raises(ValueError, match="No servable domain"):
|
||||
domains.sanitize_config(Config(domains={}))
|
||||
with pytest.raises(ValueError, match="No servable domain"):
|
||||
domains.sanitize_config(Config(domains={"not a domain!": DomainConfig()}))
|
||||
|
||||
def test_build_serves_related_origin(self):
|
||||
reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})})
|
||||
domain = reg.get("localhost")
|
||||
assert domain.related_origins == ["https://example.com"]
|
||||
domain.passkey.validate_origin("https://example.com")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Origin validation semantics (Passkey)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOriginValidation:
|
||||
"""The allow-list is explicit: empty allows nothing, wildcards cover
|
||||
subtrees, related origins are additive exact matches."""
|
||||
|
||||
def test_empty_allow_list_denies_all(self):
|
||||
p = Passkey(rp_id="example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://app.example.com")
|
||||
|
||||
def test_allow_list_restricts_subtree(self):
|
||||
p = Passkey(rp_id="example.com", origins=["https://app.example.com"])
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://www.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com")
|
||||
|
||||
def test_related_origins_are_additive(self):
|
||||
p = Passkey(rp_id="example.com", related_origins=["https://app2.com"])
|
||||
assert p.validate_origin("https://app2.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://app.example.com") # nothing in-domain listed
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://other.com")
|
||||
|
||||
def test_double_star_matches_apex_and_any_depth(self):
|
||||
"""'**.example.com' covers the apex and subdomains at any depth
|
||||
(the shell-glob convention)."""
|
||||
p = Passkey(rp_id="example.com", origins=["**.example.com"])
|
||||
assert p.validate_origin("https://example.com") # apex
|
||||
assert p.validate_origin("https://app.example.com") # one level
|
||||
assert p.validate_origin("https://a.b.c.example.com") # any depth
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://anotherexample.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://other.com")
|
||||
|
||||
def test_single_star_matches_exactly_one_level(self):
|
||||
"""'*.example.com' covers exactly one subdomain level — neither the
|
||||
apex nor deeper levels."""
|
||||
p = Passkey(rp_id="example.com", origins=["*.example.com"])
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com") # apex excluded
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://a.b.example.com") # too deep
|
||||
|
||||
def test_wildcard_is_https_only(self):
|
||||
"""A '**.example.com' entry does not fall back to other schemes."""
|
||||
p = Passkey(rp_id="example.com", origins=["**.example.com"])
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("http://example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("http://app.example.com:8080")
|
||||
|
||||
def test_star_entry_rejected(self):
|
||||
with pytest.raises(ValueError, match="Invalid origin"):
|
||||
Passkey(rp_id="example.com", origins=["*"])
|
||||
|
||||
def test_malformed_hostname_rejected(self):
|
||||
"""Leading/trailing/double dots are invalid in any entry form."""
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", origins=["https://.example.com"])
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", origins=["**.a..example.com"])
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", related_origins=["https://other..com"])
|
||||
|
||||
def test_localhost_wildcard_matches_any_scheme_and_port(self):
|
||||
"""Under localhost, wildcards match any scheme and any port."""
|
||||
p = Passkey(rp_id="localhost", origins=["**.localhost"])
|
||||
assert p.validate_origin("http://localhost:8080")
|
||||
assert p.validate_origin("http://app.localhost:3000")
|
||||
assert p.validate_origin("http://a.b.localhost:3000")
|
||||
assert p.validate_origin("https://localhost")
|
||||
|
||||
def test_exact_entry_matches_scheme_and_port(self):
|
||||
p = Passkey(rp_id="localhost", origins=["http://localhost:4403"])
|
||||
assert p.validate_origin("http://localhost:4403")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://localhost:4403")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("http://localhost:4404")
|
||||
|
||||
def test_sub_wildcard_matches_only_its_subtree(self):
|
||||
p = Passkey(rp_id="example.com", origins=["**.app.example.com"])
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
assert p.validate_origin("https://www.app.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://other.example.com")
|
||||
|
||||
def test_wildcard_related_origin_rejected(self):
|
||||
with pytest.raises(ValueError, match="wildcard"):
|
||||
Passkey(rp_id="example.com", related_origins=["*.other.com"])
|
||||
|
||||
def test_related_origins_combined_with_allow_list(self):
|
||||
p = Passkey(
|
||||
rp_id="example.com",
|
||||
origins=["https://app.example.com"],
|
||||
related_origins=["https://app2.com"],
|
||||
)
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
assert p.validate_origin("https://app2.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://www.example.com")
|
||||
|
||||
def test_constructor_rejects_mixed_up_fields(self):
|
||||
with pytest.raises(ValueError, match="related origin"):
|
||||
Passkey(rp_id="example.com", origins=["https://app2.com"])
|
||||
with pytest.raises(ValueError, match="within the rp-id domain"):
|
||||
Passkey(rp_id="example.com", related_origins=["https://app.example.com"])
|
||||
|
||||
def test_domain_wires_both_lists(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
p = reg.get("company.com").passkey
|
||||
assert p.validate_origin("https://app.com") # related origin
|
||||
assert p.validate_origin("https://auth.company.com") # allow-listed
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://www.company.com") # not allow-listed
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ASGI dispatch
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatchMiddleware:
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_unknown_host_421(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, sent = await drive_http(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"evil.com")]
|
||||
)
|
||||
assert stub.scope is None # Inner app not called
|
||||
assert sent[0]["type"] == "http.response.start"
|
||||
assert sent[0]["status"] == 421
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_dispatches_domain(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, _sent = await drive_http(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"app.com.")]
|
||||
)
|
||||
assert stub.scope is not None
|
||||
assert stub.scope["state"]["domain"].rp_id == "company.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_current_domain_set_inside_request(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
seen = {}
|
||||
|
||||
async def app(scope, receive, send):
|
||||
seen["domain"] = domains.current_domain()
|
||||
|
||||
await drive_http(DispatchMiddleware(app), [(b"host", b"pro.com")])
|
||||
assert seen["domain"].rp_id == "pro.com"
|
||||
# Contextvar is reset after the request; with several domains there
|
||||
# is no implicit current domain outside a request context.
|
||||
with pytest.raises(RuntimeError, match="request context"):
|
||||
domains.current_domain()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_unknown_host_closed(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"evil.com")]
|
||||
)
|
||||
assert stub.scope is None
|
||||
assert sent == [{"type": "websocket.close", "code": 1008}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_same_domain_origin(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"auth.company.com"), (b"origin", b"https://app.com")],
|
||||
)
|
||||
assert sent == []
|
||||
assert stub.scope["state"]["domain"].rp_id == "company.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_cross_domain_requires_origin_own_auth_host(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
# pro.com has no own auth host: its page may only connect to pro.com
|
||||
# hosts — the company.com auth host does not serve foreign domains
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"auth.company.com"), (b"origin", b"https://pro.com")],
|
||||
)
|
||||
assert stub.scope is None
|
||||
assert sent == [{"type": "websocket.close", "code": 1008}]
|
||||
|
||||
# pro.com page connecting to some other host: closed pre-accept
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"company.com"), (b"origin", b"https://pro.com")],
|
||||
)
|
||||
assert stub.scope is None
|
||||
assert sent == [{"type": "websocket.close", "code": 1008}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_cross_domain_via_own_auth_host(self):
|
||||
"""On a shared auth host (nested rp-ids), the WS Origin selects the
|
||||
domain: plain HTTP resolves to the longest-suffix claimant, but a
|
||||
WebSocket from another claimant's page is dispatched by Origin."""
|
||||
build_registry(
|
||||
{
|
||||
"com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"company.com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
}
|
||||
)
|
||||
# Host alone resolves to company.com (longest suffix)
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"auth.company.com")]
|
||||
)
|
||||
assert stub.scope["state"]["domain"].rp_id == "company.com"
|
||||
# A page on com (the other claimant) is accepted: the Host is its
|
||||
# own auth host, and the Origin selects its domain
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"auth.company.com"), (b"origin", b"https://com")],
|
||||
)
|
||||
assert sent == []
|
||||
assert stub.scope["state"]["domain"].rp_id == "com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_unknown_origin_uses_host_domain(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
# Missing origin
|
||||
stub, _ = await drive_ws(DispatchMiddleware(StubApp()), [(b"host", b"pro.com")])
|
||||
assert stub.scope["state"]["domain"].rp_id == "pro.com"
|
||||
# Unknown origin: host domain applies (endpoint-side validation decides)
|
||||
stub, _ = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"pro.com"), (b"origin", b"https://evil.com")],
|
||||
)
|
||||
assert stub.scope["state"]["domain"].rp_id == "pro.com"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Domain binding of auth codes
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthCodeDomainBinding:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cookie_code_rejected_on_other_domain(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
code = authcode.store_cookie(
|
||||
authcode.CookieCode(
|
||||
session_key=session_token,
|
||||
created=datetime.now(UTC),
|
||||
rp_id="other.com",
|
||||
)
|
||||
)
|
||||
response = await client.post(
|
||||
"/auth/api/set-session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {code}",
|
||||
"Host": "localhost:4401",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oidc_code_is_host_independent(
|
||||
self, client: httpx.AsyncClient, test_db: DB, test_user, test_credential
|
||||
):
|
||||
"""OIDC codes carry no domain binding: the provider is
|
||||
instance-global, so a code is redeemable at any host."""
|
||||
oidc_client, secret = Client.create(
|
||||
name="Test Client",
|
||||
redirect_uris=["https://client.example/callback"],
|
||||
client_secret="topsecret",
|
||||
)
|
||||
token = "doesnotmatter1234"
|
||||
session = Session.create(
|
||||
user=test_user.uuid,
|
||||
credential=test_credential.uuid,
|
||||
key=hash_secret("oidc", token),
|
||||
host="other.com",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
validated=datetime.now(UTC),
|
||||
client=oidc_client.uuid,
|
||||
rp_id="other.com",
|
||||
issuer="https://other.com",
|
||||
)
|
||||
store = test_db._store
|
||||
with store.transaction("seed_oidc_session"):
|
||||
test_db.oidc.clients[oidc_client.uuid] = oidc_client
|
||||
test_db.sessions[session.key] = session
|
||||
|
||||
code = authcode.store_oidc(
|
||||
authcode.OIDCCode(
|
||||
session_key=token,
|
||||
created=datetime.now(UTC),
|
||||
redirect_uri="https://client.example/callback",
|
||||
scope="openid",
|
||||
)
|
||||
)
|
||||
response = await client.post(
|
||||
"/auth/oidc/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": "https://client.example/callback",
|
||||
"client_id": str(oidc_client.uuid),
|
||||
"client_secret": secret,
|
||||
},
|
||||
headers={"Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Legacy database conversion
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_db(path) -> DB:
|
||||
async def _read() -> DB:
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(path), new_db)
|
||||
await kanta.open(readonly=True)
|
||||
return kanta.data
|
||||
|
||||
return asyncio.run(_read())
|
||||
|
||||
|
||||
async def _write_legacy(src_file, config: LegacyConfig) -> None:
|
||||
kanta = Kanta(str(src_file), LegacyDB())
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:seed"):
|
||||
kanta.data.config = config
|
||||
await kanta.close()
|
||||
|
||||
|
||||
class TestLegacyConversion:
|
||||
def test_convert_stamps_domain_everywhere(self, tmp_path):
|
||||
src = tmp_path / "example.com.paskiadb"
|
||||
src.mkdir()
|
||||
src_file = src / "main.db"
|
||||
|
||||
cred_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||
user_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c6")
|
||||
|
||||
async def _write() -> None:
|
||||
kanta = Kanta(str(src_file), LegacyDB())
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:seed"):
|
||||
kanta.data.config = LegacyConfig(
|
||||
rp_id="example.com",
|
||||
rp_name="Example",
|
||||
origins=["https://app.example.com", "*.example.com"],
|
||||
)
|
||||
kanta.data.credentials[cred_uuid] = LegacyCredential(
|
||||
credential_id=b"credential-id",
|
||||
user_uuid=user_uuid,
|
||||
aaguid=UUID(int=0),
|
||||
public_key=b"public-key",
|
||||
sign_count=3,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
kanta.data.sessions["session-key"] = LegacySession(
|
||||
user_uuid=user_uuid,
|
||||
credential_uuid=cred_uuid,
|
||||
host="example.com",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
validated=datetime.now(UTC),
|
||||
)
|
||||
kanta.data.oidc = OIDC(key=b"legacy-signing-key")
|
||||
await kanta.close()
|
||||
|
||||
asyncio.run(_write())
|
||||
|
||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||
domain = config.domains["example.com"]
|
||||
assert domain.rp_name == "Example"
|
||||
# Legacy wildcard origins convert as-is (https-only outside localhost)
|
||||
assert domain.origins == {"app.example.com": True, "*.example.com": True}
|
||||
|
||||
converted = _read_db(tmp_path / "paskia.kantadb")
|
||||
assert converted.credentials[cred_uuid].rp_id == "example.com"
|
||||
assert converted.sessions["session-key"].rp_id == "example.com"
|
||||
# The legacy OIDC provider carries over as the instance-global one
|
||||
assert converted.oidc.key == b"legacy-signing-key"
|
||||
|
||||
def test_convert_empty_origins_seeds_wildcard(self, tmp_path):
|
||||
"""Legacy 'no origins' meant the whole rp-id domain; the new format
|
||||
makes that explicit as '**.{rp-id}'."""
|
||||
src_file = tmp_path / "main.db"
|
||||
asyncio.run(
|
||||
_write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex"))
|
||||
)
|
||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||
assert config.domains["example.com"].origins == {"**.example.com": True}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Transaction log censoring
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLogCensoring:
|
||||
def test_oidc_key_values_hidden(self):
|
||||
assert format_log_uuid(b"raw-key-material", "oidc.key") == "<hidden>"
|
||||
|
||||
def test_oidc_key_path_component_visible(self):
|
||||
# The path component itself must stay visible ("oidc.key = <hidden>")
|
||||
assert format_log_uuid("key", "oidc.key") is None
|
||||
|
||||
def test_other_paths_unaffected(self):
|
||||
assert format_log_uuid("not-a-uuid", "oidc.clients") is None
|
||||
assert format_log_uuid("not-a-uuid", "config.domains") is None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Bootstrap caveat: admin credential is checked on the configured domains
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBootstrapCaveat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_without_credentials_gets_link(
|
||||
self, test_db: DB, domain_registry
|
||||
):
|
||||
assert await check_admin_credentials() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_with_domain_credential_ok(
|
||||
self, test_db: DB, domain_registry, test_user, test_credential
|
||||
):
|
||||
assert await check_admin_credentials() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_with_only_unconfigured_domain_credential_gets_link(
|
||||
self, test_db: DB, domain_registry, test_user
|
||||
):
|
||||
"""A passkey under an rp-id outside the config does not satisfy the check."""
|
||||
cred = Credential.create(
|
||||
credential_id=os.urandom(32),
|
||||
user=test_user.uuid,
|
||||
aaguid=UUID(int=0),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id="example.com",
|
||||
)
|
||||
create_credential(cred)
|
||||
assert await check_admin_credentials() is True
|
||||
@@ -15,7 +15,6 @@ from urllib.parse import urlsplit
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia.db.paths import db_file_path, users_root_path
|
||||
from tests.conftest import auth_headers, create_test_image_bytes
|
||||
|
||||
|
||||
@@ -93,8 +92,6 @@ class TestUserAvatar:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Uploading a WebP avatar should store and expose the canonical URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload_bytes = create_test_image_bytes()
|
||||
|
||||
response = await client.put(
|
||||
@@ -138,8 +135,6 @@ class TestUserAvatar:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar uploads must already be browser-prepared WebP."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={
|
||||
@@ -165,8 +160,6 @@ class TestUserAvatar:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Deleting avatar should clear the user avatar URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -203,30 +196,6 @@ class TestUserAvatar:
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch):
|
||||
legacy_path = tmp_path / "legacy.paskiadb"
|
||||
legacy_bytes = b'{"v":0}\n'
|
||||
legacy_path.write_bytes(legacy_bytes)
|
||||
|
||||
monkeypatch.setenv("PASKIA_DB", str(legacy_path))
|
||||
|
||||
db_path = db_file_path(create_root=True)
|
||||
|
||||
assert legacy_path.is_dir()
|
||||
assert db_path == legacy_path / "main.db"
|
||||
assert db_path.read_bytes() == legacy_bytes
|
||||
|
||||
|
||||
def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch):
|
||||
root_path = tmp_path / "instance-root"
|
||||
monkeypatch.setenv("PASKIA_DB", str(root_path))
|
||||
|
||||
users_path = users_root_path(create_root=True)
|
||||
|
||||
assert users_path == root_path / "users"
|
||||
assert users_path.parent == root_path
|
||||
|
||||
|
||||
class TestUserLogoutAll:
|
||||
"""Tests for POST /auth/api/user/logout-all"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user