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: https://git.zi.fi/LeoVasanko/paskia/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
LeoVasanko committed 2026-09-07 22:02:06 +00:00
1 parent 3912b5473e
commit 10af29f92d
91 files changed
+5755 -1867

No files matched your search

+115 -18
View File
@@ -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")
@@ -235,6 +239,104 @@ class TestForwardEndpoint:
assert data["auth"]["mode"] == "forbidden"
class TestForwardPublicAccess:
"""Tests for GET /auth/api/forward with public=1 (public access mode)"""
@pytest.mark.asyncio
async def test_public_without_session_returns_204_anonymous(
self, client: httpx.AsyncClient
):
"""Public access without session should pass as anonymous."""
response = await client.get("/auth/api/forward?public=1")
assert response.status_code == 204
assert response.headers["Remote-Public"] == "anonymous"
assert "Remote-User" not in response.headers
assert "Remote-Groups" not in response.headers
@pytest.mark.asyncio
async def test_public_with_expired_session_returns_204_anonymous(
self, client: httpx.AsyncClient
):
"""Public access with invalid session should pass as anonymous."""
fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters
response = await client.get(
"/auth/api/forward?public=1",
headers={**auth_headers(fake_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
assert response.headers["Remote-Public"] == "anonymous"
assert "Remote-User" not in response.headers
# Cookie must not be cleared on public pass-through
assert "set-cookie" not in response.headers
@pytest.mark.asyncio
async def test_public_permission_denied_returns_204_forbidden(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""Public access with missing permission should pass as forbidden with identity."""
response = await client.get(
"/auth/api/forward?public=1&perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
},
)
assert response.status_code == 204
assert response.headers["Remote-Public"] == "forbidden"
# Identity is known and sent, including (trustworthy) groups
assert "Remote-User" in response.headers
assert "Remote-Groups" in response.headers
@pytest.mark.asyncio
async def test_public_authorized_returns_204_authenticated(
self, client: httpx.AsyncClient, session_token: str
):
"""Public access with full authorization should be marked authenticated."""
response = await client.get(
"/auth/api/forward?public=1&perm=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
assert response.headers["Remote-Public"] == "authenticated"
assert "Remote-User" in response.headers
assert "Remote-Groups" in response.headers
@pytest.mark.asyncio
async def test_public_reauth_still_returns_401(
self, client: httpx.AsyncClient, session_token: str
):
"""Reauth (max_age) is never soft-passed, even with public=1."""
response = await client.get(
"/auth/api/forward?public=1&max_age=0s",
headers={
**auth_headers(session_token),
"Host": "localhost:4401",
"Accept": "application/json",
},
)
assert response.status_code == 401
data = response.json()
assert data["auth"]["mode"] == "reauth"
@pytest.mark.asyncio
async def test_public_malformed_perm_returns_400(self, client: httpx.AsyncClient):
"""Malformed perm remains a hard error with public=1."""
response = await client.get("/auth/api/forward?public=1&perm=a||b")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_without_public_no_remote_public_header(
self, client: httpx.AsyncClient, session_token: str
):
"""Without public=1, Remote-Public is absent on success."""
response = await client.get(
"/auth/api/forward",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
assert "Remote-Public" not in response.headers
class TestPermOrSemantics:
"""Tests for OR ('|') semantics and strict parsing of the perm argument"""
@@ -548,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")},
@@ -578,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")},
@@ -616,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")},
@@ -678,6 +774,7 @@ class TestSetSessionEndpoint:
authcode.CookieCode(
session_key=session_token,
created=datetime.now(UTC),
rp_id="localhost",
)
)
response = await client.post(