Skip to content

Commit 57a8505

Browse files
feat(spp_dci_server): accept OAuth2 access tokens for bearer auth
The DCI server only string-matched a static bearer-token list (dci.api_tokens), so the client's OAuth2 client-credentials support had nothing to authenticate against. Extend verify_bearer_token to also accept an OAuth2 access token: a JWT issued by spp_api_v2 (POST /api_v2/oauth/token) is validated with spp_api_v2's own verifier (HS256 signature, iss/aud, expiry), then the client_id claim must resolve to an active spp.api.client. Static bearer tokens keep working unchanged; the JWT path is an additive fallback. spp_dci_server already depends on spp_api_v2, so the verifier is reused, not duplicated. End-to-end: point the client data source's oauth2_token_url at the server's /api_v2/oauth/token with an spp.api.client's credentials.
1 parent 8c563bf commit 57a8505

6 files changed

Lines changed: 160 additions & 10 deletions

File tree

spp_dci_server/README.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ Key Capabilities
3535
``/dci_api/v1`` with automatic OpenAPI documentation
3636
- **HTTP Signature Verification**: Validates inbound requests using
3737
Ed25519/RSA signatures against sender public keys
38+
- **Bearer Authentication**: Accepts either a static token (system
39+
parameter ``dci.api_tokens``) or an OAuth2 client-credentials access
40+
token (a JWT issued by ``spp_api_v2`` at ``POST /api_v2/oauth/token``,
41+
validated against an active ``spp.api.client``)
3842
- **Async Transaction Processing**: Queues search, subscribe, and
3943
unsubscribe operations for background processing with automatic
4044
callbacks

