|
9 | 9 | """ |
10 | 10 |
|
11 | 11 | import asyncio |
| 12 | +import os |
| 13 | +from datetime import datetime, timedelta |
12 | 14 |
|
13 | 15 | from odoo.tests import tagged |
14 | 16 |
|
15 | 17 | from fastapi import HTTPException |
16 | 18 |
|
17 | 19 | from .common import DCIServerCommon |
18 | 20 |
|
| 21 | +# 48-char high-entropy secret so spp_api_v2's _validate_jwt_secret_strength |
| 22 | +# (>=32 chars, entropy >= 3.0) accepts it. |
| 23 | +_TEST_JWT_SECRET = "Zx9Kq2Lm7Pw4Rt6Yv1Nb8Hc3Jd5Fg0SaUeWiOqTzXyMnBvCr" |
| 24 | + |
19 | 25 |
|
20 | 26 | def _run(coro): |
21 | 27 | loop = asyncio.new_event_loop() |
@@ -191,3 +197,97 @@ def test_security_flags_default_to_fail_closed(self): |
191 | 197 | safe_value, |
192 | 198 | f"{key} must default to {safe_value!r} (fail-closed)", |
193 | 199 | ) |
| 200 | + |
| 201 | + |
| 202 | +@tagged("post_install", "-at_install") |
| 203 | +class TestOAuth2BearerToken(DCIServerCommon): |
| 204 | + """The bearer dependency also accepts OAuth2 access tokens (spp_api_v2 |
| 205 | + JWTs) so DCI callers can authenticate with client-credentials, not only |
| 206 | + static tokens.""" |
| 207 | + |
| 208 | + def setUp(self): |
| 209 | + super().setUp() |
| 210 | + from odoo.addons.spp_dci_server.middleware import signature as sig_module |
| 211 | + |
| 212 | + self.verify_bearer_token = sig_module.verify_bearer_token |
| 213 | + sig_module._bearer_bypass_warning_logged = False |
| 214 | + sig_module._empty_tokens_warning_logged = False |
| 215 | + self.ICP = self.env["ir.config_parameter"].sudo() |
| 216 | + # Sign with the secret the verifier will use (env var wins over param). |
| 217 | + self.secret = os.environ.get("OPENSPP_JWT_SECRET") or _TEST_JWT_SECRET |
| 218 | + if not os.environ.get("OPENSPP_JWT_SECRET"): |
| 219 | + self.ICP.set_param("spp_api_v2.jwt_secret", self.secret) |
| 220 | + self.client = self.env["spp.api.client"].create( |
| 221 | + { |
| 222 | + "name": "DCI OAuth Test Client", |
| 223 | + "partner_id": self.test_partner.id, |
| 224 | + "organization_type_id": self.org_type_government.id, |
| 225 | + } |
| 226 | + ) |
| 227 | + |
| 228 | + def _call(self, authorization=None): |
| 229 | + return _run(self.verify_bearer_token(self.env, authorization)) |
| 230 | + |
| 231 | + def _mint_jwt(self, client_id=None, expires_in_hours=1, secret=None): |
| 232 | + import jwt |
| 233 | + |
| 234 | + now = datetime.utcnow() |
| 235 | + payload = { |
| 236 | + "iss": "openspp-api-v2", |
| 237 | + "aud": "openspp", |
| 238 | + "sub": client_id or self.client.client_id, |
| 239 | + "client_id": client_id or self.client.client_id, |
| 240 | + "iat": now, |
| 241 | + "exp": now + timedelta(hours=expires_in_hours), |
| 242 | + "scopes": [], |
| 243 | + } |
| 244 | + return jwt.encode(payload, secret or self.secret, algorithm="HS256") |
| 245 | + |
| 246 | + def test_valid_oauth2_jwt_accepted(self): |
| 247 | + """A valid OAuth2 JWT is accepted even with no static tokens and |
| 248 | + dci.api_tokens_required=true.""" |
| 249 | + self.ICP.set_param("dci.api_tokens", "") |
| 250 | + self.ICP.set_param("dci.api_tokens_required", "true") |
| 251 | + token = self._mint_jwt() |
| 252 | + self.assertEqual(self._call(f"Bearer {token}"), token) |
| 253 | + |
| 254 | + def test_oauth2_jwt_accepted_alongside_nonmatching_static(self): |
| 255 | + """A valid OAuth2 JWT is accepted even when a (non-matching) static |
| 256 | + token list is configured.""" |
| 257 | + self.ICP.set_param("dci.api_tokens", "some-other-static-token") |
| 258 | + token = self._mint_jwt() |
| 259 | + self.assertEqual(self._call(f"Bearer {token}"), token) |
| 260 | + |
| 261 | + def test_static_token_still_accepted(self): |
| 262 | + """Regression: configured static tokens keep working.""" |
| 263 | + self.ICP.set_param("dci.api_tokens", "static-abc") |
| 264 | + self.assertEqual(self._call("Bearer static-abc"), "static-abc") |
| 265 | + |
| 266 | + def test_expired_oauth2_jwt_rejected(self): |
| 267 | + self.ICP.set_param("dci.api_tokens", "") |
| 268 | + token = self._mint_jwt(expires_in_hours=-1) |
| 269 | + with self.assertRaises(HTTPException) as ctx: |
| 270 | + self._call(f"Bearer {token}") |
| 271 | + self.assertEqual(ctx.exception.status_code, 401) |
| 272 | + |
| 273 | + def test_invalid_signature_jwt_rejected(self): |
| 274 | + self.ICP.set_param("dci.api_tokens", "") |
| 275 | + token = self._mint_jwt(secret="wrong-but-long-enough-secret-aB3dE6fH9jK2mN5pQ8rT1v") |
| 276 | + with self.assertRaises(HTTPException) as ctx: |
| 277 | + self._call(f"Bearer {token}") |
| 278 | + self.assertEqual(ctx.exception.status_code, 401) |
| 279 | + |
| 280 | + def test_oauth2_jwt_for_inactive_client_rejected(self): |
| 281 | + self.ICP.set_param("dci.api_tokens", "") |
| 282 | + self.client.active = False |
| 283 | + token = self._mint_jwt() |
| 284 | + with self.assertRaises(HTTPException) as ctx: |
| 285 | + self._call(f"Bearer {token}") |
| 286 | + self.assertEqual(ctx.exception.status_code, 401) |
| 287 | + |
| 288 | + def test_oauth2_jwt_unknown_client_rejected(self): |
| 289 | + self.ICP.set_param("dci.api_tokens", "") |
| 290 | + token = self._mint_jwt(client_id="no-such-client-id") |
| 291 | + with self.assertRaises(HTTPException) as ctx: |
| 292 | + self._call(f"Bearer {token}") |
| 293 | + self.assertEqual(ctx.exception.status_code, 401) |
0 commit comments