MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch, per-domain credentials and sessions, domains managed at runtime in the admin UI — previously one RP per instance - Cross-domain sign-in via Related Origin Requests: per-domain related-origins list with a served .well-known/webauthn document - Explicit per-domain origin lists with shell-glob wildcards (**. for apex + any subdomain depth, *. for one level), editable in the admin UI with validation and self-lockout guards - Per-domain auth hosts: the account/admin UI can live on a different host per domain, no longer confined to subdomains of a single RP - CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an existing database; 'paskia migrate' converts legacy databases BREAKING CHANGES (v2.0): - Database schema: config is now per-domain and credentials/sessions carry an rp_id — existing databases must be converted with 'paskia migrate' - Origins are now explicit: main implicitly allowed every subdomain of the RP; configure '**.' origins to reproduce that behavior - CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
This commit is contained in:
+37
-51
@@ -12,12 +12,12 @@ in the database to test authenticated endpoints.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
@@ -25,22 +25,8 @@ import pytest
|
||||
import pytest_asyncio
|
||||
from kanta import Kanta
|
||||
|
||||
# Keep runtime initialization invariant aligned with production:
|
||||
# db.lifecycle requires PASKIA_CONFIG at import time.
|
||||
os.environ.setdefault(
|
||||
"PASKIA_CONFIG",
|
||||
json.dumps(
|
||||
{
|
||||
"config": {"rp_id": "localhost", "rp_name": "localhost"},
|
||||
"site_url": "http://localhost:4401",
|
||||
"site_path": "/auth/",
|
||||
"save": False,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import globals as paskia_globals
|
||||
from paskia import domains
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import (
|
||||
@@ -56,12 +42,15 @@ from paskia.db import (
|
||||
)
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.operations import DB
|
||||
from paskia.db.structs import Session
|
||||
from paskia.db.structs import Config, DomainConfig, Session
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import avatar
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
TEST_RP_ID = "localhost"
|
||||
TEST_LISTEN = ["localhost:4401"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
@@ -71,6 +60,19 @@ def event_loop():
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _avatar_tmp_root(tmp_path, monkeypatch):
|
||||
"""Redirect avatar storage to a per-test temporary directory."""
|
||||
root = tmp_path / "users"
|
||||
|
||||
def users_root(create_root: bool = False) -> Path:
|
||||
if create_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
monkeypatch.setattr(avatar, "users_root_path", users_root)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_db() -> AsyncGenerator[DB]:
|
||||
"""Create a temporary JSONL database for testing using kanta.
|
||||
@@ -79,15 +81,11 @@ async def test_db() -> AsyncGenerator[DB]:
|
||||
- auth:admin and auth:org:admin permissions
|
||||
- A default organization with Administration role
|
||||
- An admin user with the Administration role
|
||||
- The localhost domain configuration (with its OIDC provider)
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||
db = DB()
|
||||
kanta = Kanta(
|
||||
f.name,
|
||||
db,
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = "test.example.com"
|
||||
kanta = Kanta(f.name, db)
|
||||
|
||||
# Register bootstrap callback so kanta seeds the empty DB during open()
|
||||
@kanta.bootstrap(action="bootstrap")
|
||||
@@ -96,6 +94,11 @@ async def test_db() -> AsyncGenerator[DB]:
|
||||
data,
|
||||
org_name="Test Organization",
|
||||
admin_name="Test Admin",
|
||||
config=Config(
|
||||
domains={
|
||||
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True})
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
await kanta.open()
|
||||
@@ -107,25 +110,10 @@ async def test_db() -> AsyncGenerator[DB]:
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def passkey_instance() -> Passkey:
|
||||
"""Override the module-level passkey instance for testing."""
|
||||
pk = Passkey(
|
||||
rp_id="localhost",
|
||||
rp_name="Test RP",
|
||||
origins=["http://localhost:4401"],
|
||||
)
|
||||
original = {
|
||||
"rp_id": paskia_globals.passkey.rp_id,
|
||||
"rp_name": paskia_globals.passkey.rp_name,
|
||||
"allowed_origins": paskia_globals.passkey.allowed_origins,
|
||||
}
|
||||
paskia_globals.passkey.rp_id = pk.rp_id
|
||||
paskia_globals.passkey.rp_name = pk.rp_name
|
||||
paskia_globals.passkey.allowed_origins = pk.allowed_origins
|
||||
yield pk
|
||||
paskia_globals.passkey.rp_id = original["rp_id"]
|
||||
paskia_globals.passkey.rp_name = original["rp_name"]
|
||||
paskia_globals.passkey.allowed_origins = original["allowed_origins"]
|
||||
async def domain_registry(test_db: DB) -> domains.DomainRegistry:
|
||||
"""Install the domain registry built from the test database config."""
|
||||
domains.configure(listen=TEST_LISTEN)
|
||||
return domains.init_registry(test_db.config)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -192,6 +180,7 @@ async def test_credential(test_db: DB, test_user: User) -> Credential:
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id=TEST_RP_ID,
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -206,6 +195,7 @@ async def regular_credential(test_db: DB, regular_user: User) -> Credential:
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id=TEST_RP_ID,
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -247,15 +237,9 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential)
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def client(
|
||||
test_db: DB, passkey_instance: Passkey
|
||||
test_db: DB, domain_registry: domains.DomainRegistry
|
||||
) -> AsyncGenerator[httpx.AsyncClient]:
|
||||
"""Create an async test client for the FastAPI app.
|
||||
|
||||
Note: We import the app inside the fixture to ensure globals are
|
||||
initialized first.
|
||||
"""
|
||||
# Import app after globals are set
|
||||
|
||||
"""Create an async test client for the FastAPI app."""
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
@@ -283,6 +267,7 @@ def create_test_session(
|
||||
ip: str = "127.0.0.1",
|
||||
user_agent: str = "pytest",
|
||||
duration: timedelta | None = None,
|
||||
rp_id: str = TEST_RP_ID,
|
||||
) -> tuple[str, str]:
|
||||
"""Create a test session. Returns (key, token) tuple.
|
||||
|
||||
@@ -309,6 +294,7 @@ def create_test_session(
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
validated=now,
|
||||
rp_id=rp_id,
|
||||
)
|
||||
if session.key in ops_db._db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
|
||||
+303
-53
@@ -22,7 +22,7 @@ import pytest
|
||||
import pytest_asyncio
|
||||
import uuid7
|
||||
|
||||
from paskia import db
|
||||
from paskia import db, domains
|
||||
from paskia.db import (
|
||||
Credential,
|
||||
Org,
|
||||
@@ -37,10 +37,7 @@ from paskia.db import (
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.operations import DB
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.crypto import hash_secret
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
|
||||
# -------------------- Additional Fixtures --------------------
|
||||
@@ -91,6 +88,7 @@ async def second_org_credential(test_db: DB, second_org_user: User) -> Credentia
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id="localhost",
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -145,6 +143,7 @@ async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential:
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id="localhost",
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -253,8 +252,6 @@ class TestAdminOrganizations:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin org payload should include canonical avatar URLs for listed users."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -948,8 +945,6 @@ class TestAdminUsersInOrg:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Admin should be able to upload avatar for a managed user."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -1794,38 +1789,60 @@ class TestOrgAdminAuthExceptions:
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestServerConfig:
|
||||
"""Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates."""
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def restore_runtime_config(self):
|
||||
"""Restore PASKIA_CONFIG env and cache after a test mutates runtime."""
|
||||
original = os.environ["PASKIA_CONFIG"]
|
||||
yield
|
||||
os.environ["PASKIA_CONFIG"] = original
|
||||
clear_config_cache()
|
||||
class TestDomains:
|
||||
"""Tests for the domain management API (/auth/api/admin/domains/)."""
|
||||
|
||||
async def _set_auth_host(self, client, session_token, test_user, test_credential):
|
||||
"""Configure an auth host via PATCH, as the admin UI would."""
|
||||
"""Configure an auth host on the localhost domain, as the admin UI would."""
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"auth_host": "auth.localhost",
|
||||
"origins": ["auth.localhost", "localhost"],
|
||||
"origins": {
|
||||
"auth.localhost": {"auth_host": True},
|
||||
"localhost": True,
|
||||
},
|
||||
},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.data().config.auth_host == "https://auth.localhost"
|
||||
assert hostutil.dedicated_auth_host() == "auth.localhost"
|
||||
assert hostutil.auth_site_url() == "https://auth.localhost/"
|
||||
domain_cfg = db.data().config.domains["localhost"]
|
||||
assert domains.auth_host_url(domain_cfg) == "https://auth.localhost"
|
||||
domain = domains.registry().get("localhost")
|
||||
assert domain.own_auth_host == "auth.localhost"
|
||||
assert domain.auth_site_url == "https://auth.localhost/"
|
||||
# Session for requests coming from the auth host (sessions are host-bound)
|
||||
_, token = create_test_session(
|
||||
test_user.uuid, test_credential.uuid, host="auth.localhost"
|
||||
)
|
||||
return {**auth_headers(token), "Host": "auth.localhost"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_domains(self, client: httpx.AsyncClient, session_token: str):
|
||||
r = await client.get(
|
||||
"/auth/api/admin/domains/",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert len(data) == 1
|
||||
domain = data[0]
|
||||
assert domain["rp_id"] == "localhost"
|
||||
assert domain["origins"] == {"**.localhost": True}
|
||||
assert "related" not in domain
|
||||
assert domain["auth_host"] is None
|
||||
assert domain["site_url"] == "http://localhost:4401"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_domains_require_master_admin(
|
||||
self, client: httpx.AsyncClient, regular_session_token: str
|
||||
):
|
||||
r = await client.get(
|
||||
"/auth/api/admin/domains/",
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_auth_host_updates_runtime(
|
||||
self,
|
||||
@@ -1833,41 +1850,41 @@ class TestServerConfig:
|
||||
session_token: str,
|
||||
test_user,
|
||||
test_credential,
|
||||
restore_runtime_config,
|
||||
):
|
||||
"""Removing auth_host must clear it from runtime config and URLs."""
|
||||
"""Removing the auth host mark must clear it from runtime config and URLs."""
|
||||
headers = await self._set_auth_host(
|
||||
client, session_token, test_user, test_credential
|
||||
)
|
||||
|
||||
# The dialog still lists the old auth host among origins, so it is sent back
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"auth_host": "",
|
||||
"origins": ["auth.localhost", "localhost"],
|
||||
"origins": {"auth.localhost": True, "localhost": True},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert db.data().config.auth_host is None
|
||||
domain_cfg = db.data().config.domains["localhost"]
|
||||
assert domains.auth_host_url(domain_cfg) is None
|
||||
|
||||
rt = runtime_config()
|
||||
assert rt.config.auth_host is None
|
||||
assert rt.site_path == "/auth/"
|
||||
assert "auth.localhost" not in rt.site_url
|
||||
assert hostutil.dedicated_auth_host() is None
|
||||
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||
domain = domains.registry().get("localhost")
|
||||
assert domain.own_auth_host is None
|
||||
assert domain.ui_base_path == "/auth/"
|
||||
# Site URL derivation is stateless: with the auth host mark removed,
|
||||
# the exact rp-id origin becomes the site URL.
|
||||
assert domain.auth_site_url == "https://localhost/auth/"
|
||||
|
||||
# GET and settings reflect the cleared state
|
||||
r = await client.get(
|
||||
"/auth/api/admin/server-config/",
|
||||
"/auth/api/admin/domains/",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert r.json()["auth_host"] == ""
|
||||
assert r.json()[0]["origins"] == {"auth.localhost": True, "localhost": True}
|
||||
r = await client.get("/auth/api/settings")
|
||||
assert r.json()["auth_host"] is None
|
||||
assert r.json()["own_auth_host"] is None
|
||||
assert r.json()["ui_base_path"] == "/auth/"
|
||||
|
||||
# Middleware no longer redirects to the removed auth host
|
||||
@@ -1879,28 +1896,261 @@ class TestServerConfig:
|
||||
assert "auth.localhost" not in r.headers.get("location", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_auth_host_without_origins_falls_back_to_rp_id(
|
||||
async def test_remove_auth_host_without_origins_falls_back(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
):
|
||||
"""Emptying a domain's origins table must not keep the removed auth
|
||||
host in derived URLs. Only possible on a domain other than the one
|
||||
in use — the lockout guard refuses it there."""
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"origins": {
|
||||
"auth.example.com": {"auth_host": True},
|
||||
"app.example.com": True,
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
domain = domains.registry().get("example.com")
|
||||
assert domain.own_auth_host == "auth.example.com"
|
||||
assert "auth.example.com" in domain.site_url
|
||||
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/example.com",
|
||||
json={"rp_name": "", "origins": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
domain = domains.registry().get("example.com")
|
||||
assert domain.own_auth_host is None
|
||||
assert domain.ui_base_path == "/auth/"
|
||||
assert "auth.example.com" not in domain.site_url
|
||||
assert "auth.example.com" not in domain.auth_site_url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_delete_domain(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"rp_name": "Example",
|
||||
"origins": {"app.example.com": True, "unrelated-site.com": True},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = await client.get("/auth/api/admin/domains/", headers=headers)
|
||||
domains_list = {domain["rp_id"]: domain for domain in r.json()}
|
||||
assert set(domains_list) == {"localhost", "example.com"}
|
||||
created = domains_list["example.com"]
|
||||
assert created["rp_name"] == "Example"
|
||||
# In-domain and related origins live in one table; classification
|
||||
# is derived from the rp-id
|
||||
assert created["origins"] == {
|
||||
"app.example.com": True,
|
||||
"unrelated-site.com": True,
|
||||
}
|
||||
|
||||
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "example.com" not in db.data().config.domains
|
||||
assert domains.registry().get("example.com") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_domain_validation(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
|
||||
# rp_id is required
|
||||
r = await client.post("/auth/api/admin/domains/", json={}, headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Duplicate rp-id
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "localhost"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Invalid rp-id
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "not a domain!"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# An auth host must be within the rp-id domain
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={
|
||||
"rp_id": "example.com",
|
||||
"origins": {"auth.other.com": {"auth_host": True}},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Related origin host may not collide across domains
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "example.com", "origins": {"shared-app.com": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "other.com", "origins": {"shared-app.com": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Cross-domain entries are related origins — accepted in the same table
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "another.com", "origins": {"elsewhere.com": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
# Plain '*' is rejected — wildcards must be explicit ('**.another.com')
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/",
|
||||
json={"rp_id": "star.com", "origins": {"*": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_domain_guards(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_credential
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
|
||||
# Cannot delete the last domain
|
||||
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
# Unknown domain
|
||||
r = await client.delete("/auth/api/admin/domains/nope.com", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
# A domain with credentials still registered under it cannot be deleted
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
cred = Credential.create(
|
||||
credential_id=secrets.token_bytes(32),
|
||||
user=test_credential.user_uuid,
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=secrets.token_bytes(64),
|
||||
sign_count=0,
|
||||
rp_id="example.com",
|
||||
)
|
||||
create_credential(cred)
|
||||
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_domain_refuses_self_lockout(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""An allow-list excluding the admin's current host is refused."""
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
|
||||
# Allow-list without the current host and no auth host → lockout
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={"rp_name": "", "origins": {"auth.localhost": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "lock you out" in r.text
|
||||
|
||||
# Emptying the origins table entirely is likewise a lockout
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={"rp_name": "", "origins": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "lock you out" in r.text
|
||||
|
||||
# Allow-list including the current host is fine
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={"rp_name": "", "origins": {"localhost:4401": True}},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# An allow-list without the current host is also fine when an auth
|
||||
# host is set: ceremonies move there (and it is always allowed).
|
||||
# Done last: with an auth host set, the API here routes differently.
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/domains/localhost",
|
||||
json={
|
||||
"rp_name": "",
|
||||
"origins": {"auth.localhost": {"auth_host": True}},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_current_domain_refused(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
headers = {**auth_headers(session_token), "Host": "localhost:4401"}
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Deleting the domain in use is refused even if it has no credentials
|
||||
r = await client.delete("/auth/api/admin/domains/localhost", headers=headers)
|
||||
assert r.status_code == 400
|
||||
assert "currently using" in r.text
|
||||
# Deleting another domain while authenticated here is fine
|
||||
r = await client.delete("/auth/api/admin/domains/example.com", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_cross_domain_auth_host_fallback(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
test_user,
|
||||
test_credential,
|
||||
restore_runtime_config,
|
||||
):
|
||||
"""With no origins left, site_url must not keep the removed auth host."""
|
||||
"""A domain without its own auth host reports none — there is no
|
||||
cross-domain fallback to another domain's auth host."""
|
||||
headers = await self._set_auth_host(
|
||||
client, session_token, test_user, test_credential
|
||||
)
|
||||
|
||||
r = await client.patch(
|
||||
"/auth/api/admin/server-config/",
|
||||
json={"rp_name": "", "auth_host": "", "origins": []},
|
||||
headers=headers,
|
||||
r = await client.post(
|
||||
"/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.status_code == 200
|
||||
|
||||
rt = runtime_config()
|
||||
assert rt.config.auth_host is None
|
||||
assert rt.site_path == "/auth/"
|
||||
assert "auth.localhost" not in rt.site_url
|
||||
assert "auth.localhost" not in hostutil.auth_site_url()
|
||||
# Settings on the example.com host report no auth host
|
||||
r = await client.get("/auth/api/settings", headers={"Host": "example.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["rp_id"] == "example.com"
|
||||
assert r.json()["auth_host"] is None
|
||||
assert r.json()["own_auth_host"] is None
|
||||
|
||||
# The localhost domain still reports its own auth host
|
||||
r = await client.get("/auth/api/settings", headers={"Host": "auth.localhost"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["auth_host"] == "auth.localhost"
|
||||
assert r.json()["own_auth_host"] == "auth.localhost"
|
||||
|
||||
+17
-18
@@ -18,12 +18,12 @@ from uuid import UUID
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia import authcode, db, domains
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import delete_session
|
||||
from paskia.db.structs import Client
|
||||
from paskia.db.structs import Client, Config, DomainConfig, OriginEntry
|
||||
from paskia.fastapi.api import _REFRESH_INTERVAL
|
||||
from paskia.util import avatar, hostutil, oidjwt, permutil
|
||||
from paskia.util import avatar, oidjwt, permutil
|
||||
from paskia.util.crypto import hash_secret
|
||||
from paskia.util.passphrase import generate
|
||||
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
|
||||
@@ -42,7 +42,7 @@ class TestSettingsEndpoint:
|
||||
assert "rp_name" in data
|
||||
assert "session_cookie" in data
|
||||
assert data["rp_id"] == "localhost"
|
||||
assert data["rp_name"] == "Test RP"
|
||||
assert data["rp_name"] == "localhost"
|
||||
assert data["session_cookie"] == "__Host-paskia"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -69,16 +69,20 @@ class TestAvatarUrls:
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
|
||||
db_root = tmp_path / "test-avatar-db.paskiadb"
|
||||
monkeypatch.setenv("PASKIA_DB", str(db_root))
|
||||
monkeypatch.setattr(
|
||||
hostutil,
|
||||
"api_url",
|
||||
lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}",
|
||||
domains.configure(listen=None)
|
||||
domains.init_registry(
|
||||
Config(
|
||||
domains={
|
||||
"zi.fi": DomainConfig(
|
||||
origins={"auth.zi.fi": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||
path = db_root / "users" / str(test_uuid) / "profile.webp"
|
||||
# The autouse avatar fixture redirects storage to tmp_path / "users"
|
||||
user_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||
path = tmp_path / "users" / str(user_uuid) / "profile.webp"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"RIFF1234WEBP")
|
||||
|
||||
@@ -646,8 +650,6 @@ class TestUserInfoEndpoint:
|
||||
monkeypatch,
|
||||
):
|
||||
"""User info should include the canonical avatar URL when present."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -676,8 +678,6 @@ class TestUserInfoEndpoint:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar route should honor If-None-Match for unchanged avatars."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -714,8 +714,6 @@ class TestOidcUserInfoEndpoint:
|
||||
monkeypatch,
|
||||
):
|
||||
"""OIDC userinfo should expose picture when profile scope is granted."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -776,6 +774,7 @@ class TestSetSessionEndpoint:
|
||||
authcode.CookieCode(
|
||||
session_key=session_token,
|
||||
created=datetime.now(UTC),
|
||||
rp_id="localhost",
|
||||
)
|
||||
)
|
||||
response = await client.post(
|
||||
|
||||
+234
-102
@@ -1,4 +1,10 @@
|
||||
"""Tests for the CLI entry point in paskia/__main__.py."""
|
||||
"""Tests for the CLI entry point in paskia/__main__.py.
|
||||
|
||||
The CLI is split into ``paskia init`` (create the combined paskia.kantadb
|
||||
with the initial domain(s)), ``paskia migrate`` (convert a legacy
|
||||
``<rp-id>.paskiadb`` database), and bare ``paskia`` (serve the stored
|
||||
domains; never migrates).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,141 +12,245 @@ import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia.__main__ import main
|
||||
from paskia.__main__ import _load_stored_config, main
|
||||
from paskia.db import legacy
|
||||
from paskia.db.structs import DB, Config
|
||||
from paskia.util.runtime import clear_config_cache
|
||||
from paskia.util.runtime import config as runtime_config
|
||||
from paskia.util.runtime import ServeConfig, clear_cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_run(monkeypatch):
|
||||
"""Run the CLI main() with the given args and return the RuntimeConfig."""
|
||||
def run_cli(monkeypatch, tmp_path):
|
||||
"""Run the CLI main() in a temporary working directory.
|
||||
|
||||
def _run(*args: str, db_root: str | None = None) -> Any:
|
||||
env = os.environ.copy()
|
||||
if db_root is not None:
|
||||
env["PASKIA_DB"] = db_root
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
Returns a callable; server.run and the startup box are stubbed out.
|
||||
The returned dict records the server.run invocation (if any).
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
calls: dict = {}
|
||||
monkeypatch.setattr(
|
||||
"fastapi_vue.server.run",
|
||||
lambda app, **kw: calls.update({"app": app, **kw}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"paskia.util.startupbox.print_startup_config", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr("logging.basicConfig", lambda **kw: None)
|
||||
# Isolate environment mutations (PASKIA_CONFIG) from other tests
|
||||
env = os.environ.copy()
|
||||
env.pop("PASKIA_CONFIG", None)
|
||||
env.pop("PASKIA_VITE_URL", None)
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
|
||||
def _run(*args: str) -> dict:
|
||||
monkeypatch.setattr(sys, "argv", ["paskia", *args])
|
||||
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
"paskia.util.startupbox.print_startup_config", lambda _rt: None
|
||||
)
|
||||
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None)
|
||||
|
||||
clear_config_cache()
|
||||
main()
|
||||
runtime = runtime_config()
|
||||
clear_config_cache()
|
||||
return runtime
|
||||
clear_cache()
|
||||
try:
|
||||
main()
|
||||
finally:
|
||||
clear_cache()
|
||||
return calls
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
async def _write_config(db_path: Path, config: Config) -> None:
|
||||
"""Write a Config into a JSONL database file using Kanta.
|
||||
|
||||
The initial root uses a different rp_id so the stored diff includes the
|
||||
target rp_id (required because Config omits defaults when diffing).
|
||||
"""
|
||||
kanta = Kanta(
|
||||
str(db_path),
|
||||
DB(config=Config(rp_id="uninitialized.invalid")),
|
||||
migrations="paskia.db.migrations",
|
||||
)
|
||||
kanta.ctx.rp_id = config.rp_id
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:write_config"):
|
||||
kanta.data.config = config
|
||||
await kanta.close()
|
||||
def stored_config(tmp_path: Path) -> Config:
|
||||
"""Read back the stored combined configuration."""
|
||||
return _load_stored_config(tmp_path / "paskia.kantadb")
|
||||
|
||||
|
||||
def write_config(db_path: Path, config: Config) -> None:
|
||||
"""Synchronous wrapper for _write_config."""
|
||||
asyncio.run(_write_config(db_path, config))
|
||||
def write_legacy_db(root: Path, config: legacy.LegacyConfig) -> Path:
|
||||
"""Create a legacy-format database directory <rp-id>.paskiadb/main.db."""
|
||||
src_dir = root / f"{config.rp_id}.paskiadb"
|
||||
src_dir.mkdir()
|
||||
db_file = src_dir / "main.db"
|
||||
|
||||
async def _write() -> None:
|
||||
kanta = Kanta(str(db_file), legacy.LegacyDB())
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:seed"):
|
||||
kanta.data.config = config
|
||||
await kanta.close()
|
||||
|
||||
asyncio.run(_write())
|
||||
return src_dir
|
||||
|
||||
|
||||
def test_cli_defaults(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
|
||||
def test_init_defaults(run_cli, tmp_path):
|
||||
run_cli("init")
|
||||
|
||||
assert runtime.config.rp_id == "localhost"
|
||||
assert runtime.config.rp_name is None
|
||||
assert runtime.config.auth_host is None
|
||||
assert runtime.config.origins is None
|
||||
assert runtime.site_url == "http://localhost:4401"
|
||||
assert runtime.site_path == "/auth/"
|
||||
assert runtime.save is False
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["localhost"]
|
||||
assert config.domains["localhost"].rp_name is None
|
||||
assert config.domains["localhost"].origins == {"**.localhost": True}
|
||||
assert config.listen is None
|
||||
|
||||
|
||||
def test_cli_explicit_options(cli_run):
|
||||
runtime = cli_run(
|
||||
"--rp-id",
|
||||
"example.com",
|
||||
"--rp-name",
|
||||
"Example Corp",
|
||||
"--auth-host",
|
||||
"auth.example.com",
|
||||
"--origin",
|
||||
"https://app.example.com",
|
||||
)
|
||||
def test_init_full_options(run_cli, tmp_path):
|
||||
run_cli("init", "example.com", "Example Corp", "--listen", "4402")
|
||||
|
||||
assert runtime.config.rp_id == "example.com"
|
||||
assert runtime.config.rp_name == "Example Corp"
|
||||
assert runtime.config.auth_host == "https://auth.example.com"
|
||||
assert runtime.config.origins == [
|
||||
"https://auth.example.com",
|
||||
"https://app.example.com",
|
||||
]
|
||||
assert runtime.site_url == "https://auth.example.com"
|
||||
assert runtime.site_path == "/"
|
||||
config = stored_config(tmp_path)
|
||||
domain = config.domains["example.com"]
|
||||
assert domain.rp_name == "Example Corp"
|
||||
assert domain.origins == {"**.example.com": True}
|
||||
assert config.listen == ["4402"]
|
||||
|
||||
|
||||
def test_cli_loads_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(
|
||||
db_path,
|
||||
Config(
|
||||
rp_id="example.com",
|
||||
rp_name="Stored Name",
|
||||
origins=["https://stored.example.com"],
|
||||
),
|
||||
)
|
||||
runtime = cli_run("--rp-id", "example.com", db_root=tmp)
|
||||
def test_init_adds_domains_to_existing_database(run_cli, tmp_path):
|
||||
"""Further rp-ids are added by repeating init; no comma separation."""
|
||||
run_cli("init", "company.com")
|
||||
run_cli("init", "app.com")
|
||||
run_cli("init", "pro.com", "Pro Corp")
|
||||
|
||||
assert runtime.config.rp_name == "Stored Name"
|
||||
assert runtime.config.origins == ["https://stored.example.com"]
|
||||
assert runtime.site_url == "https://stored.example.com"
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["company.com", "app.com", "pro.com"]
|
||||
assert config.domains["pro.com"].rp_name == "Pro Corp"
|
||||
|
||||
|
||||
def test_cli_overrides_stored_config(cli_run):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "main.db"
|
||||
write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name"))
|
||||
runtime = cli_run(
|
||||
"--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp
|
||||
)
|
||||
|
||||
assert runtime.config.rp_name == "Overridden"
|
||||
def test_init_seeds_one_global_oidc_key(run_cli, tmp_path):
|
||||
"""OIDC is instance-global: init seeds a single signing key."""
|
||||
run_cli("init", "company.com")
|
||||
run_cli("init", "app.com")
|
||||
assert converted_oidc_key(tmp_path) is not None
|
||||
|
||||
|
||||
def test_cli_save_flag(cli_run):
|
||||
runtime = cli_run("--save")
|
||||
assert runtime.save is True
|
||||
def converted_oidc_key(tmp_path):
|
||||
async def _read():
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(tmp_path / "paskia.kantadb"), new_db)
|
||||
await kanta.open(readonly=True)
|
||||
try:
|
||||
return kanta.data.oidc.key
|
||||
finally:
|
||||
await kanta.close()
|
||||
|
||||
return asyncio.run(_read())
|
||||
|
||||
|
||||
def test_cli_invalid_auth_host(cli_run):
|
||||
def test_init_updates_rp_name_of_existing_domain(run_cli, tmp_path):
|
||||
run_cli("init", "example.com", "Old Name")
|
||||
run_cli("init", "example.com", "New Name")
|
||||
assert stored_config(tmp_path).domains["example.com"].rp_name == "New Name"
|
||||
|
||||
|
||||
def test_init_noop_on_existing_domain(run_cli):
|
||||
run_cli("init")
|
||||
with pytest.raises(SystemExit, match="already configured"):
|
||||
run_cli("init")
|
||||
|
||||
|
||||
def test_init_refuses_legacy_database(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
|
||||
with pytest.raises(SystemExit):
|
||||
cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org")
|
||||
run_cli("init")
|
||||
|
||||
|
||||
def test_init_rejects_removed_options(run_cli):
|
||||
"""Origins and auth hosts are admin-interface configuration, not init's."""
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli("init", "example.com", "--auth-host", "auth.example.com")
|
||||
with pytest.raises(SystemExit):
|
||||
run_cli("init", "--origin", "https://app.example.com")
|
||||
|
||||
|
||||
def test_serve_requires_database(run_cli):
|
||||
with pytest.raises(SystemExit, match="paskia init"):
|
||||
run_cli()
|
||||
|
||||
|
||||
def test_serve_uses_stored_config(run_cli, tmp_path):
|
||||
run_cli("init", "example.com", "Stored Name")
|
||||
calls = run_cli()
|
||||
|
||||
assert calls["app"] == "paskia.fastapi.mainapp:app"
|
||||
assert calls["listen"] is None # stored listen (None) used
|
||||
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
|
||||
assert serve.listen is None
|
||||
|
||||
|
||||
def test_serve_listen_override_not_persisted(run_cli, tmp_path):
|
||||
run_cli("init", "--listen", "4402")
|
||||
calls = run_cli("--listen", "4403")
|
||||
|
||||
assert calls["listen"] == ["4403"]
|
||||
serve = msgspec.json.decode(os.environ["PASKIA_CONFIG"].encode(), type=ServeConfig)
|
||||
assert serve.listen == ["4403"]
|
||||
# Stored config keeps the original listen value
|
||||
assert stored_config(tmp_path).listen == ["4402"]
|
||||
|
||||
|
||||
def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
|
||||
with pytest.raises(SystemExit, match="paskia migrate"):
|
||||
run_cli()
|
||||
|
||||
|
||||
def test_migrate_converts_legacy_database(run_cli, tmp_path):
|
||||
src_dir = write_legacy_db(
|
||||
tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Legacy Name")
|
||||
)
|
||||
# Persisted user files move to the new data root
|
||||
avatar = src_dir / "users" / "019c6831-84cf-7b88-b66c-c8165890b7c5"
|
||||
avatar.mkdir(parents=True)
|
||||
(avatar / "profile.webp").write_bytes(b"RIFF1234WEBP")
|
||||
|
||||
run_cli("migrate")
|
||||
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["example.com"]
|
||||
assert config.domains["example.com"].rp_name == "Legacy Name"
|
||||
# Legacy directory renamed aside, user files moved over
|
||||
assert not src_dir.exists()
|
||||
assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir()
|
||||
assert (
|
||||
tmp_path
|
||||
/ "paskia.data"
|
||||
/ "users"
|
||||
/ "019c6831-84cf-7b88-b66c-c8165890b7c5"
|
||||
/ "profile.webp"
|
||||
).read_bytes() == b"RIFF1234WEBP"
|
||||
|
||||
|
||||
def test_migrate_multiple_legacy_databases_require_rp_id(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
|
||||
with pytest.raises(SystemExit, match="paskia migrate"):
|
||||
run_cli("migrate")
|
||||
|
||||
|
||||
def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
|
||||
|
||||
run_cli("migrate", "two.com")
|
||||
|
||||
config = stored_config(tmp_path)
|
||||
assert list(config.domains) == ["two.com"]
|
||||
# The other candidate is left in place
|
||||
assert (tmp_path / "one.com.paskiadb").is_dir()
|
||||
assert (tmp_path / "two.com.paskiadb.converted-bak").is_dir()
|
||||
|
||||
|
||||
def test_migrate_unknown_rp_id(run_cli, tmp_path):
|
||||
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
|
||||
with pytest.raises(SystemExit, match="nope.com.paskiadb"):
|
||||
run_cli("migrate", "nope.com")
|
||||
|
||||
|
||||
def test_migrate_refuses_existing_database(run_cli):
|
||||
run_cli("init")
|
||||
with pytest.raises(SystemExit, match="already exists"):
|
||||
run_cli("migrate")
|
||||
|
||||
|
||||
def test_migrate_without_legacy_database(run_cli):
|
||||
with pytest.raises(SystemExit, match="No legacy"):
|
||||
run_cli("migrate")
|
||||
|
||||
|
||||
def test_cli_help():
|
||||
@@ -152,3 +262,25 @@ def test_cli_help():
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Paskia authentication server" in result.stdout
|
||||
|
||||
|
||||
def test_cli_init_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "paskia", "init", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Bootstrap" in result.stdout
|
||||
|
||||
|
||||
def test_cli_migrate_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "paskia", "migrate", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "Convert" in result.stdout
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
"""Tests for the multi-domain machinery: registry resolution, config
|
||||
validation, ASGI dispatch, domain binding of auth codes, legacy database
|
||||
conversion, log censoring and bootstrap caveats.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from kanta import Kanta
|
||||
|
||||
from paskia import authcode, domains
|
||||
from paskia.bootstrap import check_admin_credentials
|
||||
from paskia.db import create_credential
|
||||
from paskia.db.legacy import (
|
||||
LegacyConfig,
|
||||
LegacyCredential,
|
||||
LegacyDB,
|
||||
LegacySession,
|
||||
convert_legacy_database,
|
||||
)
|
||||
from paskia.db.lifecycle import format_log_uuid
|
||||
from paskia.db.operations import DB
|
||||
from paskia.db.structs import (
|
||||
OIDC,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
DomainConfig,
|
||||
OriginEntry,
|
||||
Session,
|
||||
)
|
||||
from paskia.fastapi.dispatch import DispatchMiddleware
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Registry construction helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_registry(configs: dict[str, DomainConfig]) -> domains.DomainRegistry:
|
||||
"""Build and install a registry from domain configs (listen unset)."""
|
||||
domains.configure(listen=None)
|
||||
return domains.init_registry(Config(domains=configs))
|
||||
|
||||
|
||||
ROR_CONFIG = Config(
|
||||
domains={
|
||||
"company.com": DomainConfig(
|
||||
rp_name="Company",
|
||||
origins={
|
||||
"auth.company.com": OriginEntry(auth_host=True),
|
||||
"app.com": True, # related origin (outside the rp-id domain)
|
||||
},
|
||||
),
|
||||
"pro.com": DomainConfig(rp_name="Pro", origins={"**.pro.com": True}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StubApp:
|
||||
"""ASGI app recording the scope it was called with."""
|
||||
|
||||
def __init__(self):
|
||||
self.scope = None
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
self.scope = scope
|
||||
|
||||
|
||||
async def drive_ws(middleware: DispatchMiddleware, headers: list[tuple[bytes, bytes]]):
|
||||
"""Run a websocket scope through the middleware, capturing sent messages."""
|
||||
|
||||
async def receive():
|
||||
return {"type": "websocket.connect"}
|
||||
|
||||
sent = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
stub = middleware.app
|
||||
await middleware(
|
||||
{"type": "websocket", "headers": headers, "path": "/"}, receive, send
|
||||
)
|
||||
return stub, sent
|
||||
|
||||
|
||||
async def drive_http(
|
||||
middleware: DispatchMiddleware, headers: list[tuple[bytes, bytes]]
|
||||
):
|
||||
"""Run an http scope through the middleware, capturing sent messages."""
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
sent = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
stub = middleware.app
|
||||
await middleware(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": headers,
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"query_string": b"",
|
||||
},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
return stub, sent
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Host resolution
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_exact_rp_id(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("pro.com").rp_id == "pro.com"
|
||||
assert reg.resolve("company.com").rp_id == "company.com"
|
||||
|
||||
def test_auth_host_and_related_origin(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("auth.company.com").rp_id == "company.com"
|
||||
assert reg.resolve("app.com").rp_id == "company.com"
|
||||
|
||||
def test_subdomain_suffix_longest_match(self):
|
||||
reg = build_registry(
|
||||
{"example.com": DomainConfig(), "sub.example.com": DomainConfig()}
|
||||
)
|
||||
assert reg.resolve("www.example.com").rp_id == "example.com"
|
||||
assert reg.resolve("api.sub.example.com").rp_id == "sub.example.com"
|
||||
|
||||
def test_port_and_trailing_dot_normalized(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("pro.com:8443").rp_id == "pro.com"
|
||||
assert reg.resolve("app.com.").rp_id == "company.com"
|
||||
|
||||
def test_unknown_host(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.resolve("evil.com") is None
|
||||
assert reg.resolve("") is None
|
||||
assert reg.resolve(None) is None
|
||||
|
||||
def test_auth_host_is_per_domain_no_fallback(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
assert reg.get("company.com").own_auth_host == "auth.company.com"
|
||||
# pro.com has no own auth host and there is no cross-domain fallback
|
||||
assert reg.get("pro.com").own_auth_host is None
|
||||
|
||||
def test_shared_auth_host_resolves_best_suffix(self):
|
||||
"""Domains may share an auth host (nested rp-ids); the longest
|
||||
rp-id suffix match wins, first configured as tiebreak."""
|
||||
reg = build_registry(
|
||||
{
|
||||
"com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"company.com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
}
|
||||
)
|
||||
assert reg.resolve("auth.company.com").rp_id == "company.com"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Cross-domain configuration validation
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
def test_valid(self):
|
||||
domains.validate_config(ROR_CONFIG)
|
||||
|
||||
def test_empty_origins_table_is_valid(self):
|
||||
"""No origins at all: nothing of the domain is allowed, but the
|
||||
configuration itself is legal (e.g. a related-only domain)."""
|
||||
domains.validate_config(Config(domains={"a.com": DomainConfig()}))
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"b.com": True})})
|
||||
)
|
||||
|
||||
def test_related_origin_cap(self):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"company.com": DomainConfig(
|
||||
origins={f"app{i}.com": True for i in range(5)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="related origins"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"company.com": DomainConfig(
|
||||
origins={f"app{i}.com": True for i in range(6)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_star_origin_rejected(self):
|
||||
"""Plain '*' suggests 'anything goes' — the wildcard must be
|
||||
explicit and under the rp-id."""
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*": True})})
|
||||
)
|
||||
|
||||
def test_malformed_origin_hostname_rejected(self):
|
||||
"""No empty hostname labels — leading, trailing and double dots
|
||||
are invalid, in concrete entries and wildcard bases alike."""
|
||||
for key in (".a.com", "a..com", "a.com.", "http://.a.com:8080"):
|
||||
with pytest.raises(ValueError, match="Invalid origin"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={key: True})})
|
||||
)
|
||||
for key in ("*..a.com", "**..a.com"):
|
||||
with pytest.raises(ValueError, match="Invalid wildcard origin"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={key: True})})
|
||||
)
|
||||
|
||||
def test_subdomain_entry_is_in_domain(self):
|
||||
"""An entry within the rp-id domain is an ordinary in-domain
|
||||
sign-in site, never a related origin."""
|
||||
config = Config(domains={"a.com": DomainConfig(origins={"app.a.com": True})})
|
||||
domains.validate_config(config)
|
||||
reg = build_registry(config.domains)
|
||||
assert reg.get("a.com").related_origins == []
|
||||
|
||||
def test_wildcard_outside_rp_id_rejected(self):
|
||||
"""Related origins are individual hosts; wildcards must stay within
|
||||
the rp-id domain. Both wildcard forms are accepted in-domain."""
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})})
|
||||
)
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"**.a.com": True})})
|
||||
)
|
||||
with pytest.raises(ValueError, match="wildcard outside the rp-id"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"**.b.com": True})})
|
||||
)
|
||||
|
||||
def test_wildcard_auth_host_rejected(self):
|
||||
with pytest.raises(ValueError, match="cannot be the auth host"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"*.a.com": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_related_auth_host_rejected(self):
|
||||
"""The auth host is always in-domain; a related origin cannot
|
||||
carry the mark."""
|
||||
with pytest.raises(ValueError, match="cannot be the auth host"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.b.com": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_several_auth_hosts_rejected(self):
|
||||
with pytest.raises(ValueError, match="several origins as the auth host"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={
|
||||
"auth.a.com": OriginEntry(auth_host=True),
|
||||
"login.a.com": OriginEntry(auth_host=True),
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_auth_host_collision(self):
|
||||
with pytest.raises(ValueError, match="collides with a related origin"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.a.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"b.com": DomainConfig(origins={"auth.a.com": True}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_related_origin_may_fall_inside_other_domain(self):
|
||||
"""A related origin at/inside another domain's rp-id is allowed.
|
||||
The related listing wins dispatch over suffix matching (a host that
|
||||
*is* a configured rp-id always serves its own domain)."""
|
||||
config = Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"b.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
domains.validate_config(config)
|
||||
reg = build_registry(config.domains)
|
||||
assert reg.resolve("app.b.com").rp_id == "a.com"
|
||||
assert reg.resolve("b.com").rp_id == "b.com"
|
||||
|
||||
def test_related_origin_shared_when_covered_by_rp_id(self):
|
||||
"""Two domains may list the same related host when it falls inside
|
||||
a configured rp-id; otherwise the collision is rejected."""
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"c.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"b.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="configured for both"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"shared.com": True}),
|
||||
"c.com": DomainConfig(origins={"shared.com": True}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def test_auth_host_must_not_collide_with_rp_id(self):
|
||||
with pytest.raises(ValueError, match="collides with an rp-id"):
|
||||
domains.validate_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"b.a.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"b.a.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Best-effort serving: stored config sanitization
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanitizeConfig:
|
||||
"""Serving never fails on stored config problems; it degrades + warns."""
|
||||
|
||||
def test_cross_domain_origin_stays_as_related(self):
|
||||
"""An out-of-domain entry simply IS a related origin — no repair
|
||||
needed, no warning."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"localhost": DomainConfig(origins={"example.com": True})})
|
||||
)
|
||||
assert config.domains["localhost"].origins == {"example.com": True}
|
||||
assert not warnings
|
||||
domains.validate_config(config) # sanitized config is strict-clean
|
||||
|
||||
def test_star_origin_rewritten_explicit(self):
|
||||
"""Branch-era '*' shorthand is rewritten to '**.{rp-id}'; an auth
|
||||
mark on it is cleared."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*": True})})
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"**.a.com": True}
|
||||
assert any("**." in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"*": OriginEntry(auth_host=True)})
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"**.a.com": True}
|
||||
assert any("mark cleared" in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
def test_malformed_origin_dropped(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"https://": True})})
|
||||
)
|
||||
assert config.domains["a.com"].origins == {}
|
||||
assert warnings
|
||||
|
||||
def test_malformed_hostname_dropped(self):
|
||||
"""Empty hostname labels (leading/trailing/double dots) are dropped,
|
||||
from concrete entries and wildcard bases alike."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={
|
||||
".a.com": True,
|
||||
"a.com.": True,
|
||||
"**.a..com": True,
|
||||
"ok.a.com": True,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
assert list(config.domains["a.com"].origins) == ["ok.a.com"]
|
||||
assert len(warnings) == 3
|
||||
domains.validate_config(config) # sanitized config is strict-clean
|
||||
|
||||
def test_invalid_rp_id_domain_dropped(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"not a domain!": DomainConfig(), "ok.com": DomainConfig()})
|
||||
)
|
||||
assert list(config.domains) == ["ok.com"]
|
||||
assert any("dropped" in w for w in warnings)
|
||||
|
||||
def test_wildcard_outside_rp_id_dropped(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})})
|
||||
)
|
||||
assert config.domains["a.com"].origins == {}
|
||||
assert any("wildcard" in w for w in warnings)
|
||||
domains.validate_config(config) # sanitized config is strict-clean
|
||||
|
||||
def test_cap_exceeded_truncated(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={f"app{i}.com": True for i in range(6)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
assert len(config.domains["a.com"].origins) == 5
|
||||
assert any("maximum" in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
def test_related_auth_host_mark_cleared(self):
|
||||
"""An auth mark on a related (out-of-domain) origin is cleared."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.b.com": OriginEntry(auth_host=True)}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"auth.b.com": True}
|
||||
assert any("cannot be the auth host" in w for w in warnings)
|
||||
domains.validate_config(config)
|
||||
|
||||
def test_auth_host_colliding_with_rp_id_cleared(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={"auth.a.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"auth.a.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"auth.a.com": True}
|
||||
assert any("collides" in w for w in warnings)
|
||||
|
||||
def test_related_inside_other_domain_kept(self):
|
||||
"""A related origin falling inside another domain's rp-id is kept."""
|
||||
config, _ = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"app.b.com": True}),
|
||||
"b.com": DomainConfig(),
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"app.b.com": True}
|
||||
|
||||
def test_related_claimed_twice_first_wins(self):
|
||||
"""Two non-owner domains claiming one related host: first wins."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(origins={"shared.com": True}),
|
||||
"b.com": DomainConfig(origins={"shared.com": True}),
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.domains["a.com"].origins == {"shared.com": True}
|
||||
assert config.domains["b.com"].origins == {}
|
||||
assert any("first domain wins" in w for w in warnings)
|
||||
|
||||
def test_no_domains_is_fatal(self):
|
||||
with pytest.raises(ValueError, match="No servable domain"):
|
||||
domains.sanitize_config(Config(domains={}))
|
||||
with pytest.raises(ValueError, match="No servable domain"):
|
||||
domains.sanitize_config(Config(domains={"not a domain!": DomainConfig()}))
|
||||
|
||||
def test_build_serves_related_origin(self):
|
||||
reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})})
|
||||
domain = reg.get("localhost")
|
||||
assert domain.related_origins == ["https://example.com"]
|
||||
domain.passkey.validate_origin("https://example.com")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Origin validation semantics (Passkey)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOriginValidation:
|
||||
"""The allow-list is explicit: empty allows nothing, wildcards cover
|
||||
subtrees, related origins are additive exact matches."""
|
||||
|
||||
def test_empty_allow_list_denies_all(self):
|
||||
p = Passkey(rp_id="example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://app.example.com")
|
||||
|
||||
def test_allow_list_restricts_subtree(self):
|
||||
p = Passkey(rp_id="example.com", origins=["https://app.example.com"])
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://www.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com")
|
||||
|
||||
def test_related_origins_are_additive(self):
|
||||
p = Passkey(rp_id="example.com", related_origins=["https://app2.com"])
|
||||
assert p.validate_origin("https://app2.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://app.example.com") # nothing in-domain listed
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://other.com")
|
||||
|
||||
def test_double_star_matches_apex_and_any_depth(self):
|
||||
"""'**.example.com' covers the apex and subdomains at any depth
|
||||
(the shell-glob convention)."""
|
||||
p = Passkey(rp_id="example.com", origins=["**.example.com"])
|
||||
assert p.validate_origin("https://example.com") # apex
|
||||
assert p.validate_origin("https://app.example.com") # one level
|
||||
assert p.validate_origin("https://a.b.c.example.com") # any depth
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://anotherexample.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://other.com")
|
||||
|
||||
def test_single_star_matches_exactly_one_level(self):
|
||||
"""'*.example.com' covers exactly one subdomain level — neither the
|
||||
apex nor deeper levels."""
|
||||
p = Passkey(rp_id="example.com", origins=["*.example.com"])
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com") # apex excluded
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://a.b.example.com") # too deep
|
||||
|
||||
def test_wildcard_is_https_only(self):
|
||||
"""A '**.example.com' entry does not fall back to other schemes."""
|
||||
p = Passkey(rp_id="example.com", origins=["**.example.com"])
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("http://example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("http://app.example.com:8080")
|
||||
|
||||
def test_star_entry_rejected(self):
|
||||
with pytest.raises(ValueError, match="Invalid origin"):
|
||||
Passkey(rp_id="example.com", origins=["*"])
|
||||
|
||||
def test_malformed_hostname_rejected(self):
|
||||
"""Leading/trailing/double dots are invalid in any entry form."""
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", origins=["https://.example.com"])
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", origins=["**.a..example.com"])
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", related_origins=["https://other..com"])
|
||||
|
||||
def test_localhost_wildcard_matches_any_scheme_and_port(self):
|
||||
"""Under localhost, wildcards match any scheme and any port."""
|
||||
p = Passkey(rp_id="localhost", origins=["**.localhost"])
|
||||
assert p.validate_origin("http://localhost:8080")
|
||||
assert p.validate_origin("http://app.localhost:3000")
|
||||
assert p.validate_origin("http://a.b.localhost:3000")
|
||||
assert p.validate_origin("https://localhost")
|
||||
|
||||
def test_exact_entry_matches_scheme_and_port(self):
|
||||
p = Passkey(rp_id="localhost", origins=["http://localhost:4403"])
|
||||
assert p.validate_origin("http://localhost:4403")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://localhost:4403")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("http://localhost:4404")
|
||||
|
||||
def test_sub_wildcard_matches_only_its_subtree(self):
|
||||
p = Passkey(rp_id="example.com", origins=["**.app.example.com"])
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
assert p.validate_origin("https://www.app.example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://example.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://other.example.com")
|
||||
|
||||
def test_wildcard_related_origin_rejected(self):
|
||||
with pytest.raises(ValueError, match="wildcard"):
|
||||
Passkey(rp_id="example.com", related_origins=["*.other.com"])
|
||||
|
||||
def test_related_origins_combined_with_allow_list(self):
|
||||
p = Passkey(
|
||||
rp_id="example.com",
|
||||
origins=["https://app.example.com"],
|
||||
related_origins=["https://app2.com"],
|
||||
)
|
||||
assert p.validate_origin("https://app.example.com")
|
||||
assert p.validate_origin("https://app2.com")
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://www.example.com")
|
||||
|
||||
def test_constructor_rejects_mixed_up_fields(self):
|
||||
with pytest.raises(ValueError, match="related origin"):
|
||||
Passkey(rp_id="example.com", origins=["https://app2.com"])
|
||||
with pytest.raises(ValueError, match="within the rp-id domain"):
|
||||
Passkey(rp_id="example.com", related_origins=["https://app.example.com"])
|
||||
|
||||
def test_domain_wires_both_lists(self):
|
||||
reg = build_registry(ROR_CONFIG.domains)
|
||||
p = reg.get("company.com").passkey
|
||||
assert p.validate_origin("https://app.com") # related origin
|
||||
assert p.validate_origin("https://auth.company.com") # allow-listed
|
||||
with pytest.raises(ValueError, match="not allowed"):
|
||||
p.validate_origin("https://www.company.com") # not allow-listed
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ASGI dispatch
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispatchMiddleware:
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_unknown_host_421(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, sent = await drive_http(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"evil.com")]
|
||||
)
|
||||
assert stub.scope is None # Inner app not called
|
||||
assert sent[0]["type"] == "http.response.start"
|
||||
assert sent[0]["status"] == 421
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_dispatches_domain(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, _sent = await drive_http(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"app.com.")]
|
||||
)
|
||||
assert stub.scope is not None
|
||||
assert stub.scope["state"]["domain"].rp_id == "company.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_current_domain_set_inside_request(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
seen = {}
|
||||
|
||||
async def app(scope, receive, send):
|
||||
seen["domain"] = domains.current_domain()
|
||||
|
||||
await drive_http(DispatchMiddleware(app), [(b"host", b"pro.com")])
|
||||
assert seen["domain"].rp_id == "pro.com"
|
||||
# Contextvar is reset after the request; with several domains there
|
||||
# is no implicit current domain outside a request context.
|
||||
with pytest.raises(RuntimeError, match="request context"):
|
||||
domains.current_domain()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_unknown_host_closed(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"evil.com")]
|
||||
)
|
||||
assert stub.scope is None
|
||||
assert sent == [{"type": "websocket.close", "code": 1008}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_same_domain_origin(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"auth.company.com"), (b"origin", b"https://app.com")],
|
||||
)
|
||||
assert sent == []
|
||||
assert stub.scope["state"]["domain"].rp_id == "company.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_cross_domain_requires_origin_own_auth_host(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
# pro.com has no own auth host: its page may only connect to pro.com
|
||||
# hosts — the company.com auth host does not serve foreign domains
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"auth.company.com"), (b"origin", b"https://pro.com")],
|
||||
)
|
||||
assert stub.scope is None
|
||||
assert sent == [{"type": "websocket.close", "code": 1008}]
|
||||
|
||||
# pro.com page connecting to some other host: closed pre-accept
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"company.com"), (b"origin", b"https://pro.com")],
|
||||
)
|
||||
assert stub.scope is None
|
||||
assert sent == [{"type": "websocket.close", "code": 1008}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_cross_domain_via_own_auth_host(self):
|
||||
"""On a shared auth host (nested rp-ids), the WS Origin selects the
|
||||
domain: plain HTTP resolves to the longest-suffix claimant, but a
|
||||
WebSocket from another claimant's page is dispatched by Origin."""
|
||||
build_registry(
|
||||
{
|
||||
"com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
"company.com": DomainConfig(
|
||||
origins={"auth.company.com": OriginEntry(auth_host=True)}
|
||||
),
|
||||
}
|
||||
)
|
||||
# Host alone resolves to company.com (longest suffix)
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()), [(b"host", b"auth.company.com")]
|
||||
)
|
||||
assert stub.scope["state"]["domain"].rp_id == "company.com"
|
||||
# A page on com (the other claimant) is accepted: the Host is its
|
||||
# own auth host, and the Origin selects its domain
|
||||
stub, sent = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"auth.company.com"), (b"origin", b"https://com")],
|
||||
)
|
||||
assert sent == []
|
||||
assert stub.scope["state"]["domain"].rp_id == "com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_unknown_origin_uses_host_domain(self):
|
||||
build_registry(ROR_CONFIG.domains)
|
||||
# Missing origin
|
||||
stub, _ = await drive_ws(DispatchMiddleware(StubApp()), [(b"host", b"pro.com")])
|
||||
assert stub.scope["state"]["domain"].rp_id == "pro.com"
|
||||
# Unknown origin: host domain applies (endpoint-side validation decides)
|
||||
stub, _ = await drive_ws(
|
||||
DispatchMiddleware(StubApp()),
|
||||
[(b"host", b"pro.com"), (b"origin", b"https://evil.com")],
|
||||
)
|
||||
assert stub.scope["state"]["domain"].rp_id == "pro.com"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Domain binding of auth codes
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthCodeDomainBinding:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cookie_code_rejected_on_other_domain(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
code = authcode.store_cookie(
|
||||
authcode.CookieCode(
|
||||
session_key=session_token,
|
||||
created=datetime.now(UTC),
|
||||
rp_id="other.com",
|
||||
)
|
||||
)
|
||||
response = await client.post(
|
||||
"/auth/api/set-session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {code}",
|
||||
"Host": "localhost:4401",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oidc_code_is_host_independent(
|
||||
self, client: httpx.AsyncClient, test_db: DB, test_user, test_credential
|
||||
):
|
||||
"""OIDC codes carry no domain binding: the provider is
|
||||
instance-global, so a code is redeemable at any host."""
|
||||
oidc_client, secret = Client.create(
|
||||
name="Test Client",
|
||||
redirect_uris=["https://client.example/callback"],
|
||||
client_secret="topsecret",
|
||||
)
|
||||
token = "doesnotmatter1234"
|
||||
session = Session.create(
|
||||
user=test_user.uuid,
|
||||
credential=test_credential.uuid,
|
||||
key=hash_secret("oidc", token),
|
||||
host="other.com",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
validated=datetime.now(UTC),
|
||||
client=oidc_client.uuid,
|
||||
rp_id="other.com",
|
||||
issuer="https://other.com",
|
||||
)
|
||||
store = test_db._store
|
||||
with store.transaction("seed_oidc_session"):
|
||||
test_db.oidc.clients[oidc_client.uuid] = oidc_client
|
||||
test_db.sessions[session.key] = session
|
||||
|
||||
code = authcode.store_oidc(
|
||||
authcode.OIDCCode(
|
||||
session_key=token,
|
||||
created=datetime.now(UTC),
|
||||
redirect_uri="https://client.example/callback",
|
||||
scope="openid",
|
||||
)
|
||||
)
|
||||
response = await client.post(
|
||||
"/auth/oidc/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": "https://client.example/callback",
|
||||
"client_id": str(oidc_client.uuid),
|
||||
"client_secret": secret,
|
||||
},
|
||||
headers={"Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Legacy database conversion
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_db(path) -> DB:
|
||||
async def _read() -> DB:
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(path), new_db)
|
||||
await kanta.open(readonly=True)
|
||||
return kanta.data
|
||||
|
||||
return asyncio.run(_read())
|
||||
|
||||
|
||||
async def _write_legacy(src_file, config: LegacyConfig) -> None:
|
||||
kanta = Kanta(str(src_file), LegacyDB())
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:seed"):
|
||||
kanta.data.config = config
|
||||
await kanta.close()
|
||||
|
||||
|
||||
class TestLegacyConversion:
|
||||
def test_convert_stamps_domain_everywhere(self, tmp_path):
|
||||
src = tmp_path / "example.com.paskiadb"
|
||||
src.mkdir()
|
||||
src_file = src / "main.db"
|
||||
|
||||
cred_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
|
||||
user_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c6")
|
||||
|
||||
async def _write() -> None:
|
||||
kanta = Kanta(str(src_file), LegacyDB())
|
||||
await kanta.open()
|
||||
with kanta.transaction("test:seed"):
|
||||
kanta.data.config = LegacyConfig(
|
||||
rp_id="example.com",
|
||||
rp_name="Example",
|
||||
origins=["https://app.example.com", "*.example.com"],
|
||||
)
|
||||
kanta.data.credentials[cred_uuid] = LegacyCredential(
|
||||
credential_id=b"credential-id",
|
||||
user_uuid=user_uuid,
|
||||
aaguid=UUID(int=0),
|
||||
public_key=b"public-key",
|
||||
sign_count=3,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
kanta.data.sessions["session-key"] = LegacySession(
|
||||
user_uuid=user_uuid,
|
||||
credential_uuid=cred_uuid,
|
||||
host="example.com",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
validated=datetime.now(UTC),
|
||||
)
|
||||
kanta.data.oidc = OIDC(key=b"legacy-signing-key")
|
||||
await kanta.close()
|
||||
|
||||
asyncio.run(_write())
|
||||
|
||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||
domain = config.domains["example.com"]
|
||||
assert domain.rp_name == "Example"
|
||||
# Legacy wildcard origins convert as-is (https-only outside localhost)
|
||||
assert domain.origins == {"app.example.com": True, "*.example.com": True}
|
||||
|
||||
converted = _read_db(tmp_path / "paskia.kantadb")
|
||||
assert converted.credentials[cred_uuid].rp_id == "example.com"
|
||||
assert converted.sessions["session-key"].rp_id == "example.com"
|
||||
# The legacy OIDC provider carries over as the instance-global one
|
||||
assert converted.oidc.key == b"legacy-signing-key"
|
||||
|
||||
def test_convert_empty_origins_seeds_wildcard(self, tmp_path):
|
||||
"""Legacy 'no origins' meant the whole rp-id domain; the new format
|
||||
makes that explicit as '**.{rp-id}'."""
|
||||
src_file = tmp_path / "main.db"
|
||||
asyncio.run(
|
||||
_write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex"))
|
||||
)
|
||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
||||
assert config.domains["example.com"].origins == {"**.example.com": True}
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Transaction log censoring
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLogCensoring:
|
||||
def test_oidc_key_values_hidden(self):
|
||||
assert format_log_uuid(b"raw-key-material", "oidc.key") == "<hidden>"
|
||||
|
||||
def test_oidc_key_path_component_visible(self):
|
||||
# The path component itself must stay visible ("oidc.key = <hidden>")
|
||||
assert format_log_uuid("key", "oidc.key") is None
|
||||
|
||||
def test_other_paths_unaffected(self):
|
||||
assert format_log_uuid("not-a-uuid", "oidc.clients") is None
|
||||
assert format_log_uuid("not-a-uuid", "config.domains") is None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Bootstrap caveat: admin credential is checked on the configured domains
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBootstrapCaveat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_without_credentials_gets_link(
|
||||
self, test_db: DB, domain_registry
|
||||
):
|
||||
assert await check_admin_credentials() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_with_domain_credential_ok(
|
||||
self, test_db: DB, domain_registry, test_user, test_credential
|
||||
):
|
||||
assert await check_admin_credentials() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_with_only_unconfigured_domain_credential_gets_link(
|
||||
self, test_db: DB, domain_registry, test_user
|
||||
):
|
||||
"""A passkey under an rp-id outside the config does not satisfy the check."""
|
||||
cred = Credential.create(
|
||||
credential_id=os.urandom(32),
|
||||
user=test_user.uuid,
|
||||
aaguid=UUID(int=0),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
rp_id="example.com",
|
||||
)
|
||||
create_credential(cred)
|
||||
assert await check_admin_credentials() is True
|
||||
@@ -15,7 +15,6 @@ from urllib.parse import urlsplit
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia.db.paths import db_file_path, users_root_path
|
||||
from tests.conftest import auth_headers, create_test_image_bytes
|
||||
|
||||
|
||||
@@ -93,8 +92,6 @@ class TestUserAvatar:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Uploading a WebP avatar should store and expose the canonical URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
upload_bytes = create_test_image_bytes()
|
||||
|
||||
response = await client.put(
|
||||
@@ -138,8 +135,6 @@ class TestUserAvatar:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Avatar uploads must already be browser-prepared WebP."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={
|
||||
@@ -165,8 +160,6 @@ class TestUserAvatar:
|
||||
monkeypatch,
|
||||
):
|
||||
"""Deleting avatar should clear the user avatar URL."""
|
||||
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
|
||||
|
||||
await client.put(
|
||||
f"/auth/api/user/{test_user.uuid}/profile.webp",
|
||||
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
|
||||
@@ -203,30 +196,6 @@ class TestUserAvatar:
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch):
|
||||
legacy_path = tmp_path / "legacy.paskiadb"
|
||||
legacy_bytes = b'{"v":0}\n'
|
||||
legacy_path.write_bytes(legacy_bytes)
|
||||
|
||||
monkeypatch.setenv("PASKIA_DB", str(legacy_path))
|
||||
|
||||
db_path = db_file_path(create_root=True)
|
||||
|
||||
assert legacy_path.is_dir()
|
||||
assert db_path == legacy_path / "main.db"
|
||||
assert db_path.read_bytes() == legacy_bytes
|
||||
|
||||
|
||||
def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch):
|
||||
root_path = tmp_path / "instance-root"
|
||||
monkeypatch.setenv("PASKIA_DB", str(root_path))
|
||||
|
||||
users_path = users_root_path(create_root=True)
|
||||
|
||||
assert users_path == root_path / "users"
|
||||
assert users_path.parent == root_path
|
||||
|
||||
|
||||
class TestUserLogoutAll:
|
||||
"""Tests for POST /auth/api/user/logout-all"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user