@@ -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 )
4397async 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 ],
0 commit comments