Skip to content

Commit de347d3

Browse files
refactor(spp_oauth,spp_api_v2_oauth): address staff engineer review findings
Rename abbreviated field/param names to full forms per naming conventions: - oauth_priv_key → oauth_private_key - oauth_pub_key → oauth_public_key across models, config params, views, data, tests, and docs. spp_oauth changes: - Category: "OpenSPP" → "OpenSPP/Integration" - ACL: restrict to base.group_system with perm_create=1 - Remove ERROR logging from exception constructor (let callers decide) - Rename model class RegistryConfig → OAuthConfig - Add wrong-key verification test - Improve test assertions (assertIsNotNone) and remove numbered prefixes - Fix stale doc references to removed logging behavior spp_api_v2_oauth changes: - Remove empty ir.model.access.csv (no new models) - Add RS256 endpoint to API auth allowlist - Fix nosemgrep annotation for multiline sudo() - Remove Python <3.11 compat guard (Odoo 19 requires 3.11+) - Import constants from ..constants instead of redeclaring - Replace silent if-guards with skipTest() for endpoint tests - Safe int() cast for token_lifetime_hours config - asyncio.get_event_loop() → asyncio.run() - Manual key restore → addCleanup for test safety - SimpleNamespace for mock credentials
1 parent c95f9d0 commit de347d3

26 files changed

Lines changed: 362 additions & 169 deletions

scripts/audit-api-auth.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,9 @@
4848
# Format: (module_dir, router_file_basename, function_name)
4949
# Keep this list small and review changes carefully.
5050
ALLOWED_PUBLIC = {
51-
# OAuth token endpoint - public by design
51+
# OAuth token endpoints - public by design
5252
("spp_api_v2", "oauth.py", "get_token"),
53+
("spp_api_v2_oauth", "oauth_rs256.py", "get_rs256_token"),
5354
# Capability/metadata discovery - public by design
5455
("spp_api_v2", "metadata.py", "get_metadata"),
5556
# DCI callback endpoints - called by external systems

spp_api_v2_oauth/README.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ verification):
167167
168168
import jwt
169169
header = jwt.get_unverified_header(token)
170-
print(header["alg"]) # "RS256" or "HS256"
170+
# header["alg"] will be "RS256" or "HS256"
171171
172172
Error Responses
173173
~~~~~~~~~~~~~~~
@@ -185,6 +185,9 @@ Error Responses
185185
+---------------------------+-------------+---------------------------+
186186
| Invalid signature | 401 | "Invalid token" |
187187
+---------------------------+-------------+---------------------------+
188+
| Unsupported algorithm | 401 | "Unsupported token |
189+
| | | algorithm: {alg}" |
190+
+---------------------------+-------------+---------------------------+
188191
| Rate limit exceeded | 429 | "Rate limit exceeded" |
189192
+---------------------------+-------------+---------------------------+
190193

spp_api_v2_oauth/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
[project]
22
name = "odoo-addon-spp_api_v2_oauth"
3+
4+
[build-system]
5+
requires = ["whool"]
6+
build-backend = "whool.buildapi"

