diff --git a/README.md b/README.md index 802d70c..e5487ec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/campus_python/__init__.py b/campus_python/__init__.py index ab1b431..b78c39f 100644 --- a/campus_python/__init__.py +++ b/campus_python/__init__.py @@ -81,6 +81,7 @@ def auth(self) -> AuthRoot: json_client=CampusRequest( base_url=base_url, timeout=self.timeout, + mode=self._mode, ) ) return self._auth @@ -107,6 +108,7 @@ def api(self) -> ApiRoot: json_client=CampusRequest( base_url=base_url, timeout=self.timeout, + mode=self._mode, ) ) return self._api diff --git a/campus_python/json_client/__init__.py b/campus_python/json_client/__init__.py index 25255ff..609c7d0 100644 --- a/campus_python/json_client/__init__.py +++ b/campus_python/json_client/__init__.py @@ -79,6 +79,7 @@ def __init__( self, base_url: str | None, *, + mode: str = "server", headers: Mapping[str, str] | None = None, **kwargs: Any, ): @@ -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: diff --git a/tests/unit/test_campus_init.py b/tests/unit/test_campus_init.py index 42405ec..c11ede8 100644 --- a/tests/unit/test_campus_init.py +++ b/tests/unit/test_campus_init.py @@ -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() diff --git a/tests/unit/test_campus_request.py b/tests/unit/test_campus_request.py new file mode 100644 index 0000000..35df32e --- /dev/null +++ b/tests/unit/test_campus_request.py @@ -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()