Skip to content

Commit f5020cf

Browse files
authored
Merge pull request #69 from OpenSPP/feat/api-v2-oauth-basic-auth
feat(spp_api_v2): support HTTP Basic Auth on OAuth token endpoint
2 parents 69e0e5a + 7a04d04 commit f5020cf

4 files changed

Lines changed: 144 additions & 16 deletions

File tree

spp_api_v2/routers/oauth.py

Lines changed: 68 additions & 9 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
@@ -39,41 +40,99 @@ class TokenResponse(BaseModel):
3940
scope: str
4041

4142

43+
async def _parse_token_request(http_request: Request) -> TokenRequest:
44+
"""Parse token request from JSON, form-encoded body, or HTTP Basic Auth.
45+
46+
Supports three client authentication methods per RFC 6749:
47+
1. HTTP Basic Auth header (Section 2.3.1) - used by QGIS native OAuth2
48+
2. Form-encoded body with client_id/client_secret (Section 2.3.1)
49+
3. JSON body (non-standard, for backwards compatibility)
50+
"""
51+
# Extract client credentials from Authorization: Basic header if present
52+
# (RFC 6749 Section 2.3.1 - preferred method for confidential clients)
53+
basic_client_id = ""
54+
basic_client_secret = ""
55+
auth_header = http_request.headers.get("authorization", "")
56+
if auth_header.startswith("Basic "):
57+
try:
58+
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
59+
if ":" in decoded:
60+
basic_client_id, basic_client_secret = decoded.split(":", 1)
61+
except (ValueError, UnicodeDecodeError) as e:
62+
_logger.debug("Failed to decode Basic Auth header: %s", e)
63+
64+
content_type = http_request.headers.get("content-type", "")
65+
if "application/x-www-form-urlencoded" in content_type:
66+
form = await http_request.form()
67+
return TokenRequest(
68+
grant_type=form.get("grant_type", ""),
69+
client_id=form.get("client_id", "") or basic_client_id,
70+
client_secret=form.get("client_secret", "") or basic_client_secret,
71+
)
72+
73+
# Try JSON body
74+
try:
75+
body = await http_request.json()
76+
return TokenRequest(**body)
77+
except Exception as e:
78+
_logger.debug("Could not parse token request from JSON body, falling back: %s", e)
79+
80+
# Fall back to Basic Auth only (grant_type defaults to client_credentials)
81+
if basic_client_id:
82+
return TokenRequest(
83+
grant_type="client_credentials",
84+
client_id=basic_client_id,
85+
client_secret=basic_client_secret,
86+
)
87+
88+
raise HTTPException(
89+
status_code=status.HTTP_400_BAD_REQUEST,
90+
detail="Unable to parse token request. Send credentials via HTTP Basic Auth header, "
91+
"form-encoded body, or JSON body.",
92+
)
93+
94+
4295
@oauth_router.post("/oauth/token", response_model=TokenResponse)
4396
async def get_token(
4497
http_request: Request,
45-
request: TokenRequest,
98+
token_request: Annotated[TokenRequest, Depends(_parse_token_request)],
4699
env: Annotated[Environment, Depends(odoo_env)],
47100
_rate_limit: Annotated[None, Depends(check_auth_rate_limit)],
48101
):
49102
"""
50103
OAuth 2.0 Client Credentials flow.
51104
52105
Authenticates API client and returns JWT access token.
106+
Accepts both JSON and form-encoded request bodies (RFC 6749).
53107
54108
SECURITY: Rate limited to 5 requests/minute per IP to prevent brute force.
55109
"""
56110
# Validate grant type
57-
if request.grant_type != "client_credentials":
111+
if token_request.grant_type != "client_credentials":
58112
raise HTTPException(
59113
status_code=status.HTTP_400_BAD_REQUEST,
60114
detail="Unsupported grant_type. Only 'client_credentials' is supported.",
61115
)
62116

63117
# Authenticate client
64118
# nosemgrep: odoo-sudo-without-context
65-
api_client = env["spp.api.client"].sudo().authenticate(request.client_id, request.client_secret)
119+
api_client = env["spp.api.client"].sudo().authenticate(token_request.client_id, token_request.client_secret)
66120