spp_api_v2_oauth/readme/USAGE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ To confirm which algorithm a token uses, decode the JWT header (without verifica
5656
```python
5757
import jwt
5858
header = jwt.get_unverified_header(token)
59-
print(header["alg"]) # "RS256" or "HS256"
59+
# header["alg"] will be "RS256" or "HS256"
6060
```
6161

6262
### Error Responses
@@ -67,4 +67,5 @@ print(header["alg"]) # "RS256" or "HS256"
6767
| Invalid credentials | 401 | "Invalid client credentials" |
6868
| Expired token | 401 | "Token expired" |
6969
| Invalid signature | 401 | "Invalid token" |
70+
| Unsupported algorithm | 401 | "Unsupported token algorithm: {alg}" |
7071
| Rate limit exceeded | 429 | "Rate limit exceeded" |

spp_api_v2_oauth/routers/oauth_rs256.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,14 @@ async def get_rs256_token(
7070
)
7171

7272
# Read configurable token lifetime (same config as HS256 endpoint)
73-
# nosemgrep: odoo-sudo-without-context
74-
token_lifetime_hours = int(
75-
env["ir.config_parameter"]
76-
.sudo()
77-
.get_param("spp_api_v2.token_lifetime_hours", str(DEFAULT_TOKEN_LIFETIME_HOURS))
78-
)
73+
config_param = env["ir.config_parameter"].sudo() # nosemgrep: odoo-sudo-without-context
74+
try:
75+
token_lifetime_hours = int(
76+
config_param.get_param("spp_api_v2.token_lifetime_hours", str(DEFAULT_TOKEN_LIFETIME_HOURS))
77+
)
78+
except (ValueError, TypeError):
79+
_logger.warning("Invalid token_lifetime_hours config, using default %s", DEFAULT_TOKEN_LIFETIME_HOURS)
80+
token_lifetime_hours = DEFAULT_TOKEN_LIFETIME_HOURS
7981
expires_in = token_lifetime_hours * 3600
8082

8183
# Generate RS256 JWT token

spp_api_v2_oauth/security/ir.model.access.csv

Lines changed: 0 additions & 1 deletion
This file was deleted.

spp_api_v2_oauth/static/description/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@ <h1>Verify Token Algorithm</h1>
529529
<pre class="code python literal-block">
530530
<span class="kn">import</span><span class="w"> </span><span class="nn">jwt</span><span class="w">
531531
</span><span class="n">header</span> <span class="o">=</span> <span class="n">jwt</span><span class="o">.</span><span class="n">get_unverified_header</span><span class="p">(</span><span class="n">token</span><span class="p">)</span><span class="w">
532-
</span><span class="nb">print</span><span class="p">(</span><span class="n">header</span><span class="p">[</span><span class="s2">&quot;alg&quot;</span><span class="p">])</span> <span class="c1"># &quot;RS256&quot; or &quot;HS256&quot;</span>
532+
</span><span class="c1"># header[&quot;alg&quot;] will be &quot;RS256&quot; or &quot;HS256&quot;</span>
533533
</pre>
534534
</div>
535535
<div class="section" id="error-responses">
@@ -565,6 +565,11 @@ <h1>Error Responses</h1>
565565
<td>401</td>
566566
<td>“Invalid token”</td>
567567
</tr>
568+
<tr><td>Unsupported algorithm</td>
569+
<td>401</td>
570+
<td>“Unsupported token
571+
algorithm: {alg}”</td>
572+
</tr>
568573
<tr><td>Rate limit exceeded</td>
569574
<td>429</td>
570575
<td>“Rate limit exceeded”</td>

spp_api_v2_oauth/tests/common.py

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22
"""Common test utilities for spp_api_v2_oauth tests."""
33

4-
import sys
5-
from datetime import datetime, timedelta, timezone
4+
from datetime import UTC, datetime, timedelta
5+
from types import SimpleNamespace
66

77
import jwt
88
from cryptography.hazmat.primitives import serialization
99
from cryptography.hazmat.primitives.asymmetric import rsa
1010

1111
from odoo.tests.common import TransactionCase
1212

13-
if sys.version_info >= (3, 11): # noqa: UP036
14-
from datetime import UTC
15-
else:
16-
UTC = timezone.utc # noqa: UP017
17-
18-
# Constants matching spp_api_v2's JWT claims
19-
JWT_AUDIENCE = "openspp"
20-
JWT_ISSUER = "openspp-api-v2"
13+
from ..constants import JWT_AUDIENCE, JWT_ISSUER
2114

2215
# HS256 test secret (same as spp_api_v2 tests)
2316
HS256_TEST_SECRET = "test-secret-key-for-testing-only-do-not-use-in-production"
@@ -53,8 +46,8 @@ def setUpClass(cls):
5346
)
5447

5548
# Store RSA keys in spp_oauth config parameters
56-
cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_priv_key", cls.rsa_private_key_pem)
57-
cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_pub_key", cls.rsa_public_key_pem)
49+
cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_private_key", cls.rsa_private_key_pem)
50+
cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_public_key", cls.rsa_public_key_pem)
5851

5952
# Store HS256 secret for spp_api_v2
6053
cls.env["ir.config_parameter"].sudo().set_param("spp_api_v2.jwt_secret", HS256_TEST_SECRET)
@@ -115,10 +108,4 @@ def generate_hs256_token(self, payload_overrides=None):
115108
@staticmethod
116109
def make_credentials(token):
117110
"""Create a mock HTTPAuthorizationCredentials-like object."""
118-
119-
class _Creds:
120-
pass
121-
122-
creds = _Creds()
123-
creds.credentials = token
124-
return creds
111+
return SimpleNamespace(credentials=token)

spp_api_v2_oauth/tests/test_auth_hs256.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,54 @@ def test_dependency_override_applied(self):
6868
from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client
6969