spp_dci_server/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{ # pylint: disable=pointless-statement
22
"name": "OpenSPP DCI Server",
33
"summary": "DCI API server infrastructure with FastAPI routers",
4-
"version": "19.0.2.0.1",
4+
"version": "19.0.2.0.2",
55
"category": "OpenSPP/Integration",
66
"author": "OpenSPP.org",
77
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_dci_server/middleware/signature.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,40 @@ async def verify_dci_signature(
246246
_empty_tokens_warning_logged = False
247247

248248

249+
def _validate_oauth2_jwt(env: Environment, token: str) -> str | None:
250+
"""Return the client_id when ``token`` is a valid OAuth2 access token.
251+
252+
Lets a DCI caller authenticate with an OAuth2 client-credentials JWT issued
253+
by spp_api_v2 (``POST /api_v2/oauth/token``) instead of a static bearer
254+
token. The JWT is validated with spp_api_v2's own verifier (HS256 signature,
255+
``iss``/``aud``, expiry), then the ``client_id`` claim must resolve to an
256+
active ``spp.api.client``.
257+
258+
Returns the client_id on success, or ``None`` on any failure (bad signature,
259+
expired, wrong issuer/audience, secret unset, unknown/inactive client, or a
260+
non-JWT opaque token) so the caller can fall back to other auth paths.
261+
"""
262+
try:
263+
from odoo.addons.spp_api_v2.middleware.auth import _validate_jwt_token
264+
except ImportError:
265+
return None
266+
try:
267+
payload = _validate_jwt_token(env, token)
268+
except Exception:
269+
# Not a valid spp_api_v2 JWT — fall back to static-token handling.
270+
return None
271+
client_id = payload.get("client_id")
272+
if not client_id:
273+
return None
274+
# nosemgrep: odoo-sudo-without-context
275+
client = env["spp.api.client"].sudo().search([("client_id", "=", client_id), ("active", "=", True)], limit=1)
276+
if not client:
277+
_logger.warning("OAuth2 JWT names unknown/inactive client_id: %s", client_id)
278+
return None
279+
_logger.debug("DCI request authenticated via OAuth2 JWT for client %s", client_id)
280+
return client_id
281+
282+
249283
async def verify_bearer_token(
250284
env: Annotated[Environment, Depends(odoo_env)],
251285
authorization: Annotated[str | None, Header()] = None,
@@ -332,15 +366,22 @@ async def verify_bearer_token(
332366
for candidate in accepted_tokens:
333367
if hmac.compare_digest(token, candidate):
334368
matched = True
335-
if not matched:
336-
_logger.warning("DCI request has invalid Bearer token")
337-
raise DCIHTTPException(
338-
status_code=status.HTTP_401_UNAUTHORIZED,
339-
error_message="Invalid Bearer token",
340-
error_code="err.auth.invalid_token",
341-
headers={"WWW-Authenticate": "Bearer"},
342-
)
343-
_logger.debug("Bearer token validated against configured tokens")
369+
if matched:
370+
_logger.debug("Bearer token validated against configured tokens")
371+
return token
372+
# Not a configured static token — it may be an OAuth2 access token.
373+
if _validate_oauth2_jwt(env, token):
374+
return token
375+
_logger.warning("DCI request has invalid Bearer token")
376+
raise DCIHTTPException(
377+
status_code=status.HTTP_401_UNAUTHORIZED,
378+
error_message="Invalid Bearer token",
379+
error_code="err.auth.invalid_token",
380+
headers={"WWW-Authenticate": "Bearer"},
381+
)
382+
383+
# No static tokens configured: still accept a valid OAuth2 access token.
384+
if _validate_oauth2_jwt(env, token):
344385
return token
345386

346387
# nosemgrep: odoo-timing-attack-password # not a token compare; matches a config flag value

spp_dci_server/readme/DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ DCI API server infrastructure for receiving and processing Digital Convergence I
44

55
- **FastAPI Endpoints**: Exposes DCI-compliant REST API at `/dci_api/v1` with automatic OpenAPI documentation
66
- **HTTP Signature Verification**: Validates inbound requests using Ed25519/RSA signatures against sender public keys
7+
- **Bearer Authentication**: Accepts either a static token (system parameter `dci.api_tokens`) or an OAuth2 client-credentials access token (a JWT issued by `spp_api_v2` at `POST /api_v2/oauth/token`, validated against an active `spp.api.client`)
78
- **Async Transaction Processing**: Queues search, subscribe, and unsubscribe operations for background processing with automatic callbacks
89
- **Event Subscriptions**: Manages external system subscriptions to registry events (registration, update, delete) with notification delivery
910
- **JWKS Distribution**: Publishes server public keys at `/.well-known/jwks.json` for signature verification by clients

spp_dci_server/static/description/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,10 @@ <h1>Key Capabilities</h1>
382382
<tt class="docutils literal">/dci_api/v1</tt> with automatic OpenAPI documentation</li>
383383
<li><strong>HTTP Signature Verification</strong>: Validates inbound requests using
384384
Ed25519/RSA signatures against sender public keys</li>
385+
<li><strong>Bearer Authentication</strong>: Accepts either a static token (system
386+
parameter <tt class="docutils literal">dci.api_tokens</tt>) or an OAuth2 client-credentials access
387+
token (a JWT issued by <tt class="docutils literal">spp_api_v2</tt> at <tt class="docutils literal">POST /api_v2/oauth/token</tt>,
388+
validated against an active <tt class="docutils literal">spp.api.client</tt>)</li>
385389
<li><strong>Async Transaction Processing</strong>: Queues search, subscribe, and
386390
unsubscribe operations for background processing with automatic
387391
callbacks</li>

spp_dci_server/tests/test_bearer_middleware.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@
99
"""
1010

1111
import asyncio
12+
import os
13+
from datetime import datetime, timedelta
1214

1315
from odoo.tests import tagged
1416

1517
from fastapi import HTTPException
1618

1719
from .common import DCIServerCommon
1820

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+
1925

2026
def _run(coro):
2127
loop = asyncio.new_event_loop()
@@ -191,3 +197,97 @@ def test_security_flags_default_to_fail_closed(self):
191197
safe_value,
192198
f"{key} must default to {safe_value!r} (fail-closed)",
193199
)
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

Comments
 (0)