Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions campus_cli/auth/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ def get_auth_urls(auth_url: str | None = None) -> dict:
return {
"device_code_url": f"{base_url}/oauth/device_authorize",
"token_url": f"{base_url}/oauth/token",
"revoke_url": f"{base_url}/oauth/revoke",
}


Expand All @@ -112,9 +111,15 @@ def revoke_token(
"""
Revoke a token via the auth server's revocation endpoint (RFC 7009).

Goes through the client library's OAuth resource
(`auth.oauth.revoke`, campus-api-python#80): the endpoint targets
the given auth endpoint (or the CLI's current target), and the
server accepts JSON on all OAuth endpoints.

Best-effort by design: logout must still succeed when the server is
unreachable or deployed without /oauth/revoke, so any failure is
reported as False instead of raising.
reported as False instead of raising — including when the client
library is unavailable, matching get_api_client's degradation.

Args:
token: The access or refresh token to revoke.
Expand All @@ -127,20 +132,28 @@ def revoke_token(
Returns:
True if the server confirmed revocation, False otherwise.
"""
urls = get_auth_urls(auth_url)
base_url = auth_url if auth_url is not None else resolve_auth_url()

try:
response = requests.post(
urls["revoke_url"],
data={
"token": token,
"token_type_hint": token_type_hint,
"client_id": PUBLIC_OAUTH_CLIENT_ID,
},
timeout=10,
from campus_python import AuthRoot, CampusRequest, errors
except ImportError:
return False

try:
# Device mode: no credentials, no Authorization header — the
# public revoke endpoint takes the client_id in the body.
auth = AuthRoot(
json_client=CampusRequest(
base_url=base_url, mode="device", timeout=10
)
)
auth.oauth.revoke(
token,
client_id=PUBLIC_OAUTH_CLIENT_ID,
token_type_hint=token_type_hint,
)
return response.status_code == 200
except requests.RequestException:
return True
except (errors.APIError, requests.RequestException):
return False


Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 40 additions & 26 deletions tests/unit/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
import requests
from campus_python import errors

from campus_cli.auth.common import revoke_token
from campus_cli.auth.login import (
Expand Down Expand Up @@ -81,53 +82,66 @@ def test_request_device_code_network_error():


def test_revoke_token_sends_rfc7009_payload_and_reports_success():
"""A 200 response confirms revocation and the payload follows RFC 7009."""
response = mock.Mock(spec=requests.Response, status_code=200)
with mock.patch(
"campus_cli.auth.common.requests.post", return_value=response
) as mock_post:
"""A clean library call confirms revocation with the RFC 7009 args."""
with mock.patch.multiple(
"campus_python", AuthRoot=mock.DEFAULT, CampusRequest=mock.DEFAULT
) as mocked:
auth_root = mocked["AuthRoot"].return_value
assert revoke_token("tok-123", "refresh_token") is True

mock_post.assert_called_once()
assert mock_post.call_args.kwargs["data"] == {
"token": "tok-123",
"token_type_hint": "refresh_token",
"client_id": "guest",
}
auth_root.oauth.revoke.assert_called_once_with(
"tok-123", client_id="guest", token_type_hint="refresh_token"
)
mocked["CampusRequest"].assert_called_once_with(
base_url=mock.ANY, mode="device", timeout=10
)


def test_revoke_token_reports_failure_on_http_error():
"""Non-200 responses (e.g. endpoint not deployed) mean not revoked."""
response = mock.Mock(spec=requests.Response, status_code=404)
with mock.patch(
"campus_cli.auth.common.requests.post", return_value=response
):
"""API errors (e.g. a deployment without the endpoint) mean not revoked."""
with mock.patch.multiple(
"campus_python", AuthRoot=mock.DEFAULT, CampusRequest=mock.DEFAULT
) as mocked:
auth_root = mocked["AuthRoot"].return_value
auth_root.oauth.revoke.side_effect = errors.NotFoundError(
status_code=404, error_description="no revoke endpoint"
)
assert revoke_token("tok-123", "access_token") is False


def test_revoke_token_reports_failure_on_network_error():
"""Network errors degrade to False instead of raising from logout."""
with mock.patch(
"campus_cli.auth.common.requests.post",
side_effect=requests.ConnectionError("connection refused"),
):
with mock.patch.multiple(
"campus_python", AuthRoot=mock.DEFAULT, CampusRequest=mock.DEFAULT
) as mocked:
auth_root = mocked["AuthRoot"].return_value
auth_root.oauth.revoke.side_effect = requests.ConnectionError(
"connection refused"
)
assert revoke_token("tok-123", "refresh_token") is False


def test_revoke_token_reports_failure_when_library_unavailable():
"""A missing client library degrades to False (logout still clears)."""
with mock.patch.dict("sys.modules", {"campus_python": None}):
assert revoke_token("tok-123", "refresh_token") is False


def test_revoke_token_targets_issuing_endpoint_when_given():
"""auth_url overrides the current target for the revocation request."""
response = mock.Mock(spec=requests.Response, status_code=200)
with mock.patch(
"campus_cli.auth.common.requests.post", return_value=response
) as mock_post:
with mock.patch.multiple(
"campus_python", AuthRoot=mock.DEFAULT, CampusRequest=mock.DEFAULT
) as mocked:
assert revoke_token(
"tok-123",
"access_token",
auth_url="https://auth-old.example.com/auth/v1",
) is True

assert mock_post.call_args.args[0] == (
"https://auth-old.example.com/auth/v1/oauth/revoke"
mocked["CampusRequest"].assert_called_once_with(
base_url="https://auth-old.example.com/auth/v1",
mode="device",
timeout=10,
)


Expand Down
Loading