7070
endpoint = self.env["fastapi.endpoint"].search([("app", "=", "api_v2")], limit=1)
71-
if endpoint:
72-
overrides = endpoint._get_app_dependencies_overrides()
73-
self.assertIn(
74-
get_authenticated_client,
75-
overrides,
76-
"get_authenticated_client should be in dependency overrides",
77-
)
78-
79-
from ..middleware.auth_rs256 import get_authenticated_client_rs256
80-
81-
self.assertEqual(
82-
overrides[get_authenticated_client],
83-
get_authenticated_client_rs256,
84-
"Override should point to the RS256 bridge function",
85-
)
71+
if not endpoint:
72+
self.skipTest("No api_v2 endpoint configured in test database")
73+
74+
overrides = endpoint._get_app_dependencies_overrides()
75+
self.assertIn(
76+
get_authenticated_client,
77+
overrides,
78+
"get_authenticated_client should be in dependency overrides",
79+
)
80+
81+
from ..middleware.auth_rs256 import get_authenticated_client_rs256
82+
83+
self.assertEqual(
84+
overrides[get_authenticated_client],
85+
get_authenticated_client_rs256,
86+
"Override should point to the RS256 bridge function",
87+
)
88+
89+
def test_router_registration(self):
90+
"""Verify the RS256 router is registered for api_v2 endpoints."""
91+
endpoint = self.env["fastapi.endpoint"].search([("app", "=", "api_v2")], limit=1)
92+
if not endpoint:
93+
self.skipTest("No api_v2 endpoint configured in test database")
94+
95+
routers = endpoint._get_fastapi_routers()
96+
# Check that at least one router contains a route to /oauth/token/rs256
97+
rs256_routes = [
98+
route
99+
for router in routers
100+
for route in router.routes
101+
if hasattr(route, "path") and route.path == "/oauth/token/rs256"
102+
]
103+
self.assertTrue(
104+
rs256_routes,
105+
"RS256 token endpoint should be registered in api_v2 routers",
106+
)
107+
108+
def test_no_override_for_non_api_v2(self):
109+
"""Bridge overrides should NOT apply to non-api_v2 endpoints."""
110+
from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client
111+
112+
endpoint = self.env["fastapi.endpoint"].search([("app", "!=", "api_v2")], limit=1)
113+
if not endpoint:
114+
self.skipTest("No non-api_v2 endpoint configured in test database")
115+
116+
overrides = endpoint._get_app_dependencies_overrides()
117+
self.assertNotIn(
118+
get_authenticated_client,
119+
overrides,
120+
"get_authenticated_client should NOT be overridden for non-api_v2 endpoints",
121+
)

spp_api_v2_oauth/tests/test_auth_rs256.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def test_rs256_keys_not_configured(self):
8989
from ..middleware.auth_rs256 import _validate_rs256_token
9090

9191
# Clear RSA public key
92-
self.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_pub_key", False)
92+
self.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_public_key", False)
9393

9494
token = self.generate_rs256_token()
9595

@@ -160,3 +160,32 @@ def test_header_routing_rs256(self):
160160

161161
client = get_authenticated_client_rs256(creds, self.env)
162162
self.assertEqual(client.client_id, self.api_client.client_id)
163+
164+
def test_unsupported_algorithm_rejected(self):
165+
"""Token with unsupported algorithm (not RS256/HS256) is rejected."""
166+
from ..middleware.auth_rs256 import get_authenticated_client_rs256
167+
168+
# Create a token with HS384 algorithm (unsupported by our bridge)
169+
payload = self._build_jwt_payload()
170+
secret = "a-secret-key-long-enough-for-hs384-testing-only!!"
171+
token = jwt.encode(payload, secret, algorithm="HS384")
172+
creds = self.make_credentials(token)
173+
174+
with self.assertRaises(HTTPException) as ctx:
175+
get_authenticated_client_rs256(creds, self.env)
176+
self.assertEqual(ctx.exception.status_code, 401)
177+
self.assertIn("Unsupported token algorithm", ctx.exception.detail)
178+
179+
def test_rs256_client_not_found(self):
180+
"""RS256 token with valid signature but non-existent client_id is rejected."""
181+
from ..middleware.auth_rs256 import get_authenticated_client_rs256
182+
183+
token = self.generate_rs256_token(
184+
payload_overrides={"client_id": "non-existent-client-id", "sub": "non-existent-client-id"}
185+
)
186+
creds = self.make_credentials(token)
187+
188+
with self.assertRaises(HTTPException) as ctx:
189+
get_authenticated_client_rs256(creds, self.env)
190+
self.assertEqual(ctx.exception.status_code, 401)
191+
self.assertIn("not found", ctx.exception.detail.lower())

0 commit comments

Comments
 (0)