OAuth2 OpenID Connect provider support, API and DB refactoring (#3)
Allows Paskia to authenticate the user to a client site. - User friendly client registration flow on the admin app - Redirect-based authentication flow (per spec) - Backchannel logout both ways to keep sessions synchronized - Groups integrated with Paskia's permission system - Adds email, preferred username and telephone fields on user profile - All new user basic info layout to show the new information, better looks - API and DB structures redesigned - Various unrelated fixes to theming and layout
This commit was merged in pull request #3.
This commit is contained in:
+53
-10
@@ -9,12 +9,17 @@ Since we can't emulate WebAuthn passkeys, we create sessions directly
|
||||
in the database to test authenticated endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -22,6 +27,7 @@ import pytest_asyncio
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import globals as paskia_globals
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import (
|
||||
Config,
|
||||
Credential,
|
||||
@@ -33,14 +39,15 @@ from paskia.db import (
|
||||
create_credential,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_session,
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.operations import DB
|
||||
from paskia.db.structs import Session
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -60,7 +67,6 @@ async def test_db() -> AsyncGenerator[DB, None]:
|
||||
- A default organization with Administration role
|
||||
- An admin user with the Administration role
|
||||
"""
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||
db = DB(config=Config(rp_id="test.example.com"))
|
||||
store = JsonlStore(db, f.name)
|
||||
@@ -179,13 +185,11 @@ async def session_token(
|
||||
test_db: DB, test_user: User, test_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for the admin user and return the token."""
|
||||
return create_session(
|
||||
_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",
|
||||
)
|
||||
return secret
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -193,13 +197,11 @@ async def regular_session_token(
|
||||
test_db: DB, regular_user: User, regular_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for a regular user and return the token."""
|
||||
return create_session(
|
||||
_db_key, secret = create_test_session(
|
||||
user_uuid=regular_user.uuid,
|
||||
credential_uuid=regular_credential.uuid,
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
)
|
||||
return secret
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -241,3 +243,44 @@ def auth_cookie(token: str) -> httpx.Cookies:
|
||||
cookies = httpx.Cookies()
|
||||
cookies.set(AUTH_COOKIE_NAME, token, domain="localhost")
|
||||
return cookies
|
||||
|
||||
|
||||
def create_test_session(
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
host: str = "localhost",
|
||||
ip: str = "127.0.0.1",
|
||||
user_agent: str = "pytest",
|
||||
duration: timedelta | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Create a test session. Returns (key, token) tuple.
|
||||
|
||||
- key: str used for session lookup (base64url encoded)
|
||||
- token: stored in cookie/sent to client
|
||||
"""
|
||||
if duration is None:
|
||||
duration = SESSION_LIFETIME
|
||||
if user_uuid not in ops_db._db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
if credential_uuid not in ops_db._db.credentials:
|
||||
raise ValueError(f"Credential {credential_uuid} not found")
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
key = base64url.enc(hash_secret("cookie", token))
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
key=key,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
validated=now,
|
||||
)
|
||||
if session.key in ops_db._db.sessions:
|
||||
raise ValueError("Session already exists")
|
||||
with ops_db._db.transaction("create_test_session"):
|
||||
session.store(now)
|
||||
return session.key, token
|
||||
|
||||
+48
-40
@@ -16,6 +16,7 @@ import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -33,11 +34,11 @@ from paskia.db import (
|
||||
create_org,
|
||||
create_permission,
|
||||
create_role,
|
||||
create_session,
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.operations import DB
|
||||
from tests.conftest import auth_headers
|
||||
from paskia.util.crypto import hash_secret
|
||||
from tests.conftest import auth_headers, create_test_session
|
||||
|
||||
# -------------------- Additional Fixtures --------------------
|
||||
|
||||
@@ -97,13 +98,11 @@ async def second_org_session_token(
|
||||
test_db: DB, second_org_user: User, second_org_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for the second org admin user."""
|
||||
return create_session(
|
||||
_db_key, secret = create_test_session(
|
||||
user_uuid=second_org_user.uuid,
|
||||
credential_uuid=second_org_credential.uuid,
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
)
|
||||
return secret
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -153,13 +152,11 @@ async def org_admin_session_token(
|
||||
test_db: DB, org_admin_user: User, org_admin_credential: Credential
|
||||
) -> str:
|
||||
"""Create a session for the org admin user."""
|
||||
return create_session(
|
||||
_db_key, secret = create_test_session(
|
||||
user_uuid=org_admin_user.uuid,
|
||||
credential_uuid=org_admin_credential.uuid,
|
||||
host="localhost",
|
||||
ip="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
)
|
||||
return secret
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -182,7 +179,7 @@ class TestExceptionHandlers:
|
||||
async def test_auth_exception_handler(self, client: httpx.AsyncClient):
|
||||
"""AuthException should return proper JSON with auth info."""
|
||||
# Accessing admin without auth triggers AuthException
|
||||
response = await client.get("/auth/api/admin/orgs")
|
||||
response = await client.get("/auth/api/admin/info")
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
@@ -219,7 +216,7 @@ class TestAdminOrganizations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orgs_requires_auth(self, client: httpx.AsyncClient):
|
||||
"""List orgs without auth should return 401."""
|
||||
response = await client.get("/auth/api/admin/orgs")
|
||||
response = await client.get("/auth/api/admin/info")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -228,7 +225,7 @@ class TestAdminOrganizations:
|
||||
):
|
||||
"""List orgs without admin permission should return 403."""
|
||||
response = await client.get(
|
||||
"/auth/api/admin/orgs",
|
||||
"/auth/api/admin/info",
|
||||
headers={
|
||||
**auth_headers(regular_session_token),
|
||||
"Host": "localhost:4401",
|
||||
@@ -242,19 +239,24 @@ class TestAdminOrganizations:
|
||||
):
|
||||
"""Admin user should be able to list organizations."""
|
||||
response = await client.get(
|
||||
"/auth/api/admin/orgs",
|
||||
"/auth/api/admin/info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
assert isinstance(data, dict)
|
||||
assert "orgs" in data
|
||||
orgs_data = data["orgs"]
|
||||
assert isinstance(orgs_data, dict)
|
||||
assert len(orgs_data) >= 1
|
||||
# Check org structure
|
||||
org = data[0]
|
||||
org_data = list(orgs_data.values())[0]
|
||||
assert "org" in org_data
|
||||
org = org_data["org"]
|
||||
assert "uuid" in org
|
||||
assert "display_name" in org
|
||||
assert "roles" in org
|
||||
assert "users" in org
|
||||
assert "roles" in org_data
|
||||
assert "users" in org_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orgs_with_org_admin(
|
||||
@@ -266,13 +268,13 @@ class TestAdminOrganizations:
|
||||
):
|
||||
"""Org admin should only see their own organization."""
|
||||
response = await client.get(
|
||||
"/auth/api/admin/orgs",
|
||||
"/auth/api/admin/info",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should only see their own org, not the second org
|
||||
org_uuids = [o["uuid"] for o in data]
|
||||
org_uuids = [org_data["org"]["uuid"] for org_data in data["orgs"].values()]
|
||||
assert str(test_org.uuid) in org_uuids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -794,7 +796,8 @@ class TestAdminUsersInOrg:
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "display_name" in data
|
||||
assert "user" in data
|
||||
assert "display_name" in data["user"]
|
||||
assert "credentials" in data
|
||||
assert "sessions" in data
|
||||
assert "aaguid_info" in data
|
||||
@@ -845,7 +848,8 @@ class TestAdminUsersInOrg:
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "display_name" in data
|
||||
assert "user" in data
|
||||
assert "display_name" in data["user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_display_name_in_org(
|
||||
@@ -853,7 +857,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin should be able to update user display name."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/info",
|
||||
json={"display_name": "Updated Admin Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -866,7 +870,7 @@ class TestAdminUsersInOrg:
|
||||
"""Updating non-existent user should return 404."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/users/{fake_uuid}/display-name",
|
||||
f"/auth/api/admin/users/{fake_uuid}/info",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -884,7 +888,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Org admin cannot update user from another org."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/info",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -896,13 +900,13 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user with empty display name should fail."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/info",
|
||||
json={"display_name": " "},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "display_name required" in data["detail"]
|
||||
assert "display_name cannot be empty" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_display_name_too_long(
|
||||
@@ -910,13 +914,13 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user with too long display name should fail."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": "x" * 100},
|
||||
f"/auth/api/admin/users/{test_user.uuid}/info",
|
||||
json={"display_name": "x" * 65},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "too long" in data["detail"]
|
||||
assert "display_name too long" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_in_org(
|
||||
@@ -1290,7 +1294,7 @@ class TestAdminSessions:
|
||||
):
|
||||
"""Admin should be able to delete a user's session."""
|
||||
# Create an additional session to delete
|
||||
extra_token = create_session(
|
||||
extra_db_key, _extra_secret = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
host="other.host:4401",
|
||||
@@ -1299,7 +1303,7 @@ class TestAdminSessions:
|
||||
)
|
||||
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_token}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_db_key}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1316,8 +1320,9 @@ class TestAdminSessions:
|
||||
test_user,
|
||||
):
|
||||
"""Admin can delete their own current session."""
|
||||
session_db_key = base64url.enc(hash_secret("cookie", session_token))
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_token}",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_db_key}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1394,14 +1399,17 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Admin should be able to list all permissions."""
|
||||
response = await client.get(
|
||||
"/auth/api/admin/permissions",
|
||||
"/auth/api/admin/info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert isinstance(data, dict)
|
||||
assert "permissions" in data
|
||||
permissions_data = data["permissions"]
|
||||
assert isinstance(permissions_data, dict)
|
||||
# Should include at least auth:admin
|
||||
perm_scopes = [p["scope"] for p in data]
|
||||
perm_scopes = [p["scope"] for p in permissions_data.values()]
|
||||
assert "auth:admin" in perm_scopes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1414,13 +1422,13 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Org admin should only see permissions their org can grant."""
|
||||
response = await client.get(
|
||||
"/auth/api/admin/permissions",
|
||||
"/auth/api/admin/info",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should only see permissions the org can grant
|
||||
perm_scopes = [p["scope"] for p in data]
|
||||
perm_scopes = [p["scope"] for p in data["permissions"].values()]
|
||||
assert grantable_permission.scope in perm_scopes
|
||||
# test_org CAN grant auth:admin (it's in org.permissions), so org admin sees it
|
||||
assert "auth:admin" in perm_scopes
|
||||
@@ -1704,7 +1712,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to update display name should get 403."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/display-name",
|
||||
f"/auth/api/admin/users/{test_user.uuid}/info",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
|
||||
+29
-22
@@ -11,15 +11,16 @@ These tests cover:
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia import authcode
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import create_session, delete_session
|
||||
from paskia.db import delete_session
|
||||
from paskia.util.passphrase import generate
|
||||
from tests.conftest import auth_headers
|
||||
from tests.conftest import auth_headers, create_test_session
|
||||
|
||||
|
||||
class TestSettingsEndpoint:
|
||||
@@ -229,12 +230,12 @@ class TestLogoutEndpoint:
|
||||
|
||||
|
||||
class TestUserInfoEndpoint:
|
||||
"""Tests for POST /auth/api/user-info"""
|
||||
"""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.post("/auth/api/user-info")
|
||||
response = await client.get("/auth/api/user-info")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -242,22 +243,22 @@ class TestUserInfoEndpoint:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_user
|
||||
):
|
||||
"""User info with valid session should return user data."""
|
||||
response = await client.post(
|
||||
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 "ctx" in data
|
||||
assert data["ctx"]["user"]["uuid"] == str(test_user.uuid)
|
||||
assert data["ctx"]["user"]["display_name"] == test_user.display_name
|
||||
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.post(
|
||||
response = await client.get(
|
||||
"/auth/api/user-info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -271,7 +272,7 @@ class TestUserInfoEndpoint:
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""User info should include user's active sessions."""
|
||||
response = await client.post(
|
||||
response = await client.get(
|
||||
"/auth/api/user-info",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -285,36 +286,42 @@ class TestUserInfoEndpoint:
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""User info should include user's permissions."""
|
||||
response = await client.post(
|
||||
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 "ctx" in data
|
||||
assert "permissions" in data["ctx"]
|
||||
assert "permissions" in data
|
||||
|
||||
|
||||
class TestSetSessionEndpoint:
|
||||
"""Tests for POST /auth/api/set-session"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_session_without_bearer_returns_401(
|
||||
async def test_set_session_without_bearer_returns_400(
|
||||
self, client: httpx.AsyncClient
|
||||
):
|
||||
"""Set session without bearer token should return 401."""
|
||||
"""Set session without bearer token should return 400."""
|
||||
response = await client.post("/auth/api/set-session")
|
||||
assert response.status_code == 401
|
||||
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 bearer token should set cookie."""
|
||||
"""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),
|
||||
)
|
||||
)
|
||||
response = await client.post(
|
||||
"/auth/api/set-session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {session_token}",
|
||||
"Authorization": f"Bearer {code}",
|
||||
"Host": "localhost:4401",
|
||||
},
|
||||
)
|
||||
@@ -522,7 +529,7 @@ class TestValidateSessionRefresh:
|
||||
"""Validate should return 401 if session disappears during refresh."""
|
||||
|
||||
# Create a session with a short remaining duration to trigger refresh
|
||||
token = create_session(
|
||||
db_key, secret = create_test_session(
|
||||
user_uuid=test_user.uuid,
|
||||
credential_uuid=test_credential.uuid,
|
||||
host="localhost",
|
||||
@@ -532,11 +539,11 @@ class TestValidateSessionRefresh:
|
||||
)
|
||||
|
||||
# Delete the session right before validate tries to refresh
|
||||
delete_session(token)
|
||||
delete_session(db_key)
|
||||
|
||||
response = await client.post(
|
||||
"/auth/api/validate",
|
||||
headers={**auth_headers(token), "Host": "localhost:4401"},
|
||||
headers={**auth_headers(secret), "Host": "localhost:4401"},
|
||||
)
|
||||
# Session was found initially but disappeared during refresh
|
||||
assert response.status_code == 401
|
||||
|
||||
Reference in New Issue
Block a user