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
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,42 @@ campus-api-python/

## Authentication Modes

- **Server Mode:** For server-to-server communication. Requires `CLIENT_ID` and `CLIENT_SECRET` environment variables.
- **Device Mode:** For public clients (e.g., CLI tools). No credentials required; limited to public endpoints.
### Server Mode

For server-to-server communication. Uses Basic authentication with OAuth client credentials.

**Required Environment Variables:**
- `CLIENT_ID` - OAuth client ID from Campus auth
- `CLIENT_SECRET` - OAuth client secret from Campus auth

**Example:**
```python
from campus_python import Campus

# Server mode (default) - requires CLIENT_ID and CLIENT_SECRET
client = Campus(timeout=30, mode="server")
```

### Device Mode

For public clients (e.g., CLI tools) that authenticate users via OAuth device flow. Does not require client credentials. Instead, you set Bearer token authentication after obtaining an access token.

**No Environment Variables Required**

**Example:**
```python
from campus_python import Campus

# Device mode - no credentials required
client = Campus(timeout=30, mode="device")

# After obtaining an OAuth access token (via device flow)
access_token = "your-access-token"
client.api.client.set_bearer_authorization(access_token)
client.auth.client.set_bearer_authorization(access_token)

# Now you can make authenticated requests
```

## Environment Variables

Expand Down
2 changes: 2 additions & 0 deletions campus_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def auth(self) -> AuthRoot:
json_client=CampusRequest(
base_url=base_url,
timeout=self.timeout,
mode=self._mode,
)
)
return self._auth
Expand All @@ -107,6 +108,7 @@ def api(self) -> ApiRoot:
json_client=CampusRequest(
base_url=base_url,
timeout=self.timeout,
mode=self._mode,
)
)
return self._api
Expand Down
6 changes: 5 additions & 1 deletion campus_python/json_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def __init__(
self,
base_url: str | None,
*,
mode: str = "server",
headers: Mapping[str, str] | None = None,
**kwargs: Any,
):
Expand All @@ -89,7 +90,10 @@ def __init__(
# Session to persist headers and connection pooling
self._session = requests.Session()
self._session.headers.update(self._headers)
self.reset_authorization()
# Only set client credentials in server mode
# Device mode starts without auth (Bearer token will be set later)
if mode == "server":
self.reset_authorization()

@property
def headers(self) -> campus.model.HttpHeader:
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_campus_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,26 @@ def test_campus_init_fail_fast(self):

# If we got here without an exception, the fail-fast check is broken

def test_campus_init_device_mode_no_credentials(self):
"""Test that Campus.__init__() succeeds in device mode without credentials."""
# Device mode should NOT require CLIENT_ID or CLIENT_SECRET
campus = campus_python.Campus(timeout=60, mode="device")

# Verify mode is set correctly
self.assertEqual(campus._mode, "device")

def test_campus_init_device_mode_with_credentials_ignored(self):
"""Test that credentials are ignored in device mode."""
# Set credentials (they should be ignored in device mode)
os.environ['CLIENT_ID'] = 'test-client-id'
os.environ['CLIENT_SECRET'] = 'test-client-secret'

# Should succeed without using credentials
campus = campus_python.Campus(timeout=60, mode="device")

# Verify mode is set correctly
self.assertEqual(campus._mode, "device")


if __name__ == "__main__":
unittest.main()
116 changes: 116 additions & 0 deletions tests/unit/test_campus_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Tests for CampusRequest class, specifically around authentication modes."""

import os
import unittest

from campus_python.json_client import CampusRequest


class TestCampusRequestModes(unittest.TestCase):
"""Test CampusRequest initialization with different authentication modes."""

def setUp(self):
"""Save and clear environment variables before each test."""
self.saved_client_id = os.environ.get('CLIENT_ID')
self.saved_client_secret = os.environ.get('CLIENT_SECRET')

# Clear env vars to ensure clean state
if 'CLIENT_ID' in os.environ:
del os.environ['CLIENT_ID']
if 'CLIENT_SECRET' in os.environ:
del os.environ['CLIENT_SECRET']

def tearDown(self):
"""Restore original environment variables after each test."""
# Restore original values
if self.saved_client_id is not None:
os.environ['CLIENT_ID'] = self.saved_client_id
elif 'CLIENT_ID' in os.environ:
del os.environ['CLIENT_ID']

if self.saved_client_secret is not None:
os.environ['CLIENT_SECRET'] = self.saved_client_secret
elif 'CLIENT_SECRET' in os.environ:
del os.environ['CLIENT_SECRET']

def test_server_mode_requires_credentials(self):
"""Test that server mode (default) requires CLIENT_ID and CLIENT_SECRET."""
with self.assertRaises(OSError) as context:
CampusRequest(base_url="http://localhost", mode="server")

# Verify error message mentions both variables
self.assertIn('CLIENT_ID', str(context.exception))
self.assertIn('CLIENT_SECRET', str(context.exception))

def test_device_mode_no_credentials_required(self):
"""Test that device mode does NOT require CLIENT_ID or CLIENT_SECRET."""
# Should not raise any exception
client = CampusRequest(base_url="http://localhost", mode="device")

# Verify the client was created successfully
self.assertIsNotNone(client)
self.assertEqual(client.base_url, "http://localhost")

def test_server_mode_with_credentials(self):
"""Test that server mode works when credentials are provided."""
os.environ['CLIENT_ID'] = 'test-client-id'
os.environ['CLIENT_SECRET'] = 'test-client-secret'

# Should not raise
client = CampusRequest(base_url="http://localhost", mode="server")

# Verify the client was created successfully
self.assertIsNotNone(client)

# Verify Basic auth header was set
auth_header = client._session.headers.get('Authorization')
self.assertIsNotNone(auth_header)
self.assertTrue(auth_header.startswith('Basic '))

def test_default_mode_is_server(self):
"""Test that the default mode is 'server' which requires credentials."""
# Without specifying mode, it should default to "server"
with self.assertRaises(OSError):
CampusRequest(base_url="http://localhost")

def test_bearer_authorization_in_device_mode(self):
"""Test that Bearer authorization can be set in device mode."""
# Create client in device mode (no credentials required)
client = CampusRequest(base_url="http://localhost", mode="device")

# Verify no Authorization header initially
auth_header = client._session.headers.get('Authorization')
self.assertIsNone(auth_header)

# Set Bearer authorization
test_token = "test-bearer-token-12345"
client.set_bearer_authorization(test_token)

# Verify Bearer auth header was set
auth_header = client._session.headers.get('Authorization')
self.assertIsNotNone(auth_header)
self.assertEqual(auth_header, f'Bearer {test_token}')

def test_bearer_authorization_overrides_basic(self):
"""Test that set_bearer_authorization overrides Basic auth."""
os.environ['CLIENT_ID'] = 'test-client-id'
os.environ['CLIENT_SECRET'] = 'test-client-secret'

# Create client in server mode (sets Basic auth)
client = CampusRequest(base_url="http://localhost", mode="server")

# Verify Basic auth was set initially
auth_header = client._session.headers.get('Authorization')
self.assertTrue(auth_header.startswith('Basic '))

# Set Bearer authorization (should override)
test_token = "test-bearer-token-67890"
client.set_bearer_authorization(test_token)

# Verify Bearer auth header replaced Basic auth
auth_header = client._session.headers.get('Authorization')
self.assertEqual(auth_header, f'Bearer {test_token}')


if __name__ == "__main__":
unittest.main()
Loading