diff --git a/gnexus_creds/auth.py b/gnexus_creds/auth.py index f0b178e..d29842b 100644 --- a/gnexus_creds/auth.py +++ b/gnexus_creds/auth.py @@ -31,6 +31,21 @@ actor.user_agent = request.headers.get("user-agent") db.commit() return actor + # An explicitly presented token that no longer works must NOT ride + # the browser's session cookie: a revoked token would keep "working" + # inside a logged-in browser — silently authenticated as channel=ui + # with the session's privileges. Fail closed instead; only requests + # without a Bearer header may use cookie auth (the SPA). + failed_key = f"bearer:{credentials.credentials[:12]}" + log_failed_access_once( + db, + key=failed_key, + channel="rest", + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + db.commit() + raise AppError("unauthorized", "Authentication required.", status_code=401) session_id = request.cookies.get(get_settings().session_cookie_name) user = get_session_user(db, session_id) if user: diff --git a/tests/test_auth.py b/tests/test_auth.py index 07dabf0..d06e02b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -266,3 +266,54 @@ # 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