from datetime import timedelta
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from gnexus_creds.models import OAuthState, SessionRecord, User, utcnow
@pytest.mark.anyio
async def test_oauth_login_creates_state(auth_app, db_session):
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
response = await client.get("/auth/login", params={"return_to": "/secrets"})
assert response.status_code == 307
assert "state=" in response.headers["location"]
state = db_session.query(OAuthState).one()
assert state.return_to == "/secrets"
assert state.scopes == ["openid", "email", "profile"]
@pytest.mark.anyio
async def test_oauth_callback_creates_session(auth_app, db_session, monkeypatch):
state = OAuthState(
state="oauth-state",
pkce_verifier="pkce-verifier",
return_to="/",
scopes=["openid", "email", "profile"],
expires_at=utcnow() + timedelta(minutes=10),
)
db_session.add(state)
db_session.commit()
class FakeTokenEndpoint:
def __init__(self, config):
self.config = config
def exchange_authorization_code(self, code, verifier):
assert code == "oauth-code"
assert verifier == "pkce-verifier"
return SimpleNamespace(access_token="access-token")
class FakeRuntimeUserProvider:
def __init__(self, config):
self.config = config
def fetch_user(self, access_token):
assert access_token == "access-token"
return SimpleNamespace(
user_id="auth-subject",
email="auth@example.test",
status="enabled",
system_role="admin",
profile={"display_name": "Auth User", "locale": "en"},
)
monkeypatch.setattr("gnexus_creds.oauth.HttpTokenEndpoint", FakeTokenEndpoint)
monkeypatch.setattr("gnexus_creds.oauth.HttpRuntimeUserProvider", FakeRuntimeUserProvider)
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
response = await client.get(
"/auth/callback", params={"code": "oauth-code", "state": "oauth-state"}
)
assert response.status_code == 307
assert response.headers["location"] == "/"
me_response = await client.get("/api/v1/me")
assert me_response.status_code == 200
assert me_response.json()["email"] == "auth@example.test"
assert me_response.json()["role"] == "admin"
db_session.expire_all()
assert db_session.query(User).filter(User.auth_subject == "auth-subject").count() == 1
assert db_session.query(SessionRecord).count() == 1
@pytest.mark.anyio
async def test_logout_clears_session_and_cookie(auth_app, db_session, user):
session = SessionRecord(
id="test-session-id",
user_id=user.id,
data={},
expires_at=utcnow() + timedelta(days=1),
)
db_session.add(session)
db_session.commit()
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
client.cookies.set("gnexus_creds_session", "test-session-id")
response = await client.post("/auth/logout")
assert response.status_code == 200
assert response.json()["status"] == "ok"
set_cookie = response.headers.get("set-cookie", "")
assert "gnexus_creds_session" in set_cookie
assert "Max-Age=0" in set_cookie or "expires=" in set_cookie.lower()
db_session.expire_all()
assert db_session.get(SessionRecord, "test-session-id") is None
@pytest.mark.anyio
async def test_gnexus_auth_webhook_updates_user(auth_app, db_session, user, monkeypatch):
class FakeVerifier:
def __init__(self, config):
self.config = config
def verify(self, raw, headers, secret):
assert "auth-user-1" in raw
assert secret
class FakeParser:
def parse(self, raw):
return SimpleNamespace(
event_type="auth.updated",
target_identifiers={"sub": "auth-user-1"},
metadata={
"status": "disabled",
"profile": {"display_name": "Disabled User", "locale": "uk"},
},
)
monkeypatch.setattr("gnexus_creds.oauth.HmacWebhookVerifier", FakeVerifier)
monkeypatch.setattr("gnexus_creds.oauth.JsonWebhookParser", FakeParser)
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
response = await client.post(
"/webhooks/gnexus-auth",
json={"sub": "auth-user-1"},
headers={"X-Test-Signature": "ok"},
)
assert response.status_code == 200
db_session.refresh(user)
assert user.status == "disabled"
assert user.display_name == "Disabled User"
# the webhook refreshes the auth profile but must not overwrite the
# per-service locale override
assert user.locale == "en"
assert user.profile == {"display_name": "Disabled User", "locale": "uk"}
@pytest.mark.anyio
async def test_webhook_accepts_trailing_slash(auth_app, db_session, user, monkeypatch):
# the auth platform's target URL may carry a trailing slash; the SPA
# catch-all (a GET route) must not answer the POST with 405
class FakeVerifier:
def __init__(self, config):
self.config = config
def verify(self, raw, headers, secret):
pass
class FakeParser:
def parse(self, raw):
return SimpleNamespace(
target_identifiers={"sub": "auth-user-1"}, metadata={}, event_type="auth.updated"
)
monkeypatch.setattr("gnexus_creds.oauth.HmacWebhookVerifier", FakeVerifier)
monkeypatch.setattr("gnexus_creds.oauth.JsonWebhookParser", FakeParser)
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
response = await client.post("/webhooks/gnexus-auth/", json={"x": 1})
assert response.status_code == 200
@pytest.mark.anyio
async def test_webhook_rejects_bad_signature_with_401(auth_app, db_session, monkeypatch):
from gnexus_gauth.exceptions import WebhookVerificationException
class Explode:
def __init__(self, config):
self.config = config
def verify(self, raw, headers, secret):
raise WebhookVerificationException("signature mismatch")
monkeypatch.setattr("gnexus_creds.oauth.HmacWebhookVerifier", Explode)
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
response = await client.post("/webhooks/gnexus-auth", content=b"{}", headers={})
assert response.status_code == 401
@pytest.mark.anyio
async def test_webhook_global_logout_ends_sessions(auth_app, db_session, user, monkeypatch):
db_session.add_all(
[
SessionRecord(
id="logged-out-session",
user_id=user.id,
data={},
expires_at=utcnow() + timedelta(days=1),
),
SessionRecord(
id="foreign-session",
user_id=user.id,
data={},
expires_at=utcnow() + timedelta(days=1),
),
]
)
db_session.commit()
other_user = User(
auth_subject="other-sub",
email="other@example.test",
display_name="Other",
status="enabled",
profile={},
locale="en",
)
db_session.add(other_user)
db_session.commit()
db_session.add(
SessionRecord(
id="kept-session",
user_id=other_user.id,
data={},
expires_at=utcnow() + timedelta(days=1),
)
)
db_session.commit()
class FakeVerifier:
def __init__(self, config):
self.config = config
def verify(self, raw, headers, secret):
pass
class FakeParser:
def parse(self, raw):
return SimpleNamespace(
event_type="auth.global_logout",
target_identifiers={"sub": "auth-user-1"},
metadata={},
)
monkeypatch.setattr("gnexus_creds.oauth.HmacWebhookVerifier", FakeVerifier)
monkeypatch.setattr("gnexus_creds.oauth.JsonWebhookParser", FakeParser)
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
response = await client.post("/webhooks/gnexus-auth", json={"x": 1})
assert response.status_code == 200
db_session.expire_all()
assert db_session.get(SessionRecord, "logged-out-session") is None
# the second "own" row was part of the delete-all; only other users'
# sessions survive a global logout
assert db_session.get(SessionRecord, "foreign-session") is None
assert db_session.get(SessionRecord, "kept-session") is not None
@pytest.mark.anyio
async def test_revoked_bearer_must_not_fall_back_to_session_cookie(
auth_app, db_session, user, monkeypatch
):
# A revoked token presented in a browser that still holds a valid web
# session used to fall through to the cookie and keep "working" as
# channel=ui — masking revocation. Fail closed: bearer auth is final.
from gnexus_creds import crypto
from gnexus_creds.models import ApiToken
token = "gcr_revokedtoken_test_000000000000"
db_session.add(
ApiToken(
user_id=user.id,
public_id="revokedtok",
name="revoked",
token_hash=crypto.token_hash(token),
scopes=["read", "reveal", "write"],
revoked_at=utcnow(),
)
)
db_session.add(
SessionRecord(
id="alive-web-session",
user_id=user.id,
data={},
expires_at=utcnow() + timedelta(days=1),
)
)
db_session.commit()
async with AsyncClient(
transport=ASGITransport(app=auth_app), base_url="http://test"
) as client:
client.cookies.set("gnexus_creds_session", "alive-web-session")
with_revoked = await client.get(
"/api/v1/secrets", headers={"Authorization": f"Bearer {token}"}
)
cookie_only = await client.get("/api/v1/me")
bad_token = await client.get(
"/api/v1/secrets", headers={"Authorization": "Bearer gcr_unknown_token_value"}
)
assert with_revoked.status_code == 401
assert bad_token.status_code == 401
# the cookie alone still authenticates the SPA
assert cookie_only.status_code == 200
# and the session row survived — the token failure did not touch it
assert db_session.get(SessionRecord, "alive-web-session") is not None