Skip to content

Commit baaf8e9

Browse files
committed
feat(spp_api_v2): support HTTP Basic Auth on OAuth token endpoint
Add RFC 6749-compliant client authentication to /oauth/token endpoint: - HTTP Basic Auth header parsing (Authorization: Basic) - Form-encoded body support (application/x-www-form-urlencoded) - Configurable token lifetime via spp_api_v2.token_lifetime_hours (default 24h) - Keeps JSON body for backwards compatibility Needed for QGIS native OAuth2 flow, which sends Basic Auth headers.
1 parent 69e0e5a commit baaf8e9

File tree

2 files changed

+71
-7
lines changed

2 files changed

+71
-7
lines changed

spp_api_v2/routers/oauth.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,33 +39,88 @@ class TokenResponse(BaseModel):
3939
scope: str
4040

4141

42+
async def _parse_token_request(http_request: Request) -> TokenRequest:
43+
"""Parse token request from JSON, form-encoded body, or HTTP Basic Auth.
44+
45+
Supports three client authentication methods per RFC 6749:
46+
1. HTTP Basic Auth header (Section 2.3.1) - used by QGIS native OAuth2
47+
2. Form-encoded body with client_id/client_secret (Section 2.3.1)
48+
3. JSON body (non-standard, for backwards compatibility)
49+
"""
50+
# Extract client credentials from Authorization: Basic header if present
51+
# (RFC 6749 Section 2.3.1 - preferred method for confidential clients)
52+
basic_client_id = ""
53+
basic_client_secret = ""
54+
auth_header = http_request.headers.get("authorization", "")
55+
if auth_header.startswith("Basic "):
56+
import base64
57+
58+
try:
59+
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
60+
if ":" in decoded:
61+
basic_client_id, basic_client_secret = decoded.split(":", 1)
62+
except (ValueError, UnicodeDecodeError):
63+
pass
64+
65+
content_type = http_request.headers.get("content-type", "")
66+
if "application/x-www-form-urlencoded" in content_type:
67+
form = await http_request.form()
68+
return TokenRequest(
69+
grant_type=form.get("grant_type", ""),
70+
client_id=form.get("client_id", "") or basic_client_id,
71+
client_secret=form.get("client_secret", "") or basic_client_secret,
72+
)
73+
74+
# Try JSON body
75+
try:
76+
body = await http_request.json()
77+
return TokenRequest(**body)
78+
except Exception:
79+
pass
80+
81+
# Fall back to Basic Auth only (grant_type defaults to client_credentials)
82+
if basic_client_id:
83+
return TokenRequest(
84+
grant_type="client_credentials",
85+
client_id=basic_client_id,
86+
client_secret=basic_client_secret,
87+
)
88+
89+
raise HTTPException(
90+
status_code=status.HTTP_400_BAD_REQUEST,
91+
detail="Unable to parse token request. Send credentials via HTTP Basic Auth header, "
92+
"form-encoded body, or JSON body.",
93+
)
94+
95+
4296
@oauth_router.post("/oauth/token", response_model=TokenResponse)
4397
async def get_token(
4498
http_request: Request,
45-
request: TokenRequest,
99+
token_request: Annotated[TokenRequest, Depends(_parse_token_request)],
46100
env: Annotated[Environment, Depends(odoo_env)],
47101
_rate_limit: Annotated[None, Depends(check_auth_rate_limit)],
48102
):
49103
"""
50104
OAuth 2.0 Client Credentials flow.
51105
52106
Authenticates API client and returns JWT access token.
107+
Accepts both JSON and form-encoded request bodies (RFC 6749).
53108
54109
SECURITY: Rate limited to 5 requests/minute per IP to prevent brute force.
55110
"""
56111
# Validate grant type
57-
if request.grant_type != "client_credentials":
112+
if token_request.grant_type != "client_credentials":
58113
raise HTTPException(
59114
status_code=status.HTTP_400_BAD_REQUEST,
60115
detail="Unsupported grant_type. Only 'client_credentials' is supported.",
61116
)
62117

63118
# Authenticate client
64119
# nosemgrep: odoo-sudo-without-context
65-
api_client = env["spp.api.client"].sudo().authenticate(request.client_id, request.client_secret)
120+
api_client = env["spp.api.client"].sudo().authenticate(token_request.client_id, token_request.client_secret)
66121

67122
if not api_client:
68-
_logger.warning("Failed authentication attempt for client_id: %s", request.client_id)
123+
_logger.warning("Failed authentication attempt for client_id: %s", token_request.client_id)
69124
raise HTTPException(
70125
status_code=status.HTTP_401_UNAUTHORIZED,
71126
detail="Invalid client credentials",
@@ -84,10 +139,15 @@ async def get_token(
84139
# Build scope string from client scopes
85140
scope_str = " ".join(f"{s.resource}:{s.action}" for s in api_client.scope_ids)
86141

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+
87147
return TokenResponse(
88148
access_token=token,
89149
token_type="Bearer",
90-
expires_in=3600, # 1 hour
150+
expires_in=expires_in,
91151
scope=scope_str,
92152
)
93153

@@ -155,12 +215,16 @@ def _generate_jwt_token(env: Environment, api_client) -> str:
155215
# Build payload
156216
# SECURITY: Never include database IDs in JWT - use client_id only
157217
# 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+
158222
now = datetime.utcnow()
159223
payload = {
160224
"iss": "openspp-api-v2", # Issuer
161225
"sub": api_client.client_id, # Subject (client_id)
162226
"aud": "openspp", # Audience
163-
"exp": now + timedelta(hours=1), # Expiration
227+
"exp": now + timedelta(hours=token_lifetime_hours), # Expiration
164228
"iat": now, # Issued at
165229
"client_id": api_client.client_id,
166230
"scopes": [f"{s.resource}:{s.action}" for s in api_client.scope_ids],

spp_api_v2/tests/test_oauth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def test_token_generation_success(self):
4545
self.assertIn("token_type", data)
4646
self.assertEqual(data["token_type"], "Bearer")
4747
self.assertIn("expires_in", data)
48-
self.assertEqual(data["expires_in"], 3600) # 1 hour
48+
self.assertEqual(data["expires_in"], 86400) # 24 hours (default)
4949
self.assertIn("scope", data)
5050
self.assertIn("individual:read", data["scope"])
5151
self.assertIn("group:search", data["scope"])

0 commit comments

Comments
 (0)