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
45import logging
56import os
67from 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 )
4396async 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 ],
0 commit comments