67121
if not api_client:
68-
_logger.warning("Failed authentication attempt for client_id: %s", request.client_id)
122+
_logger.warning("Failed authentication attempt for client_id: %s", token_request.client_id)
69123
raise HTTPException(
70124
status_code=status.HTTP_401_UNAUTHORIZED,
71125
detail="Invalid client credentials",
72126
)
73127

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+
74133
# Generate JWT token
75134
try:
76-
token = _generate_jwt_token(env, api_client)
135+
token = _generate_jwt_token(env, api_client, token_lifetime_hours)
77136
except Exception as e:
78137
_logger.exception("Error generating JWT token")
79138
raise HTTPException(
@@ -87,7 +146,7 @@ async def get_token(
87146
return TokenResponse(
88147
access_token=token,
89148
token_type="Bearer",
90-
expires_in=3600, # 1 hour
149+
expires_in=expires_in,
91150
scope=scope_str,
92151
)
93152

@@ -129,14 +188,14 @@ def _get_jwt_secret(env: Environment) -> str:
129188
return secret
130189

131190

132-
def _generate_jwt_token(env: Environment, api_client) -> str:
191+
def _generate_jwt_token(env: Environment, api_client, token_lifetime_hours: int) -> str:
133192
"""
134193
Generate JWT access token for API client.
135194
136195
Token contains:
137196
- client_id (external identifier, NOT database ID)
138197
- scopes
139-
- expiration (1 hour)
198+
- expiration time determined by token_lifetime_hours
140199
141200
SECURITY: Never include database IDs in JWT.
142201
The auth middleware loads the full api_client record from DB using client_id.
@@ -160,7 +219,7 @@ def _generate_jwt_token(env: Environment, api_client) -> str:
160219
"iss": "openspp-api-v2", # Issuer
161220
"sub": api_client.client_id, # Subject (client_id)
162221
"aud": "openspp", # Audience
163-
"exp": now + timedelta(hours=1), # Expiration
222+
"exp": now + timedelta(hours=token_lifetime_hours), # Expiration
164223
"iat": now, # Issued at
165224
"client_id": api_client.client_id,
166225
"scopes": [f"{s.resource}:{s.action}" for s in api_client.scope_ids],

spp_api_v2/tests/test_oauth.py

Lines changed: 52 additions & 1 deletion
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
@@ -45,7 +47,7 @@ def test_token_generation_success(self):
4547
self.assertIn("token_type", data)
4648
self.assertEqual(data["token_type"], "Bearer")
4749
self.assertIn("expires_in", data)
48-
self.assertEqual(data["expires_in"], 3600) # 1 hour
50+
self.assertEqual(data["expires_in"], 86400) # 24 hours (default)
4951
self.assertIn("scope", data)
5052
self.assertIn("individual:read", data["scope"])
5153
self.assertIn("group:search", data["scope"])
@@ -218,6 +220,55 @@ 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+
body = urlencode({"grant_type": "client_credentials"})
228+
229+
response = self.url_open(
230+
self.url,
231+
data=body,
232+
headers={
233+
"Content-Type": "application/x-www-form-urlencoded",
234+
"Authorization": f"Basic {credentials}",
235+
},
236+
)
237+
238+
self.assertEqual(response.status_code, 200)
239+
240+
data = json.loads(response.content)
241+
self.assertIn("access_token", data)
242+
self.assertEqual(data["token_type"], "Bearer")
243+
self.assertIn("expires_in", data)
244+
self.assertIn("scope", data)
245+
246+
def test_token_generation_form_encoded(self):
247+
"""Form-encoded body (application/x-www-form-urlencoded) returns access token"""
248+
body = urlencode(
249+
{
250+
"grant_type": "client_credentials",
251+
"client_id": self.client.client_id,
252+
"client_secret": self.client.client_secret,
253+
}
254+
)
255+
256+
response = self.url_open(
257+
self.url,
258+
data=body,
259+
headers={"Content-Type": "application/x-www-form-urlencoded"},
260+
)
261+
262+
self.assertEqual(response.status_code, 200)
263+
264+
data = json.loads(response.content)
265+
self.assertIn("access_token", data)
266+
self.assertEqual(data["token_type"], "Bearer")
267+
self.assertIn("expires_in", data)
268+
self.assertIn("scope", data)
269+
self.assertIn("individual:read", data["scope"])
270+
self.assertIn("group:search", data["scope"])
271+
221272
def test_token_no_scopes(self):
222273
"""Client with no scopes still gets token but empty scope string"""
223274
# Create client without scopes

spp_dci_server/tests/test_transaction.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import json
55
from unittest.mock import MagicMock, patch
66

7+
from psycopg2 import IntegrityError
8+
79
from odoo.exceptions import ValidationError
810
from odoo.tests import tagged
911

@@ -72,7 +74,9 @@ def test_transaction_uniqueness_per_sender(self):
7274
)
7375

7476
# Same transaction_id, same sender - should fail
75-
with self.assertRaises(ValidationError):
77+
# SQL UNIQUE constraint raises IntegrityError, use cr.savepoint()
78+
# to avoid breaking the test transaction
79+
with self.assertRaises(IntegrityError), self.cr.savepoint():
7680
self.Transaction.create(
7781
{
7882
"transaction_id": "unique-txn-001",
@@ -81,6 +85,7 @@ def test_transaction_uniqueness_per_sender(self):
8185
"sender_uri": self.test_sender_id,
8286
}
8387
)
88+
self.cr.flush()
8489

8590
def test_transaction_same_id_different_sender_allowed(self):
8691
"""Test that same transaction_id is allowed for different senders."""

spp_mis_demo_v2/tests/test_demo_programs.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class TestDemoPrograms(TransactionCase):
88
99
The demo programs use activated registry variables for CEL expressions:
1010
- Universal Child Grant: child_count variable
11+
- Conditional Child Grant: members.exists() for first 1,000 days
1112
- Elderly Social Pension: age + retirement_age variables
1213
- Emergency Relief Fund: dependency_ratio, is_female_headed, elderly_count
1314
- Cash Transfer Program: hh_total_income, poverty_line, hh_size
@@ -16,16 +17,17 @@ class TestDemoPrograms(TransactionCase):
1617
"""
1718

1819
def test_get_all_demo_programs(self):
19-
"""Test that all 6 demo programs are returned."""
20+
"""Test that all 7 demo programs are returned."""
2021
from odoo.addons.spp_mis_demo_v2.models import demo_programs
2122

2223
programs = demo_programs.get_all_demo_programs()
2324
self.assertIsInstance(programs, list)
24-
self.assertEqual(len(programs), 6, "Expected exactly 6 demo programs")
25+
self.assertEqual(len(programs), 7, "Expected exactly 7 demo programs")
2526

2627
# Check expected programs exist (V3 names)
2728
program_names = [p["name"] for p in programs]
2829
self.assertIn("Universal Child Grant", program_names)
30+
self.assertIn("Conditional Child Grant", program_names)
2931
self.assertIn("Elderly Social Pension", program_names)
3032
self.assertIn("Emergency Relief Fund", program_names)
3133
self.assertIn("Cash Transfer Program", program_names)
@@ -348,8 +350,10 @@ def test_get_programs_by_pack(self):
348350
from odoo.addons.spp_mis_demo_v2.models import demo_programs
349351

350352
programs = demo_programs.get_programs_by_pack("child_benefit")
351-
self.assertEqual(len(programs), 1)
352-
self.assertEqual(programs[0]["name"], "Universal Child Grant")
353+
self.assertEqual(len(programs), 2)
354+
program_names = [p["name"] for p in programs]
355+
self.assertIn("Universal Child Grant", program_names)
356+
self.assertIn("Conditional Child Grant", program_names)
353357

354358
# Non-existent pack should return empty
355359
programs = demo_programs.get_programs_by_pack("nonexistent")
@@ -460,11 +464,20 @@ def test_story_program_alignment_complete(self):
460464
)
461465

462466
def test_all_programs_have_enrolled_stories(self):
463-
"""Test that every demo program has at least one enrolled story."""
467+
"""Test that demo programs have at least one enrolled story.
468+
469+
Conditional Child Grant is excluded: it demonstrates compliance
470+
features and members.exists() CEL patterns without persona stories.
471+
"""
464472
from odoo.addons.spp_mis_demo_v2.models import demo_programs
465473

474+
# Programs that intentionally have no story personas
475+
programs_without_stories = {"Conditional Child Grant"}
476+
466477
for program in demo_programs.get_all_demo_programs():
467478
program_name = program["name"]
479+
if program_name in programs_without_stories:
480+
continue
468481
stories = program.get("stories", [])
469482
self.assertGreater(
470483
len(stories),

0 commit comments

Comments
 (0)