Skip to content

Commit 7957bd6

Browse files
authored
Merge pull request #34 from nyjc-computing/fix-device-mode-auth
Fix device mode authentication - add mode parameter to CampusRequest
2 parents 1345b13 + 07ce8e6 commit 7957bd6

5 files changed

Lines changed: 179 additions & 3 deletions

File tree

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,42 @@ campus-api-python/
107107

108108
## Authentication Modes
109109

110-
- **Server Mode:** For server-to-server communication. Requires `CLIENT_ID` and `CLIENT_SECRET` environment variables.
111-
- **Device Mode:** For public clients (e.g., CLI tools). No credentials required; limited to public endpoints.
110+
### Server Mode
111+
112+
For server-to-server communication. Uses Basic authentication with OAuth client credentials.
113+
114+
**Required Environment Variables:**
115+
- `CLIENT_ID` - OAuth client ID from Campus auth
116+
- `CLIENT_SECRET` - OAuth client secret from Campus auth
117+
118+
**Example:**
119+
```python
120+
from campus_python import Campus
121+
122+
# Server mode (default) - requires CLIENT_ID and CLIENT_SECRET
123+
client = Campus(timeout=30, mode="server")
124+
```
125+
126+
### Device Mode
127+
128+
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.
129+
130+
**No Environment Variables Required**
131+
132+
**Example:**
133+
```python
134+
from campus_python import Campus
135+
136+
# Device mode - no credentials required
137+
client = Campus(timeout=30, mode="device")
138+
139+
# After obtaining an OAuth access token (via device flow)
140+
access_token = "your-access-token"
141+
client.api.client.set_bearer_authorization(access_token)
142+
client.auth.client.set_bearer_authorization(access_token)
143+
144+
# Now you can make authenticated requests
145+
```
112146

113147
## Environment Variables
114148

campus_python/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def auth(self) -> AuthRoot:
8181
json_client=CampusRequest(
8282
base_url=base_url,
8383
timeout=self.timeout,
84+
mode=self._mode,
8485
)
8586
)
8687
return self._auth
@@ -107,6 +108,7 @@ def api(self) -> ApiRoot:
107108
json_client=CampusRequest(
108109
base_url=base_url,
109110
timeout=self.timeout,
111+
mode=self._mode,
110112
)
111113
)
112114
return self._api

