Skip to content

Commit 355db0e

Browse files
committed
fix(spp_api_v2): address review comments on OAuth endpoint
- Move import base64 to top-level imports - Add debug logging to Basic Auth decode errors - Add debug logging to broad except in JSON body parsing - Remove duplicate token_lifetime_hours fetch from _generate_jwt_token by passing it as a parameter - Add tests for HTTP Basic Auth and form-encoded body authentication
1 parent baaf8e9 commit 355db0e

File tree

2 files changed

+62
-18
lines changed

2 files changed

+62
-18
lines changed

spp_api_v2/routers/oauth.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22
"""OAuth 2.0 endpoints for API V2"""
33

4+
import base64
45
import logging
56
import os
67
from datetime import datetime, timedelta
@@ -53,14 +54,12 @@ async def _parse_token_request(http_request: Request) -> TokenRequest:
5354
basic_client_secret = ""
5455
auth_header = http_request.headers.get("authorization", "")
5556
if auth_header.startswith("Basic "):
56-
import base64
57-
5857
try:
5958
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
6059
if ":" in decoded:
6160
basic_client_id, basic_client_secret = decoded.split(":", 1)
62-
except (ValueError, UnicodeDecodeError):
63-
pass
61+
except (ValueError, UnicodeDecodeError) as e:
62+
_logger.debug("Failed to decode Basic Auth header: %s", e)
6463

6564
content_type = http_request.headers.get("content-type", "")
6665
if "application/x-www-form-urlencoded" in content_type:
@@ -75,8 +74,8 @@ async def _parse_token_request(http_request: Request) -> TokenRequest:
7574
try:
7675
body = await http_request.json()
7776
return TokenRequest(**body)
78-
except Exception:
79-
pass
77+
except Exception as e:
78+
_logger.debug("Could not parse token request from JSON body, falling back: %s", e)
8079

8180
# Fall back to Basic Auth only (grant_type defaults to client_credentials)
8281
if basic_client_id:
@@ -126,9 +125,14 @@ async def get_token(
126125
detail="Invalid client credentials",
127126
)
128127

128+
# Read configurable token lifetime (default: 24 hours for long-lived sessions)
129+
# nosemgrep: odoo-sudo-without-context
130+
token_lifetime_hours = int(env["ir.config_parameter"].sudo().get_param("spp_api_v2.token_lifetime_hours", "24"))
131+
expires_in = token_lifetime_hours * 3600
132+
129133
# Generate JWT token
130134
try:
131-
token = _generate_jwt_token(env, api_client)
135+
token = _generate_jwt_token(env, api_client, token_lifetime_hours)
132136
except Exception as e:
133137
_logger.exception("Error generating JWT token")
134138
raise HTTPException(
@@ -139,11 +143,6 @@ async def get_token(
139143
# Build scope string from client scopes
140144
scope_str = " ".join(f"{s.resource}:{s.action}" for s in api_client.scope_ids)
141145

142-
# Read configurable token lifetime (default: 24 hours for long-lived sessions)
143-
# nosemgrep: odoo-sudo-without-context
144-
token_lifetime_hours = int(env["ir.config_parameter"].sudo().get_param("spp_api_v2.token_lifetime_hours", "24"))
145-
expires_in = token_lifetime_hours * 3600
146-
147146
return TokenResponse(
148147
access_token=token,
149148
token_type="Bearer",
@@ -189,14 +188,14 @@ def _get_jwt_secret(env: Environment) -> str:
189188
return secret
190189

191190

192-
def _generate_jwt_token(env: Environment, api_client) -> str:
191+
def _generate_jwt_token(env: Environment, api_client, token_lifetime_hours: int) -> str:
193192
"""
194193
Generate JWT access token for API client.
195194
196195
Token contains:
197196
- client_id (external identifier, NOT database ID)
198197
- scopes
199-
- expiration (1 hour)
198+
- expiration time determined by token_lifetime_hours
200199
201200
SECURITY: Never include database IDs in JWT.
202201
The auth middleware loads the full api_client record from DB using client_id.
@@ -215,10 +214,6 @@ def _generate_jwt_token(env: Environment, api_client) -> str:
215214
# Build payload
216215
# SECURITY: Never include database IDs in JWT - use client_id only
217216
# The auth middleware looks up the full api_client record using client_id
218-
# Read configurable token lifetime (default: 24 hours)
219-
# nosemgrep: odoo-sudo-without-context
220-
token_lifetime_hours = int(env["ir.config_parameter"].sudo().get_param("spp_api_v2.token_lifetime_hours", "24"))
221-
222217
now = datetime.utcnow()
223218
payload = {
224219
"iss": "openspp-api-v2", # Issuer

spp_api_v2/tests/test_oauth.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22
"""Tests for OAuth endpoints"""
33

4+
import base64
45
import json
6+
from urllib.parse import urlencode
57

68
from ..middleware.rate_limit import get_rate_limiter
79
from .common import ApiV2HttpTestCase
@@ -218,6 +220,53 @@ def test_client_last_used_date_updated(self):
218220
self.client.invalidate_recordset()
219221
self.assertTrue(self.client.last_used_date)
220222

223+
def test_token_generation_basic_auth(self):
224+
"""HTTP Basic Auth header returns access token"""
225+
credentials = base64.b64encode(f"{self.client.client_id}:{self.client.client_secret}".encode()).decode("utf-8")
226+
227+
response = self.url_open(
228+
self.url,
229+
data="",
230+
headers={
231+
"Content-Type": "application/x-www-form-urlencoded",
232+
"Authorization": f"Basic {credentials}",
233+
},
234+
)
235+
236+
self.assertEqual(response.status_code, 200)
237+
238+
data = json.loads(response.content)
239+
self.assertIn("access_token", data)
240+
self.assertEqual(data["token_type"], "Bearer")
241+
self.assertIn("expires_in", data)
242+
self.assertIn("scope", data)
243+
244+
def test_token_generation_form_encoded(self):
245+
"""Form-encoded body (application/x-www-form-urlencoded) returns access token"""
246+
body = urlencode(
247+
{
248+
"grant_type": "client_credentials",
249+
"client_id": self.client.client_id,
250+
"client_secret": self.client.client_secret,
251+
}
252+
)
253+
254+
response = self.url_open(
255+
self.url,
256+
data=body,
257+
headers={"Content-Type": "application/x-www-form-urlencoded"},
258+
)
259+
260+
self.assertEqual(response.status_code, 200)
261+
262+
data = json.loads(response.content)
263+
self.assertIn("access_token", data)
264+
self.assertEqual(data["token_type"], "Bearer")
265+
self.assertIn("expires_in", data)
266+
self.assertIn("scope", data)
267+
self.assertIn("individual:read", data["scope"])
268+
self.assertIn("group:search", data["scope"])
269+
221270
def test_token_no_scopes(self):
222271
"""Client with no scopes still gets token but empty scope string"""
223272
# Create client without scopes

0 commit comments

Comments
 (0)