From 10af29f92dd32b170e2d76b08cd1787a5a76e3ea Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 22:02:06 +0000 Subject: [PATCH] MultiSite: one instance serves authentication across many domains (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 buildhook.py} | 10 +- scripts/fastapi-vue/buildutil.py | 156 ++- scripts/fastapi-vue/devutil.py | 114 +- tests/conftest.py | 88 +- tests/test_admin.py | 356 ++++++- tests/test_api.py | 133 ++- tests/test_cli.py | 336 ++++-- tests/test_domains.py | 995 ++++++++++++++++++ tests/test_user.py | 31 - 91 files changed, 5755 insertions(+), 1867 deletions(-) create mode 100644 docs/MultiSite.md create mode 100644 e2e/tests/50-multidomain.spec.ts create mode 100644 e2e/tests/fixtures/remote-auth.ts create mode 100644 paskia/db/legacy.py delete mode 100644 paskia/db/migrations.py create mode 100644 paskia/domains.py create mode 100644 paskia/fastapi/admin/domains.py delete mode 100644 paskia/fastapi/admin/server_config.py create mode 100644 paskia/fastapi/dispatch.py delete mode 100644 paskia/globals.py rename scripts/fastapi-vue/{build-frontend.py => buildhook.py} (50%) create mode 100644 tests/test_domains.py diff --git a/.gitignore b/.gitignore index 1afc8db..63adca1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist/ package-lock.json paskia.sqlite *.paskiadb +*.converted-bak +*.kantadb *.data /paskia/frontend-build /paskia/_version.py diff --git a/README.md b/README.md index c0f0667..d159c50 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/caddy/Caddyfile.dev b/caddy/Caddyfile.dev index 2c87a6f..ca940ca 100644 --- a/caddy/Caddyfile.dev +++ b/caddy/Caddyfile.dev @@ -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 } diff --git a/caddy/auth/require b/caddy/auth/require index 657ed3d..98f6786 100644 --- a/caddy/auth/require +++ b/caddy/auth/require @@ -2,11 +2,13 @@ # Argument is mandatory and provides a query string to /auth/api/forward # "" means just authentication # perm=yourservice:login to require specific permission +# public=1 to allow public access (backend must check Remote-Public) forward_auth {$AUTH_UPSTREAM:localhost:4401} { uri /auth/api/forward?{args[0]} header_up Connection keep-alive # Much higher performance header_up -Upgrade # Disable Upgrade: WebSocket copy_headers { + Remote-Public Remote-User Remote-Name Remote-Groups diff --git a/caddy/auth/setup b/caddy/auth/setup index 7e15c6c..f5bb22a 100644 --- a/caddy/auth/setup +++ b/caddy/auth/setup @@ -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} +} diff --git a/docs/API.md b/docs/API.md index cb3fa14..5686514 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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). diff --git a/docs/Headers.md b/docs/Headers.md index 6ff75eb..6c669b0 100644 --- a/docs/Headers.md +++ b/docs/Headers.md @@ -13,6 +13,17 @@ | Remote-Groups | Permissions the user has, comma separated | **auth:admin,yourapp:reports** | | Remote-Session-Expires | Session expiry timestamp (ISO 8601 UTC) | **2030-12-31T23:59:59Z** | | Remote-Credential | Credential UUID | Identifier for the sign-in passkey (string) | +| Remote-Public | Public-access marker, only present on routes using [`public=1`](api/forward.md#public-access) | **authenticated**, **forbidden** or **anonymous** | + +### Public access + +On routes configured with `public=1`, every forwarded request carries `Remote-Public` and the backend must check it before treating the request as authorized: + +- `authenticated` — the user has everything the route asked for; full `Remote-*` headers. +- `forbidden` — the user is logged in but the route's `perm` check failed. Full identity headers are sent, including `Remote-Groups` — it is trustworthy, it just lacks the requested permission. +- `anonymous` — no valid session; no identity headers are sent. + +Without `public=1` the header is absent and every request reaching the backend is fully authorized. Similar headers are also used by other authentication systems like [Authelia](https://www.authelia.com/integration/trusted-header-sso/introduction/) to signal the backend application information about the signed in user. diff --git a/docs/Integration.md b/docs/Integration.md index 65c0c22..b531554 100644 --- a/docs/Integration.md +++ b/docs/Integration.md @@ -132,6 +132,27 @@ Be sure to REMOVE connection hop-by-hop headers (these will break WebSockets amo "Connection", "Keep-Alive", "Proxy-Connection", "TE", "Transfer-Encoding", "Upgrade" ``` +## Public access + +For apps where authentication is optional, configure the proxy route with `public=1` (see your [proxy guide](proxy/index.md)). The auth check then always lets the request through, and your backend branches on the `Remote-Public` header: + +- `anonymous` — no valid session; no `Remote-*` identity headers are present. +- `forbidden` — the user is logged in (identity headers are present and trustworthy) but the route's `perm` was not granted. +- `authenticated` — session valid and all requested permissions met. + +```python +# Example: Python/FastAPI +@app.get("/api/reports") +def reports(request: Request): + public = request.headers.get("Remote-Public") + if public != "authenticated": + raise HTTPException(401) # or serve a limited public view + user_id = request.headers.get("Remote-User") + # ... +``` + +Login-on-demand still works unchanged: any 401 your app itself returns for privileged operations carries the `auth.iframe` URL that the [paskia](https://www.npmjs.com/package/paskia) module handles automatically (see [API Fetch with Automatic Auth](#api-fetch-with-automatic-auth)). A `max_age` reauth requirement on the route still returns the 401 auth flow directly from the proxy. See [public access](api/forward.md#public-access) and [Headers](Headers.md#public-access). + ## Proxying /auth/ to Paskia Your app server needs to proxy `/auth/` paths to Paskia. This can be done by your application but is much easier done by a reverse proxy. The [Forward-Auth Proxy Guides](proxy/index.md) cover Caddy, Nginx, Traefik, Apache APISIX, Envoy and HAProxy. diff --git a/docs/MultiSite.md b/docs/MultiSite.md new file mode 100644 index 0000000..84d52f5 --- /dev/null +++ b/docs/MultiSite.md @@ -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:///.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:///.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. diff --git a/docs/api/forward.md b/docs/api/forward.md index f4ddf07..c89da94 100644 --- a/docs/api/forward.md +++ b/docs/api/forward.md @@ -15,6 +15,19 @@ See [Forward-Auth Proxy Guides](../proxy/index.md) for Caddy, Nginx, Traefik, Ap |-----------|-------------| | perm | Required permissions. See the [perm argument](perm.md). | | max_age | Require recent passkey use. See the [max_age argument](max-age.md). | +| public | `public=1` allows public access: instead of 401 (no/expired session) or 403 (permission denied), the request passes with a `Remote-Public` header marking the bypass. Reauth (`max_age`) still requires the auth flow. | + +## Public access + +With `public=1` the endpoint returns 204 in every case except reauth and malformed arguments, and always sets `Remote-Public`: + +| Value | Meaning | Identity headers | +|---|---|---| +| `authenticated` | Session valid, all requested permissions met | Full `Remote-*` set | +| `forbidden` | Session valid, but the `perm` check failed | Full `Remote-*` set (including `Remote-Groups` — it is trustworthy, it just lacks the requested permission) | +| `anonymous` | No valid session | None | + +The backend must check `Remote-Public` before treating the request as authorized. See [Trusted Headers](../Headers.md) and the "Public access" section in the [proxy guides](../proxy/index.md). ## Request headers diff --git a/docs/proxy/apisix.md b/docs/proxy/apisix.md index e1e7b9d..aa4b4d0 100644 --- a/docs/proxy/apisix.md +++ b/docs/proxy/apisix.md @@ -54,7 +54,8 @@ curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \ "Remote-Role", "Remote-Role-Name", "Remote-Session-Expires", - "Remote-Credential" + "Remote-Credential", + "Remote-Public" ] } }, @@ -110,6 +111,7 @@ services: - Remote-Role-Name - Remote-Session-Expires - Remote-Credential + - Remote-Public upstream: type: roundrobin nodes: @@ -156,8 +158,19 @@ uri: http://localhost:4401/auth/api/forward The last form requires only authentication. See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md). +## Public access + +For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the `forward-auth` URI: + +```yaml +uri: http://localhost:4401/auth/api/forward?public=1 +uri: http://localhost:4401/auth/api/forward?public=1&perm=myapp:reports +``` + +The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and the `Remote-Public` header — included in the `upstream_headers` lists above — marks each request as `anonymous`, `forbidden` or `authenticated`. The backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access). + ## Notes - 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/`. diff --git a/docs/proxy/caddy.md b/docs/proxy/caddy.md index 1d8792b..4361f2d 100644 --- a/docs/proxy/caddy.md +++ b/docs/proxy/caddy.md @@ -58,6 +58,19 @@ app.example.com { The above setup allows unauthenticated access to certain files, then implements two different access controls for your backend app depending on which path is accessed. Note that the perm and max-age options may be combined, e.g. `perm=myapp:admin&max-age=5min` on a very sensitive endpoint. This will require additional authentication if the passkey hasn't been used in the last 5 minutes (automatic session renewals don't affect this). Use `""` if you only want the user to be authenticated with no time or perm requirements. +### Public access (public=1) + +For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the same snippet: + +```caddyfile +handle { + import auth/require "public=1" + reverse_proxy :3000 +} +``` + +The auth check then always passes (204): anonymous requests and users lacking a requested `perm` reach your backend marked with a `Remote-Public` header (`anonymous`, `forbidden` or `authenticated`) instead of getting a 401/403. Your backend must check `Remote-Public` before treating the request as authorized — see [trusted headers](../Headers.md#public-access). A `max_age` reauth requirement still renders the authentication page, even on public routes. + ### Dedicated Authentication Site When you setup a separate subdomain for the authentication site, just add to your config another section for the auth host: @@ -68,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. diff --git a/docs/proxy/envoy.md b/docs/proxy/envoy.md index df317c7..edb900a 100644 --- a/docs/proxy/envoy.md +++ b/docs/proxy/envoy.md @@ -161,7 +161,18 @@ 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 + +For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to `path_override` (globally or per route): + +```yaml +path_override: "/auth/api/forward?public=1" +path_override: "/auth/api/forward?public=1&perm=myapp:reports" +``` + +The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and the `Remote-Public` header — matched by the `prefix: Remote-` rule in `allowed_upstream_headers` — marks each request as `anonymous`, `forbidden` or `authenticated`. The backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access). ## Notes diff --git a/docs/proxy/haproxy.md b/docs/proxy/haproxy.md index e235495..2b684c5 100644 --- a/docs/proxy/haproxy.md +++ b/docs/proxy/haproxy.md @@ -106,9 +106,21 @@ frontend app See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) for query parameter syntax. +## Public access + +For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the auth subrequest path: + +```haproxy +http-request lua.auth-intercept paskia_auth /auth/api/forward?public=1 GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* * +# or with a permission the backend will check itself: +http-request lua.auth-intercept paskia_auth /auth/api/forward?public=1&perm=myapp:reports GET Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri Remote-* * +``` + +The `Remote-*` success-headers glob already copies the `Remote-Public` header that marks each request as `anonymous`, `forbidden` or `authenticated`. With `public=1` the backend always runs and must check `Remote-Public` before treating the request as authorized; only reauth (`max_age`) still returns the 401 auth flow, so the `http-request deny` safety net simply never triggers on public routes. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access). + ## Notes - 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. diff --git a/docs/proxy/index.md b/docs/proxy/index.md index 1a420c4..84cd3b4 100644 --- a/docs/proxy/index.md +++ b/docs/proxy/index.md @@ -23,6 +23,7 @@ No matter which proxy you use, the auth subrequest must: 2. Include the query parameters Paskia needs for access control: - `perm` — required permission scope, repeatable (e.g. `perm=myapp:login`). See [perm argument](../api/perm.md). - `max_age` — how recently the user must have authenticated (e.g. `max_age=5min`). See [max_age argument](../api/max-age.md). + - `public=1` — optional; allow public access (anonymous visitors and users missing `perm` pass through, marked with a `Remote-Public` header instead of a 401/403). See [public access](../api/forward.md#public-access). 3. Forward these request headers from the original client request: - `Host` — the site the user is visiting. - `Cookie` — the session cookie, normally `__Host-paskia`. @@ -30,8 +31,8 @@ No matter which proxy you use, the auth subrequest must: - `X-Forwarded-Uri` — the original path and query string (e.g. `/reports?foo=bar`). - `Accept` — decides whether a 401/403 response should be HTML (browser) or JSON (API/fetch). 4. Strip hop-by-hop headers (`Connection`, `Upgrade`, `Transfer-Encoding`, `Keep-Alive`, `Proxy-Connection`, `TE`) from the auth subrequest. The auth check is a plain HTTP request and must not carry WebSocket/body framing headers. -5. On a `204 No Content` response, copy the `Remote-*` response headers to the request that is forwarded to the protected backend. The headers are the whole point of the auth check. -6. On a 401/403 response, send Paskia's response back to the client without contacting the protected backend. +5. On a `204 No Content` response, copy the `Remote-*` response headers to the request that is forwarded to the protected backend. The headers are the whole point of the auth check. With `public=1`, also copy `Remote-Public` — it marks whether the request is `authenticated`, `forbidden` or `anonymous`, and the backend must check it. +6. On a 401/403 response, send Paskia's response back to the client without contacting the protected backend. (With `public=1` these only occur for reauth requirements.) 7. Also proxy the `/auth/` path prefix to Paskia so the login/profile UI, API endpoints, and WebSockets are reachable. Paskia's WebSocket endpoints need `Upgrade` and `Connection` headers passed through for that path. ## Backend usage diff --git a/docs/proxy/nginx.md b/docs/proxy/nginx.md index 9619481..1052011 100644 --- a/docs/proxy/nginx.md +++ b/docs/proxy/nginx.md @@ -35,6 +35,7 @@ server { auth_request_set $remote_role_name $upstream_http_remote_role_name; auth_request_set $remote_session_exp $upstream_http_remote_session_expires; auth_request_set $remote_credential $upstream_http_remote_credential; + auth_request_set $remote_public $upstream_http_remote_public; proxy_set_header Remote-User $remote_user; proxy_set_header Remote-Name $remote_name; @@ -45,6 +46,7 @@ server { proxy_set_header Remote-Role-Name $remote_role_name; proxy_set_header Remote-Session-Expires $remote_session_exp; proxy_set_header Remote-Credential $remote_credential; + proxy_set_header Remote-Public $remote_public; # 4. The proxy_set_header lines above override any client-supplied # Remote-* headers, so the backend receives only the values from @@ -123,6 +125,17 @@ location /static/ { } ``` +## Public access + +For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the auth subrequest URI inside `/auth-internal`: + +```nginx +proxy_pass http://localhost:4401/auth/api/forward?public=1; +proxy_pass http://localhost:4401/auth/api/forward?public=1&perm=myapp:reports; +``` + +The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and `Remote-Public` marks each request as `anonymous`, `forbidden` or `authenticated`. It is captured and forwarded by the `auth_request_set $remote_public` / `proxy_set_header Remote-Public` lines added in the overview above — the backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access). + ## Notes - Nginx `auth_request` always makes the auth subrequest with the same HTTP method as the original request, but the body is suppressed by the configuration above. Paskia uses the `X-Forwarded-Method` and `X-Forwarded-Uri` headers for logging. diff --git a/docs/proxy/traefik.md b/docs/proxy/traefik.md index 7b4ef29..0ec971a 100644 --- a/docs/proxy/traefik.md +++ b/docs/proxy/traefik.md @@ -85,13 +85,14 @@ authResponseHeaders: - Remote-Role-Name - Remote-Session-Expires - Remote-Credential + - Remote-Public ``` ## Proxying `/auth/` to Paskia 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 @@ -119,6 +120,17 @@ labels: - "traefik.http.middlewares.paskia-auth.forwardauth.authRequestHeaders=Host,Cookie,Accept,X-Forwarded-Method,X-Forwarded-Uri" ``` +## Public access + +For routes where anonymous visitors are allowed but logged-in users should still be identified, add `public=1` to the middleware `address`: + +```yaml +address: "http://localhost:4401/auth/api/forward?public=1" +address: "http://localhost:4401/auth/api/forward?public=1&perm=myapp:reports" +``` + +The auth check then always returns 204 (except reauth with `max_age`, which still returns the 401 auth flow), and the `Remote-Public` header — copied by `authResponseHeadersRegex: "^Remote-"` — marks each request as `anonymous`, `forbidden` or `authenticated`. The backend always runs and must check `Remote-Public` before treating the request as authorized. See [public access](../api/forward.md#public-access) and [trusted headers](../Headers.md#public-access). + ## Notes - By default ForwardAuth sends a request without the original body. If you need to forward the body for logging/validation, set `forwardBody: true` and a sensible `maxBodySize`, but for Paskia this is not required. diff --git a/e2e/README.md b/e2e/README.md index e946d2c..9ce8faf 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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 diff --git a/e2e/tests/50-multidomain.spec.ts b/e2e/tests/50-multidomain.spec.ts new file mode 100644 index 0000000..60dd23e --- /dev/null +++ b/e2e/tests/50-multidomain.spec.ts @@ -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() + }) +}) diff --git a/e2e/tests/fixtures/remote-auth.ts b/e2e/tests/fixtures/remote-auth.ts new file mode 100644 index 0000000..dc83b9e --- /dev/null +++ b/e2e/tests/fixtures/remote-auth.ts @@ -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 { + 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((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 { + 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 { + 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 }) +} diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index fcceb0a..b832620 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -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)) { - mkdirSync(testDataDir, { recursive: true }) - } + // 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((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timed out waiting for server bootstrap (30s)')) - }, 30000) + serverProcess.stdout?.on('data', (data: Buffer) => process.stdout.write(data)) + serverProcess.stderr?.on('data', (data: Buffer) => process.stderr.write(data)) - 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.on('exit', (code) => { + if (code !== 0 && code !== null) { + console.error(`Server exited unexpectedly with code ${code}`) } - - serverProcess.stdout?.on('data', handleData) - serverProcess.stderr?.on('data', handleData) - - serverProcess.on('error', (err) => { - clearTimeout(timeout) - reject(err) - }) - - serverProcess.on('exit', (code) => { - if (code !== 0 && code !== null) { - clearTimeout(timeout) - reject(new Error(`Server exited 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 + // 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') + if (response.ok) { + settings = await response.json() + break + } + } catch { + // Not up yet + } + await new Promise(r => setTimeout(r, 250)) } - - // Fetch session cookie name from server settings - 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) + if (!settings) { serverProcess.kill() - throw err + 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)) diff --git a/e2e/tests/global-teardown.ts b/e2e/tests/global-teardown.ts index 1570058..25ccdcb 100644 --- a/e2e/tests/global-teardown.ts +++ b/e2e/tests/global-teardown.ts @@ -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 diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue index 3933b54..12a696c 100644 --- a/frontend/auth/App.vue +++ b/frontend/auth/App.vue @@ -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 } diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 8b1949a..c54a3c5 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -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), - }) - } catch (e) { - authStore.showMessage(e.message || 'Failed to load server configuration', 'error') - } +function createDomain() { + openDialog('domain-edit', { + isNew: true, + rp_id: '', + rp_name: '', + auth_host: '', + origins: [], + originValidation: [], + wellKnownCheck: null, + }) +} + +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() { diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index fa588c1..408004f 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -1,37 +1,73 @@ - diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 5f8d62d..e7f1fce 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -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; diff --git a/frontend/src/components/CredentialList.vue b/frontend/src/components/CredentialList.vue index 6c06315..f56e3ae 100644 --- a/frontend/src/components/CredentialList.vue +++ b/frontend/src/components/CredentialList.vue @@ -32,6 +32,7 @@

{{ getCredentialAuthName(credential) }}

+ {{ credential.rp_id }} Current Selected Linked @@ -61,8 +62,13 @@