campus_python/json_client/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def __init__(
7979
self,
8080
base_url: str | None,
8181
*,
82+
mode: str = "server",
8283
headers: Mapping[str, str] | None = None,
8384
**kwargs: Any,
8485
):
@@ -89,7 +90,10 @@ def __init__(
8990
# Session to persist headers and connection pooling
9091
self._session = requests.Session()
9192
self._session.headers.update(self._headers)
92-
self.reset_authorization()
93+
# Only set client credentials in server mode
94+
# Device mode starts without auth (Bearer token will be set later)
95+
if mode == "server":
96+
self.reset_authorization()
9397

9498
@property
9599
def headers(self) -> campus.model.HttpHeader:

tests/unit/test_campus_init.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,26 @@ def test_campus_init_fail_fast(self):
8383

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

86+
def test_campus_init_device_mode_no_credentials(self):
87+
"""Test that Campus.__init__() succeeds in device mode without credentials."""
88+
# Device mode should NOT require CLIENT_ID or CLIENT_SECRET
89+
campus = campus_python.Campus(timeout=60, mode="device")
90+
91+
# Verify mode is set correctly
92+
self.assertEqual(campus._mode, "device")
93+
94+
def test_campus_init_device_mode_with_credentials_ignored(self):
95+
"""Test that credentials are ignored in device mode."""
96+
# Set credentials (they should be ignored in device mode)
97+
os.environ['CLIENT_ID'] = 'test-client-id'
98+
os.environ['CLIENT_SECRET'] = 'test-client-secret'
99+
100+
# Should succeed without using credentials
101+
campus = campus_python.Campus(timeout=60, mode="device")
102+
103+
# Verify mode is set correctly
104+
self.assertEqual(campus._mode, "device")
105+
86106

87107
if __name__ == "__main__":
88108
unittest.main()

tests/unit/test_campus_request.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Tests for CampusRequest class, specifically around authentication modes."""
2+
3+
import os
4+
import unittest
5+
6+
from campus_python.json_client import CampusRequest
7+
8+
9+
class TestCampusRequestModes(unittest.TestCase):
10+
"""Test CampusRequest initialization with different authentication modes."""
11+
12+
def setUp(self):
13+
"""Save and clear environment variables before each test."""
14+
self.saved_client_id = os.environ.get('CLIENT_ID')
15+
self.saved_client_secret = os.environ.get('CLIENT_SECRET')
16+
17+
# Clear env vars to ensure clean state
18+
if 'CLIENT_ID' in os.environ:
19+
del os.environ['CLIENT_ID']
20+
if 'CLIENT_SECRET' in os.environ:
21+
del os.environ['CLIENT_SECRET']
22+
23+
def tearDown(self):
24+
"""Restore original environment variables after each test."""
25+
# Restore original values
26+
if self.saved_client_id is not None:
27+
os.environ['CLIENT_ID'] = self.saved_client_id
28+
elif 'CLIENT_ID' in os.environ:
29+
del os.environ['CLIENT_ID']
30+
31+
if self.saved_client_secret is not None:
32+
os.environ['CLIENT_SECRET'] = self.saved_client_secret
33+
elif 'CLIENT_SECRET' in os.environ:
34+
del os.environ['CLIENT_SECRET']
35+
36+
def test_server_mode_requires_credentials(self):
37+
"""Test that server mode (default) requires CLIENT_ID and CLIENT_SECRET."""
38+
with self.assertRaises(OSError) as context:
39+
CampusRequest(base_url="http://localhost", mode="server")
40+
41+
# Verify error message mentions both variables
42+
self.assertIn('CLIENT_ID', str(context.exception))
43+
self.assertIn('CLIENT_SECRET', str(context.exception))
44+
45+
def test_device_mode_no_credentials_required(self):
46+
"""Test that device mode does NOT require CLIENT_ID or CLIENT_SECRET."""
47+
# Should not raise any exception
48+
client = CampusRequest(base_url="http://localhost", mode="device")
49+
50+
# Verify the client was created successfully
51+
self.assertIsNotNone(client)
52+
self.assertEqual(client.base_url, "http://localhost")
53+
54+
def test_server_mode_with_credentials(self):
55+
"""Test that server mode works when credentials are provided."""
56+
os.environ['CLIENT_ID'] = 'test-client-id'
57+
os.environ['CLIENT_SECRET'] = 'test-client-secret'
58+
59+
# Should not raise
60+
client = CampusRequest(base_url="http://localhost", mode="server")
61+
62+
# Verify the client was created successfully
63+
self.assertIsNotNone(client)
64+
65+
# Verify Basic auth header was set
66+
auth_header = client._session.headers.get('Authorization')
67+
self.assertIsNotNone(auth_header)
68+
self.assertTrue(auth_header.startswith('Basic '))
69+
70+
def test_default_mode_is_server(self):
71+
"""Test that the default mode is 'server' which requires credentials."""
72+
# Without specifying mode, it should default to "server"
73+
with self.assertRaises(OSError):
74+
CampusRequest(base_url="http://localhost")
75+
76+
def test_bearer_authorization_in_device_mode(self):
77+
"""Test that Bearer authorization can be set in device mode."""
78+
# Create client in device mode (no credentials required)
79+
client = CampusRequest(base_url="http://localhost", mode="device")
80+
81+
# Verify no Authorization header initially
82+
auth_header = client._session.headers.get('Authorization')
83+
self.assertIsNone(auth_header)
84+
85+
# Set Bearer authorization
86+
test_token = "test-bearer-token-12345"
87+
client.set_bearer_authorization(test_token)
88+
89+
# Verify Bearer auth header was set
90+
auth_header = client._session.headers.get('Authorization')
91+
self.assertIsNotNone(auth_header)
92+
self.assertEqual(auth_header, f'Bearer {test_token}')
93+
94+
def test_bearer_authorization_overrides_basic(self):
95+
"""Test that set_bearer_authorization overrides Basic auth."""
96+
os.environ['CLIENT_ID'] = 'test-client-id'
97+
os.environ['CLIENT_SECRET'] = 'test-client-secret'
98+
99+
# Create client in server mode (sets Basic auth)
100+
client = CampusRequest(base_url="http://localhost", mode="server")
101+
102+
# Verify Basic auth was set initially
103+
auth_header = client._session.headers.get('Authorization')
104+
self.assertTrue(auth_header.startswith('Basic '))
105+
106+
# Set Bearer authorization (should override)
107+
test_token = "test-bearer-token-67890"
108+
client.set_bearer_authorization(test_token)
109+
110+
# Verify Bearer auth header replaced Basic auth
111+
auth_header = client._session.headers.get('Authorization')
112+
self.assertEqual(auth_header, f'Bearer {test_token}')
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()

0 commit comments

Comments
 (0)