Files
paskia/tests/test_api.py
T
LeoVasanko 10af29f92d 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
2026-09-07 22:02:06 +00:00

1076 lines
40 KiB
Python

"""
Tests for the core API endpoints (/auth/api/).
These tests cover:
- /auth/api/settings - Public settings endpoint
- /auth/api/validate - Session validation
- /auth/api/forward - Forward auth for reverse proxies
- /auth/api/logout - Session logout
- /auth/api/user-info - User information
- /auth/api/set-session - Set session from bearer token
"""
import secrets
from datetime import UTC, datetime, timedelta
from urllib.parse import urlsplit
from uuid import UUID
import httpx
import pytest
from paskia import authcode, db, domains
from paskia.authsession import EXPIRES
from paskia.db import delete_session
from paskia.db.structs import Client, Config, DomainConfig, OriginEntry
from paskia.fastapi.api import _REFRESH_INTERVAL
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
class TestSettingsEndpoint:
"""Tests for GET /auth/api/settings"""
@pytest.mark.asyncio
async def test_get_settings_returns_rp_info(self, client: httpx.AsyncClient):
"""Settings endpoint should return RP configuration."""
response = await client.get("/auth/api/settings")
assert response.status_code == 200
data = response.json()
assert "rp_id" in data
assert "rp_name" in data
assert "session_cookie" in data
assert data["rp_id"] == "localhost"
assert data["rp_name"] == "localhost"
assert data["session_cookie"] == "__Host-paskia"
@pytest.mark.asyncio
async def test_settings_includes_ui_base_path(self, client: httpx.AsyncClient):
"""Settings should include UI base path."""
response = await client.get("/auth/api/settings")
data = response.json()
assert "ui_base_path" in data
@pytest.mark.asyncio
async def test_openid_configuration_includes_picture_claim(
self, client: httpx.AsyncClient
):
"""Discovery document should advertise picture claim support."""
response = await client.get("/.well-known/openid-configuration")
assert response.status_code == 200
assert "picture" in response.json()["claims_supported"]
class TestAvatarUrls:
"""Tests for avatar URL helpers."""
def test_avatar_url_uses_canonical_public_path_in_auth_host_mode(
self, tmp_path, monkeypatch
):
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
domains.configure(listen=None)
domains.init_registry(
Config(
domains={
"zi.fi": DomainConfig(
origins={"auth.zi.fi": OriginEntry(auth_host=True)}
)
}
)
)
# 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")
assert avatar.avatar_url(user_uuid) == (
"https://auth.zi.fi/auth/api/user/"
"019c6831-84cf-7b88-b66c-c8165890b7c5/profile.webp"
)
class TestValidateEndpoint:
"""Tests for POST /auth/api/validate"""
@pytest.mark.asyncio
async def test_validate_without_auth_returns_401(self, client: httpx.AsyncClient):
"""Validate without session should return 401."""
response = await client.post("/auth/api/validate")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_validate_with_invalid_token_returns_error(
self, client: httpx.AsyncClient
):
"""Validate with invalid token should return 4xx error."""
response = await client.post(
"/auth/api/validate",
headers=auth_headers("invalid_token!!"),
)
# Invalid token format returns 400, expired/missing returns 401
assert response.status_code in (400, 401)
@pytest.mark.asyncio
async def test_validate_with_valid_token_returns_200(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate with valid session should return success."""
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
assert "ctx" in data
assert "user" in data["ctx"]
assert "uuid" in data["ctx"]["user"]
@pytest.mark.asyncio
async def test_validate_with_permission_check(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate should check permissions when provided."""
# Admin user should pass admin permission check
response = await client.post(
"/auth/api/validate?perm=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_validate_permission_denied_for_regular_user(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""Regular user should fail admin permission check."""
response = await client.post(
"/auth/api/validate?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
},
)
assert response.status_code == 403
class TestForwardEndpoint:
"""Tests for GET /auth/api/forward (reverse proxy auth)"""
@pytest.mark.asyncio
async def test_forward_without_auth_returns_401(self, client: httpx.AsyncClient):
"""Forward auth without session should return 401."""
response = await client.get("/auth/api/forward")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_forward_401_json_response(self, client: httpx.AsyncClient):
"""Forward auth 401 should include auth iframe info for JSON clients."""
response = await client.get(
"/auth/api/forward",
headers={"Accept": "application/json"},
)
assert response.status_code == 401
data = response.json()
assert "auth" in data
assert "iframe" in data["auth"]
assert "mode" in data["auth"]
assert data["auth"]["mode"] == "login"
@pytest.mark.asyncio
async def test_forward_with_valid_session_returns_204(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with valid session should return 204 with headers."""
response = await client.get(
"/auth/api/forward",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
# Check Remote-* headers
assert "Remote-User" in response.headers
assert "Remote-Name" in response.headers
assert "Remote-Groups" in response.headers
assert "Remote-Org" in response.headers
@pytest.mark.asyncio
async def test_forward_with_permission_returns_204(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with valid permission should return 204."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_forward_permission_denied_returns_403(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""Forward auth with missing permission should return 403."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_forward_403_json_includes_forbidden_mode(
self, client: httpx.AsyncClient, regular_session_token: str
):
"""403 response should include forbidden mode for iframe."""
response = await client.get(
"/auth/api/forward?perm=auth:admin",
headers={
**auth_headers(regular_session_token),
"Host": "localhost:4401",
"Accept": "application/json",
},
)
assert response.status_code == 403
data = response.json()
assert "auth" in data
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"""
@pytest.mark.asyncio
async def test_or_alternative_matches(
self, client: httpx.AsyncClient, session_token: str
):
"""Group is satisfied when any alternative matches."""
response = await client.post(
"/auth/api/validate?perm=missing:scope|auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_or_no_alternative_matches(
self, client: httpx.AsyncClient, session_token: str
):
"""Group fails when no alternative matches."""
response = await client.post(
"/auth/api/validate?perm=missing:a|missing:b",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_or_combined_with_and_group(
self, client: httpx.AsyncClient, session_token: str
):
"""Space-separated groups are ANDed with OR groups."""
response = await client.post(
"/auth/api/validate?perm=auth:admin|missing:a+missing:b",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_multiple_perm_args_and_semantics(
self, client: httpx.AsyncClient, session_token: str
):
"""Repeated perm arguments remain ANDed."""
ok = await client.post(
"/auth/api/validate?perm=auth:admin&perm=auth:admin|missing:a",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert ok.status_code == 200
denied = await client.post(
"/auth/api/validate?perm=auth:admin&perm=missing:a",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert denied.status_code == 403
@pytest.mark.asyncio
async def test_forward_or_semantics(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward endpoint supports OR semantics too."""
response = await client.get(
"/auth/api/forward?perm=missing:a|auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_extra_spaces_tolerated(
self, client: httpx.AsyncClient, session_token: str
):
"""Leading, trailing and repeated spaces between groups are tolerated."""
response = await client.post(
"/auth/api/validate?perm=%20auth:admin%20%20",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_pipe_in_separate_arg_does_not_weaken(
self, client: httpx.AsyncClient, session_token: str
):
"""perm=auth:admin&perm=|bar is a syntax error, not an OR for auth:admin."""
response = await client.post(
"/auth/api/validate?perm=auth:admin&perm=|bar",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_forward_400_identifies_origin_without_echoing_args(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward 400 names the endpoint and does not echo query args."""
response = await client.get(
"/auth/api/forward?perm=a||b",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
detail = response.json()["detail"]
assert detail.startswith("/auth/api/forward")
assert "a||b" not in detail
@pytest.mark.asyncio
@pytest.mark.parametrize(
"query",
[
"perm=", # empty value
"perm=a||b", # empty alternative
"perm=|a", # leading pipe
"perm=a|", # trailing pipe
"perm=a%20|%20b", # spaces around pipe
"perm=a%2Bb", # percent-encoded plus
"perm=a,b", # character not allowed in scopes
],
)
async def test_invalid_perm_syntax_returns_400(
self, client: httpx.AsyncClient, session_token: str, query: str
):
"""Out-of-spec perm values are rejected with 400, not guessed at."""
for path in ("/auth/api/validate", "/auth/api/forward"):
if path.endswith("validate"):
response = await client.post(
f"{path}?{query}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
else:
response = await client.get(
f"{path}?{query}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400, f"{path}?{query}"
class TestPermParsing:
"""Unit tests for permutil.parse_perm_args"""
def test_single_scope(self):
assert permutil.parse_perm_args(["auth:admin"]) == [("auth:admin",)]
def test_space_separated_groups(self):
assert permutil.parse_perm_args(["a b", "c"]) == [("a",), ("b",), ("c",)]
def test_or_group(self):
assert permutil.parse_perm_args(["a|b c"]) == [("a", "b"), ("c",)]
def test_wildcard_allowed(self):
assert permutil.parse_perm_args(["myapp:*|other"]) == [("myapp:*", "other")]
def test_extra_spaces_tolerated(self):
assert permutil.parse_perm_args([" a b ", "c"]) == [("a",), ("b",), ("c",)]
@pytest.mark.parametrize(
"values",
[
[""],
["a||b"],
["a | b"],
["a| b"],
["a+b"],
["a,b"],
["a\tb"],
],
)
def test_syntax_errors(self, values):
with pytest.raises(ValueError):
permutil.parse_perm_args(values)
class TestPermWildcards:
"""Unit tests for filename-like wildcard semantics in scope patterns"""
@pytest.mark.parametrize(
"pattern,scope,expected",
[
("myapp:*", "myapp:read", True),
("myapp:*", "myapp:read:all", False), # * stays within one element
("myapp:**", "myapp:read:all", True), # ** crosses elements
("myapp:**", "myapp:", True),
("myapp:re*", "myapp:read", True), # partial element, suffix wildcard
("myapp:*ad", "myapp:read", True), # prefix wildcard
("myapp:r*d", "myapp:read", True), # text on both sides
("myapp:r*d", "myapp:redo", False),
("*:read", "myapp:read", True),
("*", "myapp:read", False),
("**", "myapp:read", True),
("myapp:*:all", "myapp:read:all", True),
("myapp:*:all", "myapp:read:write:all", False),
# regex metacharacters valid in scopes are matched literally
("my.app:*", "my.app:read", True),
("my.app:*", "myXapp:read", False),
("myapp:v1.*", "myapp:v1.2", True),
("myapp:v1.*", "myapp:v1x2", False),
("my-app_*:~*", "my-app_x:~tmp", True),
# slash is a literal separator; * crosses neither / nor :
("myapp:path:/api/clients:write", "myapp:path:/api/clients:write", True),
("myapp:path:*", "myapp:path:/api/clients", False),
("myapp:path:**", "myapp:path:/api/clients", True),
("myapp:path:/api/*:write", "myapp:path:/api/clients:write", True),
("myapp:path:/api/*:write", "myapp:path:/api/v2/clients:write", False),
("myapp:path:/api/**:write", "myapp:path:/api/v2/clients:write", True),
],
)
def test_wildcard_matching(self, pattern, scope, expected):
assert permutil.has_all_scopes_groups({scope}, [(pattern,)]) is expected
class TestLogoutEndpoint:
"""Tests for POST /auth/api/logout"""
@pytest.mark.asyncio
async def test_logout_without_session_returns_message(
self, client: httpx.AsyncClient
):
"""Logout without session should return already logged out message."""
response = await client.post("/auth/api/logout")
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "Already logged out" in data["message"]
@pytest.mark.asyncio
async def test_logout_with_valid_session(
self, client: httpx.AsyncClient, session_token: str
):
"""Logout with valid session should succeed and clear session."""
response = await client.post(
"/auth/api/logout",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "Logged out successfully" in data["message"]
# Verify session is no longer valid
response2 = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response2.status_code == 401
class TestUserInfoEndpoint:
"""Tests for GET /auth/api/user-info"""
@pytest.mark.asyncio
async def test_user_info_without_auth_returns_401(self, client: httpx.AsyncClient):
"""User info without session should return 401."""
response = await client.get("/auth/api/user-info")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_user_info_with_valid_session(
self, client: httpx.AsyncClient, session_token: str, test_user
):
"""User info with valid session should return user data."""
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "user" in data
assert data["user"]["uuid"] == str(test_user.uuid)
assert data["user"]["display_name"] == test_user.display_name
@pytest.mark.asyncio
async def test_user_info_includes_credentials(
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's credentials."""
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "credentials" in data
assert len(data["credentials"]) >= 1
@pytest.mark.asyncio
async def test_user_info_includes_sessions(
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's active sessions."""
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "sessions" in data
assert len(data["sessions"]) >= 1
@pytest.mark.asyncio
async def test_user_info_includes_permissions(
self, client: httpx.AsyncClient, session_token: str
):
"""User info should include user's permissions."""
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert "permissions" in data
@pytest.mark.asyncio
async def test_user_info_includes_avatar_url(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""User info should include the canonical avatar URL when present."""
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert upload.status_code == 200
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
avatar_url = data["user"]["avatar_url"]
parts = urlsplit(avatar_url)
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
assert parts.query == ""
@pytest.mark.asyncio
async def test_avatar_route_returns_304_for_matching_etag(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""Avatar route should honor If-None-Match for unchanged avatars."""
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert upload.status_code == 200
parts = urlsplit(upload.json()["avatar_url"])
first = await client.get(parts.path, headers={"Host": "localhost:4401"})
assert first.status_code == 200
response = await client.get(
f"/auth/api/user/{test_user.uuid}/profile.webp",
headers={
"Host": "localhost:4401",
"If-None-Match": first.headers["etag"],
},
)
assert response.status_code == 304
assert response.headers["etag"] == first.headers["etag"]
class TestOidcUserInfoEndpoint:
"""Tests for OIDC userinfo metadata relevant to avatars."""
@pytest.mark.asyncio
async def test_userinfo_includes_picture_claim(
self,
client: httpx.AsyncClient,
test_db,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""OIDC userinfo should expose picture when profile scope is granted."""
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert upload.status_code == 200
avatar_url = upload.json()["avatar_url"]
oidc_client, _secret = Client.create(
name="Test Client",
redirect_uris=["https://client.example/callback"],
client_secret="topsecret",
)
store = test_db._store
if store is None:
raise RuntimeError("Test DB store is not initialized")
with store.transaction("create_test_oidc_client"):
test_db.oidc.clients[oidc_client.uuid] = oidc_client
access_token = oidjwt.create_access_token(
issuer="http://localhost:4401",
subject=test_user.uuid,
audience=str(oidc_client.uuid),
scope="openid profile",
)
response = await client.get(
"/auth/oidc/userinfo",
headers={
"Authorization": f"Bearer {access_token}",
"Host": "localhost:4401",
},
)
assert response.status_code == 200
data = response.json()
assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path
assert data["picture"].startswith("http")
class TestSetSessionEndpoint:
"""Tests for POST /auth/api/set-session"""
@pytest.mark.asyncio
async def test_set_session_without_bearer_returns_400(
self, client: httpx.AsyncClient
):
"""Set session without bearer token should return 400."""
response = await client.post("/auth/api/set-session")
assert response.status_code == 400
@pytest.mark.asyncio
async def test_set_session_with_valid_bearer_token(
self, client: httpx.AsyncClient, session_token: str
):
"""Set session with valid auth code as bearer should set cookie."""
code = authcode.store_cookie(
authcode.CookieCode(
session_key=session_token,
created=datetime.now(UTC),
rp_id="localhost",
)
)
response = await client.post(
"/auth/api/set-session",
headers={
"Authorization": f"Bearer {code}",
"Host": "localhost:4401",
},
)
assert response.status_code == 200
data = response.json()
assert "user" in data
# Check that Set-Cookie header is present
assert "set-cookie" in response.headers
class TestErrorHandling:
"""Tests for API error handling"""
@pytest.mark.asyncio
async def test_invalid_endpoint_returns_404(self, client: httpx.AsyncClient):
"""Request to non-existent endpoint should return 404."""
response = await client.get("/auth/api/nonexistent")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_error_response_on_bad_token(self, client: httpx.AsyncClient):
"""Bad token should return error response."""
response = await client.post(
"/auth/api/validate",
headers=auth_headers("expired_token!"),
)
# Malformed token returns 400, expired returns 401
assert response.status_code in (400, 401)
class TestForwardAuthHtmlResponse:
"""Tests for forward auth HTML responses"""
@pytest.mark.asyncio
async def test_forward_with_expired_session_clears_cookie(
self, client: httpx.AsyncClient
):
"""Forward auth with expired session should trigger clear_session path."""
# Use a well-formed but non-existent session token
fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters
response = await client.get(
"/auth/api/forward",
headers={
**auth_headers(fake_token),
"Host": "localhost:4401",
"Accept": "application/json",
},
)
assert response.status_code == 401
# Verify the response contains auth info for re-login
data = response.json()
assert "auth" in data
assert data["auth"]["mode"] == "login"
class TestTokenInfoEndpoint:
"""Tests for token-info endpoint with reset tokens"""
@pytest.mark.asyncio
async def test_token_info_with_invalid_token(self, client: httpx.AsyncClient):
"""Token info with invalid token format should return 400."""
response = await client.get(
"/auth/api/token-info",
headers={"Authorization": "Bearer invalid-token-format"},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_token_info_with_nonexistent_token(self, client: httpx.AsyncClient):
"""Token info with well-formed but non-existent token should return 401."""
fake_token = generate()
response = await client.get(
"/auth/api/token-info",
headers={"Authorization": f"Bearer {fake_token}"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_token_info_with_valid_token(
self, client: httpx.AsyncClient, reset_token: str, test_user
):
"""Token info with valid reset token should return token type and display name."""
response = await client.get(
"/auth/api/token-info",
headers={"Authorization": f"Bearer {reset_token}"},
)
assert response.status_code == 200
data = response.json()
assert "token_type" in data
assert "display_name" in data
assert data["display_name"] == test_user.display_name
class TestSetSessionErrors:
"""Tests for set-session error cases"""
@pytest.mark.asyncio
async def test_set_session_with_invalid_bearer_token(
self, client: httpx.AsyncClient
):
"""Set session with invalid (malformed) bearer token should return 401."""
response = await client.post(
"/auth/api/set-session",
headers={
"Authorization": "Bearer invalid_token_here", # Wrong length (18 chars)
"Host": "localhost:4401",
},
)
# Invalid token returns 401 (session not found)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_set_session_with_nonexistent_token(self, client: httpx.AsyncClient):
"""Set session with valid format but non-existent token should fail."""
# Use a well-formed 16-char token that doesn't exist in DB
fake_token = "aaaaaaaaaaaaaaaa" # Exactly 16 characters
response = await client.post(
"/auth/api/set-session",
headers={
"Authorization": f"Bearer {fake_token}",
"Host": "localhost:4401",
},
)
# Non-existent session returns 401 (session expired)
assert response.status_code == 401
class TestValidateSessionRefresh:
"""Tests for session refresh behavior in validate endpoint"""
@pytest.mark.asyncio
async def test_validate_does_not_refresh_within_interval(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate should not refresh session if within refresh interval."""
# First call - may or may not refresh depending on session age
response1 = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response1.status_code == 200
# Second call immediately after - should NOT refresh (within 5 min interval)
response2 = await client.post(
"/auth/api/validate",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response2.status_code == 200
data = response2.json()
# Session shouldn't be renewed since we're within the refresh interval
assert data["renewed"] is False
@pytest.mark.asyncio
async def test_validate_with_expired_session_during_refresh(
self, client: httpx.AsyncClient, test_db
):
"""Validate should handle session expiry during refresh attempt."""
# Create a token but don't create a session for it
token = secrets.token_urlsafe(12)
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(token), "Host": "localhost:4401"},
)
# Should return 401 for non-existent session
assert response.status_code == 401
@pytest.mark.asyncio
async def test_validate_session_refresh_fails_concurrent_logout(
self,
client: httpx.AsyncClient,
test_db,
test_user,
test_credential,
):
"""Validate should return 401 if session disappears during refresh."""
# Create a session with a short remaining duration to trigger refresh
db_key, secret = create_test_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
duration=EXPIRES - timedelta(minutes=10),
)
# Delete the session right before validate tries to refresh
delete_session(db_key)
response = await client.post(
"/auth/api/validate",
headers={**auth_headers(secret), "Host": "localhost:4401"},
)
# Session was found initially but disappeared during refresh
assert response.status_code == 401
class TestForwardAuthMaxAge:
"""Tests for forward auth max_age parameter"""
@pytest.mark.asyncio
async def test_forward_with_max_age_recent_auth(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with max_age should pass for recent authentication."""
response = await client.get(
"/auth/api/forward?max_age=1h",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
# Recently authenticated session should pass
assert response.status_code == 204
@pytest.mark.asyncio
async def test_forward_with_invalid_max_age_format(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward auth with invalid max_age format should log warning but succeed."""
response = await client.get(
"/auth/api/forward?max_age=invalid",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
# Invalid format is logged but request proceeds
assert response.status_code == 204
class TestValidateWithMaxAge:
"""Tests for validate endpoint with max_age parameter"""
@pytest.mark.asyncio
async def test_validate_with_max_age(
self, client: httpx.AsyncClient, session_token: str
):
"""Validate with max_age should check authentication age."""
response = await client.post(
"/auth/api/validate?max_age=1h",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
# This exercises the max_age path - but isn't defined in validate
# Actually validate doesn't have max_age - this tests that unknown params are ignored
assert response.status_code == 200
class TestValidateRenewParameter:
"""Tests for the renew query parameter on /auth/api/validate."""
@pytest.mark.asyncio
async def test_validate_renew_false_skips_renewal(
self, client: httpx.AsyncClient, session_token: str, test_db
):
"""renew=0 should skip session renewal and leave metadata untouched."""
key = hash_secret("cookie", session_token)
old_validated = datetime.now(UTC) - _REFRESH_INTERVAL - timedelta(minutes=1)
db.update_session(key, validated=old_validated)
original_ua = test_db.sessions[key].user_agent
response = await client.post(
"/auth/api/validate?renew=0",
headers={
**auth_headers(session_token),
"Host": "localhost:4401",
"User-Agent": "different-ua",
},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
assert data["renewed"] is False
assert "set-cookie" not in response.headers
assert test_db.sessions[key].validated == old_validated
assert test_db.sessions[key].user_agent == original_ua
@pytest.mark.asyncio
async def test_validate_renew_true_renews_old_session(
self, client: httpx.AsyncClient, session_token: str, test_db
):
"""Explicit renew=1 should renew an aged session and return Set-Cookie."""
key = hash_secret("cookie", session_token)
old_validated = datetime.now(UTC) - _REFRESH_INTERVAL - timedelta(minutes=1)
db.update_session(key, validated=old_validated)
response = await client.post(
"/auth/api/validate?renew=1",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert data["valid"] is True
assert data["renewed"] is True
assert "set-cookie" in response.headers
assert test_db.sessions[key].validated > old_validated