diff --git a/scripts/audit-api-auth.py b/scripts/audit-api-auth.py index 7a72ee52..52408bea 100755 --- a/scripts/audit-api-auth.py +++ b/scripts/audit-api-auth.py @@ -48,8 +48,9 @@ # Format: (module_dir, router_file_basename, function_name) # Keep this list small and review changes carefully. ALLOWED_PUBLIC = { - # OAuth token endpoint - public by design + # OAuth token endpoints - public by design ("spp_api_v2", "oauth.py", "get_token"), + ("spp_api_v2_oauth", "oauth_rs256.py", "get_rs256_token"), # Capability/metadata discovery - public by design ("spp_api_v2", "metadata.py", "get_metadata"), # DCI callback endpoints - called by external systems diff --git a/spp_api_v2_oauth/README.rst b/spp_api_v2_oauth/README.rst new file mode 100644 index 00000000..bcfe229c --- /dev/null +++ b/spp_api_v2_oauth/README.rst @@ -0,0 +1,371 @@ +================================== +OpenSPP API V2: OAuth RS256 Bridge +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:fcf958693834e280f3eddcb2b6e8050b5c48b1cf04ca7f64638746fb22ed8af8 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OpenSPP%2FOpenSPP2-lightgray.png?logo=github + :target: https://github.com/OpenSPP/OpenSPP2/tree/19.0/spp_api_v2_oauth + :alt: OpenSPP/OpenSPP2 + +|badge1| |badge2| |badge3| + +Bridge module that enables RS256 (asymmetric RSA) JWT authentication for +the OpenSPP API V2. Automatically installed when both ``spp_api_v2`` and +``spp_oauth`` are present. + +What It Does +~~~~~~~~~~~~ + +- Adds RS256 token verification alongside existing HS256 support — both + algorithms are accepted simultaneously +- Provides a dedicated ``/oauth/token/rs256`` endpoint for generating + RS256-signed JWT tokens +- Routes incoming tokens to the correct verification path based on the + JWT header's ``alg`` field +- Enforces the same security controls as HS256: audience, issuer, and + expiration validation + +When To Use RS256 +~~~~~~~~~~~~~~~~~ + +RS256 uses asymmetric RSA keys (public/private pair) instead of a shared +secret: + +- **Distributed deployments**: External systems can verify tokens using + only the public key, without access to the signing secret +- **Zero-trust architectures**: The private key never leaves the token + issuer +- **Regulatory compliance**: Some security standards require asymmetric + signing + +How It Works +~~~~~~~~~~~~ + ++-----------------+----------------------------------------------------+ +| Token Algorithm | Verification Path | ++=================+====================================================+ +| RS256 | RSA public key from ``spp_oauth`` settings + | +| | audience/issuer/expiry validation | ++-----------------+----------------------------------------------------+ +| HS256 | Original ``spp_api_v2`` shared-secret verification | +| | (unchanged) | ++-----------------+----------------------------------------------------+ + +The bridge replaces the ``get_authenticated_client`` FastAPI dependency +via ``dependency_overrides``. All existing API endpoints automatically +support both algorithms — no router changes needed. + +Dependencies +~~~~~~~~~~~~ + +============== ======================================================= +Module Role +============== ======================================================= +``spp_api_v2`` Provides the REST API, HS256 auth, and API client model +``spp_oauth`` Provides RSA key storage and retrieval utilities +============== ======================================================= + +Configuration +~~~~~~~~~~~~~ + +1. Configure RSA keys in **Settings > General Settings > SPP OAuth + Settings** +2. The bridge activates automatically — existing HS256 clients continue + to work unchanged +3. Use ``/oauth/token/rs256`` to obtain RS256-signed tokens + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Prerequisites +~~~~~~~~~~~~~ + +- ``spp_api_v2`` and ``spp_oauth`` modules installed (bridge + auto-installs) +- RSA key pair generated and configured in SPP OAuth Settings +- An API client created in ``spp_api_v2`` with appropriate scopes + +Generate a Signing Keypair +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +RSA-2048 is the default recommendation — NIST-approved through 2030 and +roughly 5× faster on sign/verify than RSA-4096. Choose RSA-3072 or +RSA-4096 only if your organization's compliance policy requires it. + +.. code:: bash + + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem + openssl rsa -in private.pem -pubout -out public.pem + +For new deployments, EC keys (e.g. P-256 → ``ES256``) are even faster +and produce shorter tokens. The Trusted-Issuer **Algorithms** field +already accepts ``ES256/ES384/ES512``; generate an EC keypair with: + +.. code:: bash + + openssl ecparam -name prime256v1 -genkey -noout -out private.pem + openssl ec -in private.pem -pubout -out public.pem + +Configure the keys in **Settings > General Settings > SPP OAuth +Settings**. + +Obtain an RS256 Token +~~~~~~~~~~~~~~~~~~~~~ + +.. code:: bash + + curl -X POST https://your-instance/api/v2/spp/oauth/token/rs256 \ + -H "Content-Type: application/json" \ + -d '{ + "grant_type": "client_credentials", + "client_id": "client_abc123", + "client_secret": "your-client-secret" + }' + +Response: + +.. code:: json + + { + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "token_type": "Bearer", + "expires_in": 86400, + "scope": "individual:read group:read" + } + +Use the Token +~~~~~~~~~~~~~ + +.. code:: bash + + curl https://your-instance/api/v2/spp/Individual/urn:test%23ID-001 \ + -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." + +The API automatically detects RS256 tokens from the JWT header and +verifies them with the configured RSA public key. + +Existing HS256 Clients +~~~~~~~~~~~~~~~~~~~~~~ + +No changes needed. Tokens obtained from the original ``/oauth/token`` +endpoint continue to work. The bridge accepts both RS256 and HS256 +tokens simultaneously, routing based on the ``alg`` field in the JWT +header. + +Accepting Tokens from an External Identity Provider +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +OpenSPP can be configured to accept RS256 tokens issued by an external +IdP (e.g., a Keycloak realm) so deployments can integrate with an +existing organizational identity provider. The bridge dispatches by the +JWT ``iss`` claim: each ``iss`` value points to one **Trusted OAuth +Issuer** record, which provides the verification key (JWKS URI or static +PEM) and the claim that resolves the calling API client. + +1. **Create a Trusted OAuth Issuer record** under **API V2 > Trusted + OAuth Issuers** (admin-only). Required fields: + + - **Issuer** — exact value the IdP puts in ``iss`` (e.g. + ``https://keycloak.example.com/realms/openspp``). + - **Audience** — the value the IdP puts in ``aud`` for tokens + targeted at OpenSPP. + - **Key Source** — either *JWKS URI* (preferred; supports key + rotation) or *Static Public Key (PEM)*. + - **JWKS URI** — must be ``https://`` (plain ``http://`` is rejected + except for ``localhost`` / ``127.0.0.1`` dev IdPs). + - **Client Claim** — the JWT claim whose value must equal the + existing ``spp.api.client.client_id``. Defaults to ``client_id``; + for Keycloak service accounts, ``azp`` or ``sub`` is typical. + +2. **Match the Client Claim value to an existing API Client, AND link + the client to the issuer record.** On the ``spp.api.client`` record: + + - Set ``client_id`` to the value the IdP emits in the configured + ``client_claim``. + - Set **Trusted OAuth Issuer** to the issuer record you created in + step 1. + + Clients with **Trusted OAuth Issuer** left empty are reachable only + by internal HS256/RS256 tokens (issued by OpenSPP's own + ``/oauth/token`` and ``/oauth/token/rs256`` endpoints). A token from + an external IdP will *not* authenticate as such a client even if the + ``client_id`` happens to collide — preventing namespace-collision + attacks against internal clients. + +3. **Request a token from the external IdP**, then call OpenSPP with it: + + .. code:: bash + + curl https://your-instance/api/v2/spp/Individual/urn:test%23ID-001 \ + -H "Authorization: Bearer " + +The bridge: + +- Reads ``iss`` from the unverified payload. +- If ``iss`` matches the internal openspp-api-v2 issuer, uses the + spp_oauth key (existing behavior) and looks up an API client with no + **Trusted OAuth Issuer** set. +- Otherwise looks up the matching active ``spp.oauth.issuer`` record and + verifies with its JWKS or static PEM. +- Reads the configured ``client_claim`` from the verified payload and + resolves an ``spp.api.client`` whose **Trusted OAuth Issuer** equals + the matched issuer record. +- Allows up to 30 seconds of clock skew on token ``exp``/``nbf``/``iat`` + checks to absorb normal NTP drift. + +Example External IdP: Keycloak Realm +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+-------------------------------------------------------------------------------+ +| Field | Value | ++==============+===============================================================================+ +| Name | ``Org Keycloak`` | ++--------------+-------------------------------------------------------------------------------+ +| Issuer | ``https://keycloak.example.org/realms/openspp`` | ++--------------+-------------------------------------------------------------------------------+ +| Audience | ``openspp-api`` (configure an audience mapper in Keycloak to emit this value) | ++--------------+-------------------------------------------------------------------------------+ +| Key Source | ``JWKS URI`` | ++--------------+-------------------------------------------------------------------------------+ +| JWKS URI | ``https://keycloak.example.org/realms/openspp/protocol/openid-connect/certs`` | ++--------------+-------------------------------------------------------------------------------+ +| Algorithms | ``RS256`` | ++--------------+-------------------------------------------------------------------------------+ +| Client Claim | ``azp`` (Keycloak's "authorized party" — the client_id of the service | +| | account) | ++--------------+-------------------------------------------------------------------------------+ + +JWKS responses are cached in process memory for +``JWKS Cache TTL Seconds`` (default 3600). Editing or archiving the +record drops the cached client; the next request rebuilds it. + +Verify Token Algorithm +~~~~~~~~~~~~~~~~~~~~~~ + +To confirm which algorithm a token uses, decode the JWT header (without +verification): + +.. code:: python + + import jwt + header = jwt.get_unverified_header(token) + # header["alg"] will be "RS256" or "HS256" + +Error Responses +~~~~~~~~~~~~~~~ + ++---------------------------+-------------+---------------------------+ +| Scenario | HTTP Status | Detail | ++===========================+=============+===========================+ +| RSA keys not configured | 400 | "RS256 token generation | +| | | not available..." | ++---------------------------+-------------+---------------------------+ +| Invalid credentials | 401 | "Invalid client | +| | | credentials" | ++---------------------------+-------------+---------------------------+ +| Expired token | 401 | "Token expired" | ++---------------------------+-------------+---------------------------+ +| Invalid signature | 401 | "Invalid token" | ++---------------------------+-------------+---------------------------+ +| Unsupported algorithm | 401 | "Unsupported token | +| | | algorithm: {alg}" | ++---------------------------+-------------+---------------------------+ +| Missing ``iss`` claim | 401 | "Invalid token: missing | +| | | iss claim" | ++---------------------------+-------------+---------------------------+ +| Unknown / inactive | 401 | "Untrusted issuer" | +| external issuer | | | ++---------------------------+-------------+---------------------------+ +| Rate limit exceeded | 429 | "Rate limit exceeded" | ++---------------------------+-------------+---------------------------+ + +Changelog +========= + +19.0.2.0.0 +---------- + +Initial Production/Stable release. + +- Auto-installing bridge between ``spp_api_v2`` and ``spp_oauth`` that + adds RS256 JWT authentication to API V2 alongside the existing HS256 + path. Tokens are routed by the JWT header ``alg``; RS256 tokens are + further dispatched by ``iss`` so OpenSPP can accept tokens from + external Identity Providers (e.g. Keycloak) registered as + ``spp.oauth.issuer`` records. +- New endpoint ``POST /oauth/token/rs256`` for internally-issued RS256 + tokens (mirrors ``/oauth/token`` for HS256: same client-credentials + flow, rate limiting, and payload shape). +- New admin model ``spp.oauth.issuer`` (Settings → API V2 → Trusted + OAuth Issuers) for registering external IdPs by ``iss`` value, with + JWKS-URI or static-PEM key sources, configurable client claim, + algorithm whitelist, and process-local JWKS caching. +- ``spp.api.client.oauth_issuer_id`` links an API client to a Trusted + OAuth Issuer. Internal HS256 / internal RS256 tokens only resolve to + clients with no issuer link; external-issuer tokens only resolve to + clients linked to the matching issuer record. Prevents external IdPs + from authenticating as internal clients via colliding claim values. +- 30-second clock-skew leeway on RS256 verification (``exp`` / ``nbf`` / + ``iat``) to absorb normal NTP drift between OpenSPP and external IdPs. +- Algorithm allowlist enforced at both the issuer-record level + (constraints reject HMAC algorithms and ``none`` at write time) and + the JWT verification level (explicit ``algorithms=`` argument on every + ``jwt.decode``). +- JWKS URIs are constrained to ``https://`` (loopback ``http://`` + allowed for dev IdPs); static PEMs are validated with + ``cryptography.load_pem_public_key`` at write time so private-key + paste mistakes are caught immediately. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* OpenSPP.org + +Maintainers +----------- + +.. |maintainer-jeremi| image:: https://github.com/jeremi.png?size=40px + :target: https://github.com/jeremi + :alt: jeremi +.. |maintainer-gonzalesedwin1123| image:: https://github.com/gonzalesedwin1123.png?size=40px + :target: https://github.com/gonzalesedwin1123 + :alt: gonzalesedwin1123 + +Current maintainers: + +|maintainer-jeremi| |maintainer-gonzalesedwin1123| + +This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/spp_api_v2_oauth/__init__.py b/spp_api_v2_oauth/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/spp_api_v2_oauth/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/spp_api_v2_oauth/__manifest__.py b/spp_api_v2_oauth/__manifest__.py new file mode 100644 index 00000000..0a2a30ec --- /dev/null +++ b/spp_api_v2_oauth/__manifest__.py @@ -0,0 +1,25 @@ +# pylint: disable=pointless-statement +{ + "name": "OpenSPP API V2: OAuth RS256 Bridge", + "summary": "Bridges spp_api_v2 and spp_oauth to enable RS256 JWT authentication for the API.", + "category": "OpenSPP/Integration", + "version": "19.0.2.0.0", + "author": "OpenSPP.org", + "development_status": "Production/Stable", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "external_dependencies": {"python": ["pyjwt>=2.4.0", "cryptography"]}, + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "depends": [ + "spp_api_v2", + "spp_oauth", + ], + "data": [ + "security/ir.model.access.csv", + "views/oauth_issuer_views.xml", + "views/api_client_views.xml", + ], + "application": False, + "auto_install": ["spp_api_v2", "spp_oauth"], + "installable": True, +} diff --git a/spp_api_v2_oauth/constants.py b/spp_api_v2_oauth/constants.py new file mode 100644 index 00000000..edb603ff --- /dev/null +++ b/spp_api_v2_oauth/constants.py @@ -0,0 +1,13 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Shared constants for the OAuth RS256 bridge module. + +These must match the values used by spp_api_v2 in auth.py and oauth.py. +""" + +JWT_AUDIENCE = "openspp" +JWT_ISSUER = "openspp-api-v2" + +# Allowed clock skew (seconds) for RS256 verification. Absorbs normal NTP drift +# between OpenSPP and external IdPs. Applied to internal RS256 verification too +# for symmetry; harmless there since the issuer and verifier share a clock. +JWT_CLOCK_SKEW_LEEWAY_SECONDS = 30 diff --git a/spp_api_v2_oauth/middleware/__init__.py b/spp_api_v2_oauth/middleware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/spp_api_v2_oauth/middleware/auth_rs256.py b/spp_api_v2_oauth/middleware/auth_rs256.py new file mode 100644 index 00000000..8314a2cb --- /dev/null +++ b/spp_api_v2_oauth/middleware/auth_rs256.py @@ -0,0 +1,259 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""RS256-aware authentication middleware for API V2. + +Replaces get_authenticated_client via FastAPI dependency override. +Routes verification based on the JWT header `alg` and (for RS256) the `iss` +claim: + + alg == HS256 -> delegated to spp_api_v2 (unchanged) + alg == RS256 -> iss == JWT_ISSUER -> spp_oauth public key + -> iss matches spp.oauth.issuer -> static PEM or JWKS for that record + -> otherwise -> 401 + other -> 401 +""" + +import logging +from typing import Annotated + +import jwt +from jwt.exceptions import PyJWKClientError + +from odoo.api import Environment + +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.spp_api_v2.middleware.auth import _validate_jwt_token +from odoo.addons.spp_oauth.tools import OpenSPPOAuthJWTException, get_public_key + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from ..constants import JWT_AUDIENCE, JWT_CLOCK_SKEW_LEEWAY_SECONDS, JWT_ISSUER +from ..tools.jwks_cache import get_jwks_client + +_logger = logging.getLogger(__name__) + +# Must match the original security object's configuration +security = HTTPBearer(auto_error=False) + + +def get_authenticated_client_rs256( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], + env: Annotated[Environment, Depends(odoo_env)], +): + """Validate JWT token (RS256 or HS256) and return authenticated API client. + + This function replaces spp_api_v2's get_authenticated_client via + FastAPI dependency_overrides. It reads the JWT header's `alg` field + to route to the correct verification path; for RS256 it additionally + routes by the `iss` claim to support multiple trusted issuers. + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing Authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + + try: + try: + header = jwt.get_unverified_header(token) + except jwt.exceptions.DecodeError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from e + + alg = header.get("alg", "") + + if alg == "RS256": + payload, issuer_rec = _validate_rs256_token_with_issuer(env, token) + elif alg == "HS256": + payload = _validate_jwt_token(env, token) + issuer_rec = None + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Unsupported token algorithm: {alg}", + ) + + # Determine which claim holds the API client identifier. + claim_name = issuer_rec.client_claim if issuer_rec else "client_id" + client_id = payload.get(claim_name) + if not client_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token: missing {claim_name}", + ) + + # SECURITY: Scope the client lookup by the resolved issuer record. + # Internal-path tokens (HS256 + internal RS256) only match clients with no + # oauth_issuer_id. External-issuer tokens only match clients explicitly + # linked to that issuer record. Without this, an external IdP that emits + # a claim value colliding with an internal client_id would authenticate + # as the internal client. + domain = [ + ("client_id", "=", client_id), + ("active", "=", True), + ] + if issuer_rec: + domain.append(("oauth_issuer_id", "=", issuer_rec.id)) + else: + domain.append(("oauth_issuer_id", "=", False)) + + api_client = ( + env["spp.api.client"] # nosemgrep: odoo-sudo-without-context + .sudo() + .search(domain, limit=1) + ) + + if not api_client: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Client not found or inactive", + ) + + return api_client + + except HTTPException: + raise + except Exception as e: + _logger.exception("Authentication error") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication failed", + ) from e + + +def _validate_rs256_token_with_issuer(env: Environment, token: str): + """Validate an RS256-signed JWT and return (payload, issuer_record_or_None). + + Routing by `iss`: + - iss == JWT_ISSUER ("openspp-api-v2") -> internal path, key from spp_oauth + - iss matches an active spp.oauth.issuer -> external path, key per record + - iss missing or not matched -> 401 + """ + # SECURITY: We read the iss claim BEFORE signature verification solely to + # decide which key to verify with. ALL claim checks (signature, exp, nbf, + # iat, aud, iss) are disabled here and are run authoritatively by the + # verifying jwt.decode() inside _validate_internal_rs256 / + # _validate_external_rs256 below. Disabling them here also keeps this routing + # step from producing misleading errors (e.g. an expired token would bubble + # up as a generic "Authentication failed" instead of "Token expired"). + try: + unverified = jwt.decode( + token, + options={ + "verify_signature": False, + "verify_exp": False, + "verify_nbf": False, + "verify_iat": False, + "verify_aud": False, + "verify_iss": False, + }, + ) + except jwt.exceptions.DecodeError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from e + + iss = unverified.get("iss") + + if iss == JWT_ISSUER: + return _validate_internal_rs256(env, token), None + + if not iss: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token: missing iss claim", + ) + + issuer_rec = ( + env["spp.oauth.issuer"] # nosemgrep: odoo-sudo-without-context + .sudo() + .search([("issuer", "=", iss), ("active", "=", True)], limit=1) + ) + if not issuer_rec: + _logger.warning("RS256 token from unknown issuer: %s", iss) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Untrusted issuer", + ) + + return _validate_external_rs256(issuer_rec, token), issuer_rec + + +def _validate_internal_rs256(env: Environment, token: str) -> dict: + """Verify a token signed by the internal openspp-api-v2 issuer.""" + try: + public_key = get_public_key(env) + except OpenSPPOAuthJWTException as e: + _logger.warning("RS256 verification failed: %s", e) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="RS256 authentication not available", + ) from e + + try: + return jwt.decode( + token, + public_key, + algorithms=["RS256"], + audience=JWT_AUDIENCE, + issuer=JWT_ISSUER, + leeway=JWT_CLOCK_SKEW_LEEWAY_SECONDS, + ) + except jwt.ExpiredSignatureError as e: + _logger.warning("Expired RS256 JWT credential") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired", + ) from e + except jwt.InvalidTokenError as e: + _logger.warning("RS256 JWT verification failed: %s", e) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from e + + +def _validate_external_rs256(issuer_rec, token: str) -> dict: + """Verify a token signed by an externally-trusted issuer record.""" + algorithms = issuer_rec.get_allowed_algorithms() + + try: + if issuer_rec.key_source == "jwks_uri": + try: + signing_key = get_jwks_client(issuer_rec).get_signing_key_from_jwt(token) + except PyJWKClientError as e: + _logger.warning("JWKS key resolution failed for issuer %s: %s", issuer_rec.issuer, e) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from e + key = signing_key.key + else: + key = issuer_rec.public_key + + return jwt.decode( + token, + key, + algorithms=algorithms, + audience=issuer_rec.audience, + issuer=issuer_rec.issuer, + leeway=JWT_CLOCK_SKEW_LEEWAY_SECONDS, + ) + except jwt.ExpiredSignatureError as e: + _logger.warning("Expired RS256 JWT from issuer %s", issuer_rec.issuer) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired", + ) from e + except jwt.InvalidTokenError as e: + _logger.warning("RS256 JWT verification failed for issuer %s: %s", issuer_rec.issuer, e) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from e diff --git a/spp_api_v2_oauth/models/__init__.py b/spp_api_v2_oauth/models/__init__.py new file mode 100644 index 00000000..b6ad8ddb --- /dev/null +++ b/spp_api_v2_oauth/models/__init__.py @@ -0,0 +1,3 @@ +from . import api_client +from . import fastapi_endpoint +from . import oauth_issuer diff --git a/spp_api_v2_oauth/models/api_client.py b/spp_api_v2_oauth/models/api_client.py new file mode 100644 index 00000000..63030a01 --- /dev/null +++ b/spp_api_v2_oauth/models/api_client.py @@ -0,0 +1,32 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Extends `spp.api.client` with a Trusted-Issuer link. + +When set, the client can ONLY be reached by RS256 tokens issued by the linked +`spp.oauth.issuer` record. When unset, the client is "internal" and can be +reached by HS256 tokens (`spp_api_v2` /oauth/token) or by internal RS256 tokens +(/oauth/token/rs256 with `iss == openspp-api-v2`). The bridge middleware +(`auth_rs256.get_authenticated_client_rs256`) enforces this routing. + +SECURITY: Without this field, an external IdP that happened to emit a claim +value matching an internal client's `client_id` would silently authenticate +as that internal client. +""" + +from odoo import fields, models + + +class SppApiClient(models.Model): + _inherit = "spp.api.client" + + oauth_issuer_id = fields.Many2one( + "spp.oauth.issuer", + string="Trusted OAuth Issuer", + ondelete="restrict", + help=( + "External Identity Provider whose RS256 tokens may authenticate as this " + "client. When set, ONLY tokens from the linked issuer can resolve to this " + "client; the client is not reachable via internal HS256 or internal RS256 " + "auth. Leave empty for clients used with the built-in /oauth/token and " + "/oauth/token/rs256 endpoints." + ), + ) diff --git a/spp_api_v2_oauth/models/fastapi_endpoint.py b/spp_api_v2_oauth/models/fastapi_endpoint.py new file mode 100644 index 00000000..1becaa64 --- /dev/null +++ b/spp_api_v2_oauth/models/fastapi_endpoint.py @@ -0,0 +1,34 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +import logging + +from odoo import models + +from fastapi import APIRouter + +_logger = logging.getLogger(__name__) + + +class SppApiV2OAuthEndpoint(models.Model): + """Extends FastAPI endpoint to add RS256 auth and token generation for API V2.""" + + _inherit = "fastapi.endpoint" + + def _get_app_dependencies_overrides(self): + overrides = super()._get_app_dependencies_overrides() + if self.app == "api_v2": + from odoo.addons.spp_api_v2.middleware.auth import ( + get_authenticated_client, + ) + + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + overrides[get_authenticated_client] = get_authenticated_client_rs256 + return overrides + + def _get_fastapi_routers(self) -> list[APIRouter]: + routers = super()._get_fastapi_routers() + if self.app == "api_v2": + from ..routers.oauth_rs256 import oauth_rs256_router + + routers.append(oauth_rs256_router) + return routers diff --git a/spp_api_v2_oauth/models/oauth_issuer.py b/spp_api_v2_oauth/models/oauth_issuer.py new file mode 100644 index 00000000..a11c3b64 --- /dev/null +++ b/spp_api_v2_oauth/models/oauth_issuer.py @@ -0,0 +1,198 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Trusted external RS256 token issuer. + +A record represents one external Identity Provider whose RS256-signed tokens +the bridge will accept. The bridge looks the record up by the JWT `iss` claim +and uses either its configured JWKS endpoint or its static public key to +verify the signature. + +The internal `openspp-api-v2` issuer is NOT represented here — it remains +hard-wired to the spp_oauth key store. +""" + +import logging +from urllib.parse import urlparse + +from cryptography.exceptions import UnsupportedAlgorithm +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives.serialization import load_pem_public_key + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +# Whitelist of JWT signing algorithms allowed for external issuers. +# Symmetric algorithms (HS*) are excluded — they would let the bridge accept +# tokens signed with a shared secret, which is incompatible with the trust +# model here. `none` is excluded for obvious reasons. +ALLOWED_ALGORITHMS = frozenset({"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}) + +# Localhost hostnames where http:// (no TLS) is acceptable for dev IdPs. +_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + + +class SppOAuthIssuer(models.Model): + _name = "spp.oauth.issuer" + _description = "Trusted External OAuth Issuer" + _order = "name" + + name = fields.Char(required=True, help="Display name, e.g. 'Org Keycloak'.") + issuer = fields.Char( + required=True, + help="Exact value expected in the JWT `iss` claim. Tokens are routed to " + "this verifier when their `iss` matches.", + ) + audience = fields.Char( + required=True, + help="Expected JWT `aud` claim. Tokens whose audience does not match are rejected.", + ) + key_source = fields.Selection( + selection=[ + ("jwks_uri", "JWKS URI"), + ("public_key", "Static Public Key (PEM)"), + ], + required=True, + default="jwks_uri", + ) + jwks_uri = fields.Char(help="URL of the IdP's JWKS endpoint. Required when key source is JWKS.") + public_key = fields.Text(help="PEM-encoded RSA or EC public key. Required when key source is Static Public Key.") + algorithms = fields.Char( + required=True, + default="RS256", + help="Comma-separated list of JWT algorithms accepted from this issuer. Allowed values: {}".format( + ", ".join(sorted(ALLOWED_ALGORITHMS)) + ), + ) + client_claim = fields.Char( + required=True, + default="client_id", + help="Name of the JWT claim whose value is looked up against " + "spp.api.client.client_id to resolve the calling client.", + ) + jwks_cache_ttl_seconds = fields.Integer( + default=3600, + help="How long the JWKS response is cached in process memory (seconds).", + ) + http_timeout_seconds = fields.Integer( + default=5, + help="HTTP timeout when fetching the JWKS document (seconds).", + ) + active = fields.Boolean(default=True) + + _sql_constraints = [ + ( + "issuer_unique", + "UNIQUE(issuer)", + "Each `iss` claim value can only map to one issuer record.", + ), + ] + + # ----------------------------------------------------------------- constraints + @api.constrains("issuer") + def _check_issuer_unique(self): + for rec in self: + if not rec.issuer: + continue + # sudo() is required so the uniqueness constraint sees all records + # regardless of the writing user's record rules — uniqueness is a + # global invariant, not a per-user view. + count = self.sudo().search_count([("issuer", "=", rec.issuer)]) # nosemgrep: odoo-sudo-without-context + if count > 1: + raise ValidationError(_("Issuer '%s' is already registered on another record.") % rec.issuer) + + @api.constrains("key_source", "jwks_uri", "public_key") + def _check_key_source_consistency(self): + for rec in self: + if rec.key_source == "jwks_uri": + if not rec.jwks_uri: + raise ValidationError(_("JWKS URI is required when key source is 'JWKS URI'.")) + _validate_jwks_uri(rec.jwks_uri) + elif rec.key_source == "public_key": + if not rec.public_key: + raise ValidationError(_("Public Key (PEM) is required when key source is 'Static Public Key'.")) + _validate_public_key_pem(rec.public_key) + + @api.constrains("algorithms") + def _check_algorithms_whitelist(self): + for rec in self: + if not rec.algorithms or not rec.algorithms.strip(): + raise ValidationError(_("Algorithms must not be empty.")) + algs = [a.strip() for a in rec.algorithms.split(",") if a.strip()] + if not algs: + raise ValidationError(_("Algorithms must not be empty.")) + bad = [a for a in algs if a not in ALLOWED_ALGORITHMS] + if bad: + raise ValidationError( + _( + "Algorithms %(bad)s are not allowed. Permitted: %(ok)s.", + bad=", ".join(bad), + ok=", ".join(sorted(ALLOWED_ALGORITHMS)), + ) + ) + + @api.constrains("client_claim") + def _check_client_claim(self): + for rec in self: + if not rec.client_claim or not rec.client_claim.strip(): + raise ValidationError(_("Client claim must not be empty.")) + + # ----------------------------------------------------------------- write/unlink hooks + def write(self, vals): + # Invalidate cached PyJWKClients when issuer config changes. + keys_that_affect_client = { + "jwks_uri", + "jwks_cache_ttl_seconds", + "http_timeout_seconds", + "active", + "key_source", + } + if keys_that_affect_client & vals.keys(): + from ..tools.jwks_cache import invalidate + + invalidate(self.ids) + return super().write(vals) + + def unlink(self): + from ..tools.jwks_cache import invalidate + + invalidate(self.ids) + return super().unlink() + + # ----------------------------------------------------------------- helpers + def get_allowed_algorithms(self): + """Return the configured algorithms list as a Python list of strings.""" + self.ensure_one() + return [a.strip() for a in (self.algorithms or "").split(",") if a.strip()] + + +# ----------------------------------------------------------------- module-level validators + + +def _validate_jwks_uri(uri): + """Validate JWKS URI scheme. Plain http:// only accepted for loopback hosts.""" + try: + parsed = urlparse(uri) + except (ValueError, AttributeError) as exc: + raise ValidationError(_("JWKS URI is not a valid URL: %s") % uri) from exc + + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValidationError(_("JWKS URI must be an http(s) URL: %s") % uri) + + if parsed.scheme == "http": + host = (parsed.hostname or "").lower() + if host not in _LOCAL_HOSTS: + raise ValidationError( + _("JWKS URI must use https://. Plain http:// is only allowed for loopback hosts (got %s).") % uri + ) + + +def _validate_public_key_pem(pem_str): + """Validate that `pem_str` parses as a public RSA/EC key in PEM form.""" + try: + key = load_pem_public_key(pem_str.encode("utf-8")) + except (ValueError, UnsupportedAlgorithm, TypeError) as exc: + raise ValidationError(_("Public Key is not a valid PEM-encoded public key: %s") % exc) from exc + if not isinstance(key, (RSAPublicKey, EllipticCurvePublicKey)): + raise ValidationError(_("Public Key must be an RSA or EC public key.")) diff --git a/spp_api_v2_oauth/pyproject.toml b/spp_api_v2_oauth/pyproject.toml new file mode 100644 index 00000000..947f4a30 --- /dev/null +++ b/spp_api_v2_oauth/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "odoo-addon-spp_api_v2_oauth" + +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_api_v2_oauth/readme/DESCRIPTION.md b/spp_api_v2_oauth/readme/DESCRIPTION.md new file mode 100644 index 00000000..1a08e43d --- /dev/null +++ b/spp_api_v2_oauth/readme/DESCRIPTION.md @@ -0,0 +1,38 @@ +Bridge module that enables RS256 (asymmetric RSA) JWT authentication for the OpenSPP API V2. Automatically installed when both `spp_api_v2` and `spp_oauth` are present. + +### What It Does + +- Adds RS256 token verification alongside existing HS256 support — both algorithms are accepted simultaneously +- Provides a dedicated `/oauth/token/rs256` endpoint for generating RS256-signed JWT tokens +- Routes incoming tokens to the correct verification path based on the JWT header's `alg` field +- Enforces the same security controls as HS256: audience, issuer, and expiration validation + +### When To Use RS256 + +RS256 uses asymmetric RSA keys (public/private pair) instead of a shared secret: + +- **Distributed deployments**: External systems can verify tokens using only the public key, without access to the signing secret +- **Zero-trust architectures**: The private key never leaves the token issuer +- **Regulatory compliance**: Some security standards require asymmetric signing + +### How It Works + +| Token Algorithm | Verification Path | +| --------------- | ----------------- | +| RS256 | RSA public key from `spp_oauth` settings + audience/issuer/expiry validation | +| HS256 | Original `spp_api_v2` shared-secret verification (unchanged) | + +The bridge replaces the `get_authenticated_client` FastAPI dependency via `dependency_overrides`. All existing API endpoints automatically support both algorithms — no router changes needed. + +### Dependencies + +| Module | Role | +| ------ | ---- | +| `spp_api_v2` | Provides the REST API, HS256 auth, and API client model | +| `spp_oauth` | Provides RSA key storage and retrieval utilities | + +### Configuration + +1. Configure RSA keys in **Settings > General Settings > SPP OAuth Settings** +2. The bridge activates automatically — existing HS256 clients continue to work unchanged +3. Use `/oauth/token/rs256` to obtain RS256-signed tokens diff --git a/spp_api_v2_oauth/readme/HISTORY.md b/spp_api_v2_oauth/readme/HISTORY.md new file mode 100644 index 00000000..e8935667 --- /dev/null +++ b/spp_api_v2_oauth/readme/HISTORY.md @@ -0,0 +1,29 @@ +## 19.0.2.0.0 + +Initial Production/Stable release. + +- Auto-installing bridge between `spp_api_v2` and `spp_oauth` that adds RS256 + JWT authentication to API V2 alongside the existing HS256 path. Tokens are + routed by the JWT header `alg`; RS256 tokens are further dispatched by `iss` + so OpenSPP can accept tokens from external Identity Providers (e.g. Keycloak) + registered as `spp.oauth.issuer` records. +- New endpoint `POST /oauth/token/rs256` for internally-issued RS256 tokens + (mirrors `/oauth/token` for HS256: same client-credentials flow, rate + limiting, and payload shape). +- New admin model `spp.oauth.issuer` (Settings → API V2 → Trusted OAuth + Issuers) for registering external IdPs by `iss` value, with JWKS-URI or + static-PEM key sources, configurable client claim, algorithm whitelist, and + process-local JWKS caching. +- `spp.api.client.oauth_issuer_id` links an API client to a Trusted OAuth + Issuer. Internal HS256 / internal RS256 tokens only resolve to clients with + no issuer link; external-issuer tokens only resolve to clients linked to the + matching issuer record. Prevents external IdPs from authenticating as + internal clients via colliding claim values. +- 30-second clock-skew leeway on RS256 verification (`exp` / `nbf` / `iat`) to + absorb normal NTP drift between OpenSPP and external IdPs. +- Algorithm allowlist enforced at both the issuer-record level (constraints + reject HMAC algorithms and `none` at write time) and the JWT verification + level (explicit `algorithms=` argument on every `jwt.decode`). +- JWKS URIs are constrained to `https://` (loopback `http://` allowed for dev + IdPs); static PEMs are validated with `cryptography.load_pem_public_key` at + write time so private-key paste mistakes are caught immediately. diff --git a/spp_api_v2_oauth/readme/USAGE.md b/spp_api_v2_oauth/readme/USAGE.md new file mode 100644 index 00000000..fde60e22 --- /dev/null +++ b/spp_api_v2_oauth/readme/USAGE.md @@ -0,0 +1,131 @@ +### Prerequisites + +- `spp_api_v2` and `spp_oauth` modules installed (bridge auto-installs) +- RSA key pair generated and configured in SPP OAuth Settings +- An API client created in `spp_api_v2` with appropriate scopes + +### Generate a Signing Keypair + +RSA-2048 is the default recommendation — NIST-approved through 2030 and roughly +5× faster on sign/verify than RSA-4096. Choose RSA-3072 or RSA-4096 only if your +organization's compliance policy requires it. + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem +openssl rsa -in private.pem -pubout -out public.pem +``` + +For new deployments, EC keys (e.g. P-256 → `ES256`) are even faster and produce +shorter tokens. The Trusted-Issuer **Algorithms** field already accepts +`ES256/ES384/ES512`; generate an EC keypair with: + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out private.pem +openssl ec -in private.pem -pubout -out public.pem +``` + +Configure the keys in **Settings > General Settings > SPP OAuth Settings**. + +### Obtain an RS256 Token + +```bash +curl -X POST https://your-instance/api/v2/spp/oauth/token/rs256 \ + -H "Content-Type: application/json" \ + -d '{ + "grant_type": "client_credentials", + "client_id": "client_abc123", + "client_secret": "your-client-secret" + }' +``` + +Response: + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "token_type": "Bearer", + "expires_in": 86400, + "scope": "individual:read group:read" +} +``` + +### Use the Token + +```bash +curl https://your-instance/api/v2/spp/Individual/urn:test%23ID-001 \ + -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." +``` + +The API automatically detects RS256 tokens from the JWT header and verifies them with the configured RSA public key. + +### Existing HS256 Clients + +No changes needed. Tokens obtained from the original `/oauth/token` endpoint continue to work. The bridge accepts both RS256 and HS256 tokens simultaneously, routing based on the `alg` field in the JWT header. + +### Accepting Tokens from an External Identity Provider + +OpenSPP can be configured to accept RS256 tokens issued by an external IdP (e.g., a Keycloak realm) so deployments can integrate with an existing organizational identity provider. The bridge dispatches by the JWT `iss` claim: each `iss` value points to one **Trusted OAuth Issuer** record, which provides the verification key (JWKS URI or static PEM) and the claim that resolves the calling API client. + +1. **Create a Trusted OAuth Issuer record** under **API V2 > Trusted OAuth Issuers** (admin-only). Required fields: + - **Issuer** — exact value the IdP puts in `iss` (e.g. `https://keycloak.example.com/realms/openspp`). + - **Audience** — the value the IdP puts in `aud` for tokens targeted at OpenSPP. + - **Key Source** — either *JWKS URI* (preferred; supports key rotation) or *Static Public Key (PEM)*. + - **JWKS URI** — must be `https://` (plain `http://` is rejected except for `localhost` / `127.0.0.1` dev IdPs). + - **Client Claim** — the JWT claim whose value must equal the existing `spp.api.client.client_id`. Defaults to `client_id`; for Keycloak service accounts, `azp` or `sub` is typical. + +2. **Match the Client Claim value to an existing API Client, AND link the client to the issuer record.** On the `spp.api.client` record: + - Set `client_id` to the value the IdP emits in the configured `client_claim`. + - Set **Trusted OAuth Issuer** to the issuer record you created in step 1. + + Clients with **Trusted OAuth Issuer** left empty are reachable only by internal HS256/RS256 tokens (issued by OpenSPP's own `/oauth/token` and `/oauth/token/rs256` endpoints). A token from an external IdP will *not* authenticate as such a client even if the `client_id` happens to collide — preventing namespace-collision attacks against internal clients. + +3. **Request a token from the external IdP**, then call OpenSPP with it: + + ```bash + curl https://your-instance/api/v2/spp/Individual/urn:test%23ID-001 \ + -H "Authorization: Bearer " + ``` + +The bridge: +- Reads `iss` from the unverified payload. +- If `iss` matches the internal openspp-api-v2 issuer, uses the spp_oauth key (existing behavior) and looks up an API client with no **Trusted OAuth Issuer** set. +- Otherwise looks up the matching active `spp.oauth.issuer` record and verifies with its JWKS or static PEM. +- Reads the configured `client_claim` from the verified payload and resolves an `spp.api.client` whose **Trusted OAuth Issuer** equals the matched issuer record. +- Allows up to 30 seconds of clock skew on token `exp`/`nbf`/`iat` checks to absorb normal NTP drift. + +### Example External IdP: Keycloak Realm + +| Field | Value | +| ---------------- | ---------------------------------------------------------------------------- | +| Name | `Org Keycloak` | +| Issuer | `https://keycloak.example.org/realms/openspp` | +| Audience | `openspp-api` (configure an audience mapper in Keycloak to emit this value) | +| Key Source | `JWKS URI` | +| JWKS URI | `https://keycloak.example.org/realms/openspp/protocol/openid-connect/certs` | +| Algorithms | `RS256` | +| Client Claim | `azp` (Keycloak's "authorized party" — the client_id of the service account) | + +JWKS responses are cached in process memory for `JWKS Cache TTL Seconds` (default 3600). Editing or archiving the record drops the cached client; the next request rebuilds it. + +### Verify Token Algorithm + +To confirm which algorithm a token uses, decode the JWT header (without verification): + +```python +import jwt +header = jwt.get_unverified_header(token) +# header["alg"] will be "RS256" or "HS256" +``` + +### Error Responses + +| Scenario | HTTP Status | Detail | +| -------- | ----------- | ------ | +| RSA keys not configured | 400 | "RS256 token generation not available..." | +| Invalid credentials | 401 | "Invalid client credentials" | +| Expired token | 401 | "Token expired" | +| Invalid signature | 401 | "Invalid token" | +| Unsupported algorithm | 401 | "Unsupported token algorithm: {alg}" | +| Missing `iss` claim | 401 | "Invalid token: missing iss claim" | +| Unknown / inactive external issuer | 401 | "Untrusted issuer" | +| Rate limit exceeded | 429 | "Rate limit exceeded" | diff --git a/spp_api_v2_oauth/routers/__init__.py b/spp_api_v2_oauth/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/spp_api_v2_oauth/routers/oauth_rs256.py b/spp_api_v2_oauth/routers/oauth_rs256.py new file mode 100644 index 00000000..be61b9d9 --- /dev/null +++ b/spp_api_v2_oauth/routers/oauth_rs256.py @@ -0,0 +1,127 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""RS256 OAuth token generation endpoint for API V2.""" + +import logging +from datetime import UTC, datetime, timedelta +from typing import Annotated + +import jwt as pyjwt + +from odoo.api import Environment + +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.spp_api_v2.middleware.rate_limit import check_auth_rate_limit +from odoo.addons.spp_api_v2.routers.oauth import TokenRequest, TokenResponse, _parse_token_request +from odoo.addons.spp_oauth.tools import OpenSPPOAuthJWTException, get_private_key + +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from ..constants import JWT_AUDIENCE, JWT_ISSUER + +_logger = logging.getLogger(__name__) + +oauth_rs256_router = APIRouter(tags=["OAuth RS256"]) + +DEFAULT_TOKEN_LIFETIME_HOURS = 24 + + +@oauth_rs256_router.post("/oauth/token/rs256", response_model=TokenResponse) +async def get_rs256_token( + http_request: Request, + token_request: Annotated[TokenRequest, Depends(_parse_token_request)], + env: Annotated[Environment, Depends(odoo_env)], + _rate_limit: Annotated[None, Depends(check_auth_rate_limit)], +): + """OAuth 2.0 Client Credentials flow with RS256 signing. + + Authenticates API client and returns a JWT access token signed with RS256. + Requires RSA keys to be configured in spp_oauth settings. + + SECURITY: Rate limited to 5 requests/minute per IP to prevent brute force. + """ + # Validate grant type + if token_request.grant_type != "client_credentials": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported grant_type. Only 'client_credentials' is supported.", + ) + + # Verify RSA private key is configured before authenticating + try: + private_key = get_private_key(env) + except OpenSPPOAuthJWTException as e: + _logger.warning("RS256 signing unavailable: RSA keys not configured") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "RS256 token generation not available. RSA keys must be configured in Settings > SPP OAuth Settings." + ), + ) from e + + # Authenticate client (same scrypt verification as HS256 endpoint) + # nosemgrep: odoo-sudo-without-context + api_client = env["spp.api.client"].sudo().authenticate(token_request.client_id, token_request.client_secret) + + if not api_client: + _logger.warning("Failed authentication attempt for client_id: %s", token_request.client_id) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid client credentials", + ) + + # Read configurable token lifetime (same config as HS256 endpoint) + config_param = env["ir.config_parameter"].sudo() # nosemgrep: odoo-sudo-without-context + try: + token_lifetime_hours = int( + config_param.get_param("spp_api_v2.token_lifetime_hours", str(DEFAULT_TOKEN_LIFETIME_HOURS)) + ) + except (ValueError, TypeError): + _logger.warning("Invalid lifetime_hours config value, using default %s", DEFAULT_TOKEN_LIFETIME_HOURS) + token_lifetime_hours = DEFAULT_TOKEN_LIFETIME_HOURS + expires_in = token_lifetime_hours * 3600 + + # Generate RS256 JWT token + try: + token = _generate_rs256_jwt_token(private_key, api_client, token_lifetime_hours) + except (ValueError, TypeError, pyjwt.PyJWTError) as e: + _logger.exception("Error generating RS256 JWT") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate access token", + ) from e + + # Build scope string from client scopes + scope_str = " ".join(f"{s.resource}:{s.action}" for s in api_client.scope_ids) + + return TokenResponse( + access_token=token, + token_type="Bearer", + expires_in=expires_in, + scope=scope_str, + ) + + +def _generate_rs256_jwt_token(private_key: str, api_client, token_lifetime_hours: int) -> str: + """Generate JWT access token signed with RS256. + + Payload structure is identical to the HS256 token from spp_api_v2 + to ensure all downstream logic works without modification. + + SECURITY: Never include database IDs in JWT - use client_id only. + """ + now = datetime.now(tz=UTC) + payload = { + "iss": JWT_ISSUER, + "sub": api_client.client_id, + "aud": JWT_AUDIENCE, + "exp": now + timedelta(hours=token_lifetime_hours), + "iat": now, + "client_id": api_client.client_id, + "scopes": [f"{s.resource}:{s.action}" for s in api_client.scope_ids], + } + + token = pyjwt.encode(payload, private_key, algorithm="RS256") + + _logger.info("Generated RS256 JWT for client: %s", api_client.client_id) + + return token diff --git a/spp_api_v2_oauth/security/ir.model.access.csv b/spp_api_v2_oauth/security/ir.model.access.csv new file mode 100644 index 00000000..82cacdb5 --- /dev/null +++ b/spp_api_v2_oauth/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_oauth_issuer_admin,spp.oauth.issuer admin,model_spp_oauth_issuer,base.group_system,1,1,1,1 diff --git a/spp_api_v2_oauth/static/description/icon.png b/spp_api_v2_oauth/static/description/icon.png new file mode 100644 index 00000000..c7dbdaaf Binary files /dev/null and b/spp_api_v2_oauth/static/description/icon.png differ diff --git a/spp_api_v2_oauth/static/description/index.html b/spp_api_v2_oauth/static/description/index.html new file mode 100644 index 00000000..2aa2ac03 --- /dev/null +++ b/spp_api_v2_oauth/static/description/index.html @@ -0,0 +1,766 @@ + + + + + +OpenSPP API V2: OAuth RS256 Bridge + + + +
+

OpenSPP API V2: OAuth RS256 Bridge

+ + +

Production/Stable License: LGPL-3 OpenSPP/OpenSPP2

+

Bridge module that enables RS256 (asymmetric RSA) JWT authentication for +the OpenSPP API V2. Automatically installed when both spp_api_v2 and +spp_oauth are present.

+
+

What It Does

+
    +
  • Adds RS256 token verification alongside existing HS256 support — both +algorithms are accepted simultaneously
  • +
  • Provides a dedicated /oauth/token/rs256 endpoint for generating +RS256-signed JWT tokens
  • +
  • Routes incoming tokens to the correct verification path based on the +JWT header’s alg field
  • +
  • Enforces the same security controls as HS256: audience, issuer, and +expiration validation
  • +
+
+
+

When To Use RS256

+

RS256 uses asymmetric RSA keys (public/private pair) instead of a shared +secret:

+
    +
  • Distributed deployments: External systems can verify tokens using +only the public key, without access to the signing secret
  • +
  • Zero-trust architectures: The private key never leaves the token +issuer
  • +
  • Regulatory compliance: Some security standards require asymmetric +signing
  • +
+
+
+

How It Works

+ ++++ + + + + + + + + + + + + + +
Token AlgorithmVerification Path
RS256RSA public key from spp_oauth settings + +audience/issuer/expiry validation
HS256Original spp_api_v2 shared-secret verification +(unchanged)
+

The bridge replaces the get_authenticated_client FastAPI dependency +via dependency_overrides. All existing API endpoints automatically +support both algorithms — no router changes needed.

+
+
+

Dependencies

+ ++++ + + + + + + + + + + + + + +
ModuleRole
spp_api_v2Provides the REST API, HS256 auth, and API client model
spp_oauthProvides RSA key storage and retrieval utilities
+
+
+

Configuration

+
    +
  1. Configure RSA keys in Settings > General Settings > SPP OAuth +Settings
  2. +
  3. The bridge activates automatically — existing HS256 clients continue +to work unchanged
  4. +
  5. Use /oauth/token/rs256 to obtain RS256-signed tokens
  6. +
+

Table of contents

+
+ +
+
+

Usage

+
+
+
+

Prerequisites

+
    +
  • spp_api_v2 and spp_oauth modules installed (bridge +auto-installs)
  • +
  • RSA key pair generated and configured in SPP OAuth Settings
  • +
  • An API client created in spp_api_v2 with appropriate scopes
  • +
+
+
+

Generate a Signing Keypair

+

RSA-2048 is the default recommendation — NIST-approved through 2030 and +roughly 5× faster on sign/verify than RSA-4096. Choose RSA-3072 or +RSA-4096 only if your organization’s compliance policy requires it.

+
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
+openssl rsa -in private.pem -pubout -out public.pem
+
+

For new deployments, EC keys (e.g. P-256 → ES256) are even faster +and produce shorter tokens. The Trusted-Issuer Algorithms field +already accepts ES256/ES384/ES512; generate an EC keypair with:

+
+openssl ecparam -name prime256v1 -genkey -noout -out private.pem
+openssl ec -in private.pem -pubout -out public.pem
+
+

Configure the keys in Settings > General Settings > SPP OAuth +Settings.

+
+
+

Obtain an RS256 Token

+
+curl -X POST https://your-instance/api/v2/spp/oauth/token/rs256 \
+  -H "Content-Type: application/json" \
+  -d '{
+    "grant_type": "client_credentials",
+    "client_id": "client_abc123",
+    "client_secret": "your-client-secret"
+  }'
+
+

Response:

+
+{
+  "access_token": "eyJhbGciOiJSUzI1NiIs...",
+  "token_type": "Bearer",
+  "expires_in": 86400,
+  "scope": "individual:read group:read"
+}
+
+
+
+

Use the Token

+
+curl https://your-instance/api/v2/spp/Individual/urn:test%23ID-001 \
+  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
+
+

The API automatically detects RS256 tokens from the JWT header and +verifies them with the configured RSA public key.

+
+
+

Existing HS256 Clients

+

No changes needed. Tokens obtained from the original /oauth/token +endpoint continue to work. The bridge accepts both RS256 and HS256 +tokens simultaneously, routing based on the alg field in the JWT +header.

+
+
+

Accepting Tokens from an External Identity Provider

+

OpenSPP can be configured to accept RS256 tokens issued by an external +IdP (e.g., a Keycloak realm) so deployments can integrate with an +existing organizational identity provider. The bridge dispatches by the +JWT iss claim: each iss value points to one Trusted OAuth +Issuer record, which provides the verification key (JWKS URI or static +PEM) and the claim that resolves the calling API client.

+
    +
  1. Create a Trusted OAuth Issuer record under API V2 > Trusted +OAuth Issuers (admin-only). Required fields:

    +
      +
    • Issuer — exact value the IdP puts in iss (e.g. +https://keycloak.example.com/realms/openspp).
    • +
    • Audience — the value the IdP puts in aud for tokens +targeted at OpenSPP.
    • +
    • Key Source — either JWKS URI (preferred; supports key +rotation) or Static Public Key (PEM).
    • +
    • JWKS URI — must be https:// (plain http:// is rejected +except for localhost / 127.0.0.1 dev IdPs).
    • +
    • Client Claim — the JWT claim whose value must equal the +existing spp.api.client.client_id. Defaults to client_id; +for Keycloak service accounts, azp or sub is typical.
    • +
    +
  2. +
  3. Match the Client Claim value to an existing API Client, AND link +the client to the issuer record. On the spp.api.client record:

    +
      +
    • Set client_id to the value the IdP emits in the configured +client_claim.
    • +
    • Set Trusted OAuth Issuer to the issuer record you created in +step 1.
    • +
    +

    Clients with Trusted OAuth Issuer left empty are reachable only +by internal HS256/RS256 tokens (issued by OpenSPP’s own +/oauth/token and /oauth/token/rs256 endpoints). A token from +an external IdP will not authenticate as such a client even if the +client_id happens to collide — preventing namespace-collision +attacks against internal clients.

    +
  4. +
  5. Request a token from the external IdP, then call OpenSPP with it:

    +
    +curl https://your-instance/api/v2/spp/Individual/urn:test%23ID-001 \
    +  -H "Authorization: Bearer <token from external IdP>"
    +
    +
  6. +
+

The bridge:

+
    +
  • Reads iss from the unverified payload.
  • +
  • If iss matches the internal openspp-api-v2 issuer, uses the +spp_oauth key (existing behavior) and looks up an API client with no +Trusted OAuth Issuer set.
  • +
  • Otherwise looks up the matching active spp.oauth.issuer record and +verifies with its JWKS or static PEM.
  • +
  • Reads the configured client_claim from the verified payload and +resolves an spp.api.client whose Trusted OAuth Issuer equals +the matched issuer record.
  • +
  • Allows up to 30 seconds of clock skew on token exp/nbf/iat +checks to absorb normal NTP drift.
  • +
+
+
+

Example External IdP: Keycloak Realm

+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValue
NameOrg Keycloak
Issuerhttps://keycloak.example.org/realms/openspp
Audienceopenspp-api (configure an audience mapper in Keycloak to emit this value)
Key SourceJWKS URI
JWKS URIhttps://keycloak.example.org/realms/openspp/protocol/openid-connect/certs
AlgorithmsRS256
Client Claimazp (Keycloak’s “authorized party” — the client_id of the service +account)
+

JWKS responses are cached in process memory for +JWKS Cache TTL Seconds (default 3600). Editing or archiving the +record drops the cached client; the next request rebuilds it.

+
+
+

Verify Token Algorithm

+

To confirm which algorithm a token uses, decode the JWT header (without +verification):

+
+import jwt
+header = jwt.get_unverified_header(token)
+# header["alg"] will be "RS256" or "HS256"
+
+
+
+

Error Responses

+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScenarioHTTP StatusDetail
RSA keys not configured400“RS256 token generation +not available…”
Invalid credentials401“Invalid client +credentials”
Expired token401“Token expired”
Invalid signature401“Invalid token”
Unsupported algorithm401“Unsupported token +algorithm: {alg}”
Missing iss claim401“Invalid token: missing +iss claim”
Unknown / inactive +external issuer401“Untrusted issuer”
Rate limit exceeded429“Rate limit exceeded”
+
+

Changelog

+
+

19.0.2.0.0

+

Initial Production/Stable release.

+
    +
  • Auto-installing bridge between spp_api_v2 and spp_oauth that +adds RS256 JWT authentication to API V2 alongside the existing HS256 +path. Tokens are routed by the JWT header alg; RS256 tokens are +further dispatched by iss so OpenSPP can accept tokens from +external Identity Providers (e.g. Keycloak) registered as +spp.oauth.issuer records.
  • +
  • New endpoint POST /oauth/token/rs256 for internally-issued RS256 +tokens (mirrors /oauth/token for HS256: same client-credentials +flow, rate limiting, and payload shape).
  • +
  • New admin model spp.oauth.issuer (Settings → API V2 → Trusted +OAuth Issuers) for registering external IdPs by iss value, with +JWKS-URI or static-PEM key sources, configurable client claim, +algorithm whitelist, and process-local JWKS caching.
  • +
  • spp.api.client.oauth_issuer_id links an API client to a Trusted +OAuth Issuer. Internal HS256 / internal RS256 tokens only resolve to +clients with no issuer link; external-issuer tokens only resolve to +clients linked to the matching issuer record. Prevents external IdPs +from authenticating as internal clients via colliding claim values.
  • +
  • 30-second clock-skew leeway on RS256 verification (exp / nbf / +iat) to absorb normal NTP drift between OpenSPP and external IdPs.
  • +
  • Algorithm allowlist enforced at both the issuer-record level +(constraints reject HMAC algorithms and none at write time) and +the JWT verification level (explicit algorithms= argument on every +jwt.decode).
  • +
  • JWKS URIs are constrained to https:// (loopback http:// +allowed for dev IdPs); static PEMs are validated with +cryptography.load_pem_public_key at write time so private-key +paste mistakes are caught immediately.
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • OpenSPP.org
  • +
+
+
+

Maintainers

+

Current maintainers:

+

jeremi gonzalesedwin1123

+

This module is part of the OpenSPP/OpenSPP2 project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/spp_api_v2_oauth/tests/__init__.py b/spp_api_v2_oauth/tests/__init__.py new file mode 100644 index 00000000..80a07776 --- /dev/null +++ b/spp_api_v2_oauth/tests/__init__.py @@ -0,0 +1,5 @@ +from . import test_auth_hs256 +from . import test_auth_rs256 +from . import test_auth_rs256_external +from . import test_oauth_issuer +from . import test_token_generation diff --git a/spp_api_v2_oauth/tests/common.py b/spp_api_v2_oauth/tests/common.py new file mode 100644 index 00000000..838da8f5 --- /dev/null +++ b/spp_api_v2_oauth/tests/common.py @@ -0,0 +1,125 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Common test utilities for spp_api_v2_oauth tests.""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from odoo.tests.common import TransactionCase + +from ..constants import JWT_AUDIENCE, JWT_ISSUER + +# HS256 test secret (same as spp_api_v2 tests) +HS256_TEST_SECRET = "test-secret-key-for-testing-only-do-not-use-in-production" + + +class OAuthBridgeTestCase(TransactionCase): + """Base class for OAuth bridge tests. + + Sets up RSA key pair and HS256 secret for testing both algorithms. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Generate RSA key pair for testing (2048-bit for speed) + cls.rsa_private_key_obj = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + cls.rsa_private_key_pem = cls.rsa_private_key_obj.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + cls.rsa_public_key_pem = ( + cls.rsa_private_key_obj.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + + # Store RSA keys in spp_oauth config parameters + cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_private_key", cls.rsa_private_key_pem) + cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_public_key", cls.rsa_public_key_pem) + + # Store HS256 secret for spp_api_v2 + cls.env["ir.config_parameter"].sudo().set_param("spp_api_v2.jwt_secret", HS256_TEST_SECRET) + + # Shared "internal" API client: no oauth_issuer_id, reachable via HS256 + # and internal RS256 tokens. External-issuer test classes create their + # own issuer-linked clients via _make_api_client(). + cls.api_client = cls._make_api_client("OAuth Bridge Test Client") + + # Create test scopes + cls.env["spp.api.client.scope"].create( + { + "client_id": cls.api_client.id, + "resource": "individual", + "action": "read", + } + ) + + @classmethod + def _make_api_client(cls, name, oauth_issuer_id=None): + """Create an spp.api.client, optionally linked to a Trusted OAuth Issuer. + + :param name: Display name and partner name. + :param oauth_issuer_id: integer id of an spp.oauth.issuer record, or None + for an "internal" client (HS256 + internal RS256 only). + """ + partner = cls.env["res.partner"].create({"name": f"{name} Org"}) + org_type = cls.env["spp.consent.org.type"].search([("code", "=", "government")], limit=1) + if not org_type: + org_type = cls.env.ref("spp_consent.org_type_government", raise_if_not_found=False) + + vals = { + "name": name, + "partner_id": partner.id, + "is_require_consent": False, + "legal_basis": "consent", + } + if org_type: + vals["organization_type_id"] = org_type.id + if oauth_issuer_id: + vals["oauth_issuer_id"] = oauth_issuer_id + + return cls.env["spp.api.client"].create(vals) + + def _build_jwt_payload(self, overrides=None): + """Build a standard JWT payload for testing.""" + now = datetime.now(tz=UTC) + payload = { + "iss": JWT_ISSUER, + "sub": self.api_client.client_id, + "aud": JWT_AUDIENCE, + "exp": now + timedelta(hours=1), + "iat": now, + "client_id": self.api_client.client_id, + "scopes": ["individual:read"], + } + if overrides: + payload.update(overrides) + return payload + + def generate_rs256_token(self, payload_overrides=None, private_key=None): + """Generate an RS256-signed JWT token for testing.""" + payload = self._build_jwt_payload(payload_overrides) + key = private_key or self.rsa_private_key_pem + return jwt.encode(payload, key, algorithm="RS256") + + def generate_hs256_token(self, payload_overrides=None): + """Generate an HS256-signed JWT token for testing.""" + payload = self._build_jwt_payload(payload_overrides) + return jwt.encode(payload, HS256_TEST_SECRET, algorithm="HS256") + + @staticmethod + def make_credentials(token): + """Create a mock HTTPAuthorizationCredentials-like object.""" + return SimpleNamespace(credentials=token) diff --git a/spp_api_v2_oauth/tests/test_auth_hs256.py b/spp_api_v2_oauth/tests/test_auth_hs256.py new file mode 100644 index 00000000..6464a09c --- /dev/null +++ b/spp_api_v2_oauth/tests/test_auth_hs256.py @@ -0,0 +1,162 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests ensuring HS256 authentication still works through the bridge.""" + +from datetime import UTC, datetime, timedelta + +import jwt + +from odoo.tests import tagged + +from fastapi import HTTPException + +from .common import JWT_AUDIENCE, JWT_ISSUER, OAuthBridgeTestCase + + +@tagged("post_install", "-at_install") +class TestHS256Regression(OAuthBridgeTestCase): + """Verify that the bridge module does not break HS256 authentication.""" + + def test_hs256_token_still_works(self): + """HS256 token is still accepted after bridge module is installed.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self.generate_hs256_token() + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.api_client.client_id) + + def test_hs256_expired_token_rejected(self): + """Expired HS256 token is still rejected through the bridge.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + expired_time = datetime.now(tz=UTC) - timedelta(hours=1) + token = self.generate_hs256_token( + payload_overrides={ + "exp": expired_time, + "iat": expired_time - timedelta(hours=1), + } + ) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_hs256_invalid_secret_rejected(self): + """HS256 token signed with wrong secret is rejected through the bridge.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # Sign with a different secret + payload = { + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "exp": datetime.now(tz=UTC) + timedelta(hours=1), + "iat": datetime.now(tz=UTC), + "client_id": self.api_client.client_id, + } + wrong_secret = "wrong-secret-that-is-at-least-32-characters-long!!" + token = jwt.encode(payload, wrong_secret, algorithm="HS256") + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_dependency_override_applied(self): + """Verify the bridge override is set up in the endpoint model.""" + from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client + + endpoint = self.env["fastapi.endpoint"].search([("app", "=", "api_v2")], limit=1) + if not endpoint: + self.skipTest("No api_v2 endpoint configured in test database") + + overrides = endpoint._get_app_dependencies_overrides() + self.assertIn( + get_authenticated_client, + overrides, + "get_authenticated_client should be in dependency overrides", + ) + + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + self.assertEqual( + overrides[get_authenticated_client], + get_authenticated_client_rs256, + "Override should point to the RS256 bridge function", + ) + + def test_router_registration(self): + """Verify the RS256 router is registered for api_v2 endpoints.""" + endpoint = self.env["fastapi.endpoint"].search([("app", "=", "api_v2")], limit=1) + if not endpoint: + self.skipTest("No api_v2 endpoint configured in test database") + + routers = endpoint._get_fastapi_routers() + # Check that at least one router contains a route to /oauth/token/rs256 + rs256_routes = [ + route + for router in routers + for route in router.routes + if hasattr(route, "path") and route.path == "/oauth/token/rs256" + ] + self.assertTrue( + rs256_routes, + "RS256 token endpoint should be registered in api_v2 routers", + ) + + def test_no_override_for_non_api_v2(self): + """Bridge overrides should NOT apply to non-api_v2 endpoints.""" + from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client + + endpoint = self.env["fastapi.endpoint"].search([("app", "!=", "api_v2")], limit=1) + if not endpoint: + self.skipTest("No non-api_v2 endpoint configured in test database") + + overrides = endpoint._get_app_dependencies_overrides() + self.assertNotIn( + get_authenticated_client, + overrides, + "get_authenticated_client should NOT be overridden for non-api_v2 endpoints", + ) + + def test_hs256_cannot_resolve_issuer_linked_client(self): + """An HS256 (internal) token cannot authenticate as a client linked + to an external Trusted OAuth Issuer.""" + # Build an external issuer record and link a client to it. + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_pem = ( + key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + issuer_rec = self.env["spp.oauth.issuer"].create( + { + "name": "HS256 Regression Issuer", + "issuer": "https://idp.example.com/realms/hs256-reg", + "audience": "openspp-ext", + "key_source": "public_key", + "public_key": public_pem, + } + ) + linked_client = self._make_api_client( + "HS256 Regression Linked Client", + oauth_issuer_id=issuer_rec.id, + ) + + # An HS256 token (internal) that claims the linked client's id must be + # rejected — the linked client is reachable only via its issuer. + token = self.generate_hs256_token(payload_overrides={"client_id": linked_client.client_id}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) diff --git a/spp_api_v2_oauth/tests/test_auth_rs256.py b/spp_api_v2_oauth/tests/test_auth_rs256.py new file mode 100644 index 00000000..73a456ce --- /dev/null +++ b/spp_api_v2_oauth/tests/test_auth_rs256.py @@ -0,0 +1,231 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for RS256 JWT authentication via the bridge module.""" + +from datetime import UTC, datetime, timedelta + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from odoo.tests import tagged + +from fastapi import HTTPException + +from .common import JWT_AUDIENCE, JWT_ISSUER, OAuthBridgeTestCase + + +@tagged("post_install", "-at_install") +class TestRS256Authentication(OAuthBridgeTestCase): + """Test RS256 token verification through the bridge auth function.""" + + def test_rs256_valid_token(self): + """RS256 token with valid signature and correct claims is accepted.""" + from ..middleware.auth_rs256 import _validate_rs256_token_with_issuer + + token = self.generate_rs256_token() + payload, issuer_rec = _validate_rs256_token_with_issuer(self.env, token) + + self.assertEqual(payload["client_id"], self.api_client.client_id) + self.assertEqual(payload["iss"], JWT_ISSUER) + self.assertEqual(payload["aud"], JWT_AUDIENCE) + self.assertFalse(issuer_rec, "internal path should return no issuer record") + + def test_rs256_wrong_audience(self): + """RS256 token with wrong audience is rejected.""" + from ..middleware.auth_rs256 import _validate_rs256_token_with_issuer + + token = self.generate_rs256_token(payload_overrides={"aud": "wrong-audience"}) + + with self.assertRaises(HTTPException) as ctx: + _validate_rs256_token_with_issuer(self.env, token) + self.assertEqual(ctx.exception.status_code, 401) + + def test_rs256_wrong_issuer(self): + """RS256 token with wrong issuer is rejected.""" + from ..middleware.auth_rs256 import _validate_rs256_token_with_issuer + + token = self.generate_rs256_token(payload_overrides={"iss": "wrong-issuer"}) + + with self.assertRaises(HTTPException) as ctx: + _validate_rs256_token_with_issuer(self.env, token) + self.assertEqual(ctx.exception.status_code, 401) + + def test_rs256_expired_token(self): + """RS256 token that has expired is rejected.""" + from ..middleware.auth_rs256 import _validate_rs256_token_with_issuer + + expired_time = datetime.now(tz=UTC) - timedelta(hours=1) + token = self.generate_rs256_token( + payload_overrides={ + "exp": expired_time, + "iat": expired_time - timedelta(hours=1), + } + ) + + with self.assertRaises(HTTPException) as ctx: + _validate_rs256_token_with_issuer(self.env, token) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("expired", ctx.exception.detail.lower()) + + def test_rs256_wrong_key(self): + """RS256 token signed with a different private key is rejected.""" + from ..middleware.auth_rs256 import _validate_rs256_token_with_issuer + + # Generate a different RSA key pair + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + other_pem = other_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + token = self.generate_rs256_token(private_key=other_pem) + + with self.assertRaises(HTTPException) as ctx: + _validate_rs256_token_with_issuer(self.env, token) + self.assertEqual(ctx.exception.status_code, 401) + + def test_rs256_keys_not_configured(self): + """RS256 token is rejected (not 500) when RSA keys are not configured.""" + from ..middleware.auth_rs256 import _validate_rs256_token_with_issuer + + # Clear RSA public key + self.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_public_key", False) + + token = self.generate_rs256_token() + + with self.assertRaises(HTTPException) as ctx: + _validate_rs256_token_with_issuer(self.env, token) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("not available", ctx.exception.detail.lower()) + + def test_rs256_malformed_token(self): + """Malformed token string is rejected.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + creds = self.make_credentials("not.a.valid.jwt") + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_rs256_missing_client_id(self): + """RS256 token without client_id claim is rejected.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # Generate token without client_id + payload = { + "iss": JWT_ISSUER, + "aud": JWT_AUDIENCE, + "exp": datetime.now(tz=UTC) + timedelta(hours=1), + "iat": datetime.now(tz=UTC), + } + token = jwt.encode(payload, self.rsa_private_key_pem, algorithm="RS256") + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("client_id", ctx.exception.detail.lower()) + + def test_rs256_inactive_client(self): + """RS256 token for an inactive client is rejected.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # Deactivate the client (restore on cleanup to avoid breaking other tests) + self.api_client.active = False + self.addCleanup(setattr, self.api_client, "active", True) + + token = self.generate_rs256_token() + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_missing_credentials(self): + """Missing Authorization header returns 401.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(None, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("Missing", ctx.exception.detail) + + def test_header_routing_rs256(self): + """Token with alg=RS256 header is routed to RS256 verification.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self.generate_rs256_token() + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.api_client.client_id) + + def test_unsupported_algorithm_rejected(self): + """Token with unsupported algorithm (not RS256/HS256) is rejected.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # Create a token with HS384 algorithm (unsupported by our bridge) + payload = self._build_jwt_payload() + secret = "a-secret-key-long-enough-for-hs384-testing-only!!" + token = jwt.encode(payload, secret, algorithm="HS384") + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("Unsupported token algorithm", ctx.exception.detail) + + def test_rs256_client_not_found(self): + """RS256 token with valid signature but non-existent client_id is rejected.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self.generate_rs256_token( + payload_overrides={"client_id": "non-existent-client-id", "sub": "non-existent-client-id"} + ) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("not found", ctx.exception.detail.lower()) + + def test_internal_rs256_cannot_resolve_issuer_linked_client(self): + """An internal RS256 token cannot authenticate as a client linked + to an external Trusted OAuth Issuer.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_pem = ( + key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + issuer_rec = self.env["spp.oauth.issuer"].create( + { + "name": "RS256 Regression Issuer", + "issuer": "https://idp.example.com/realms/rs256-reg", + "audience": "openspp-ext", + "key_source": "public_key", + "public_key": public_pem, + } + ) + linked_client = self._make_api_client( + "RS256 Regression Linked Client", + oauth_issuer_id=issuer_rec.id, + ) + + # Internal-issuer RS256 token claiming the linked client's id must be rejected. + token = self.generate_rs256_token( + payload_overrides={"client_id": linked_client.client_id, "sub": linked_client.client_id} + ) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("not found", ctx.exception.detail.lower()) diff --git a/spp_api_v2_oauth/tests/test_auth_rs256_external.py b/spp_api_v2_oauth/tests/test_auth_rs256_external.py new file mode 100644 index 00000000..12c9b35c --- /dev/null +++ b/spp_api_v2_oauth/tests/test_auth_rs256_external.py @@ -0,0 +1,439 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for RS256 token verification against external trusted issuers. + +These exercise the iss-based dispatcher in auth_rs256.py: tokens whose `iss` +matches a registered `spp.oauth.issuer` record are verified using that +record's key source (static PEM or JWKS), and the configured `client_claim` +is used to resolve the calling `spp.api.client`. +""" + +from datetime import UTC, datetime, timedelta +from unittest import mock + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from odoo.tests import tagged + +from fastapi import HTTPException + +from .common import OAuthBridgeTestCase + +EXT_ISSUER_URL = "https://idp.example.com/realms/ext" +EXT_AUDIENCE = "openspp-ext" + + +def _generate_rsa_keypair(): + """Return (private_pem, public_pem, private_key_obj).""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + public_pem = ( + key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + return private_pem, public_pem, key + + +@tagged("post_install", "-at_install") +class TestExternalRS256StaticPEM(OAuthBridgeTestCase): + """Bridge accepts tokens signed by an external issuer (static-PEM key source).""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ext_private_pem, cls.ext_public_pem, cls.ext_private_key_obj = _generate_rsa_keypair() + cls.issuer_rec = cls.env["spp.oauth.issuer"].create( + { + "name": "Test External IdP (PEM)", + "issuer": EXT_ISSUER_URL, + "audience": EXT_AUDIENCE, + "key_source": "public_key", + "public_key": cls.ext_public_pem, + } + ) + # External-issuer tokens only resolve to clients linked to that issuer. + cls.ext_api_client = cls._make_api_client( + "OAuth Bridge External-PEM Client", + oauth_issuer_id=cls.issuer_rec.id, + ) + + def _make_external_token(self, payload_overrides=None, private_pem=None): + now = datetime.now(tz=UTC) + payload = { + "iss": EXT_ISSUER_URL, + "aud": EXT_AUDIENCE, + "exp": now + timedelta(hours=1), + "iat": now, + "sub": "external-subject-uuid", + "client_id": self.ext_api_client.client_id, + } + if payload_overrides: + payload.update(payload_overrides) + key = private_pem or self.ext_private_pem + return jwt.encode(payload, key, algorithm="RS256") + + # -------------------------------------------------------------- happy path + def test_external_pem_token_accepted(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self._make_external_token() + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.ext_api_client.client_id) + + def test_external_pem_token_tampered_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self._make_external_token() + # Flip a character in the signature segment + parts = token.split(".") + sig = parts[2] + parts[2] = ("A" if sig[0] != "A" else "B") + sig[1:] + tampered = ".".join(parts) + creds = self.make_credentials(tampered) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_external_pem_token_wrong_key_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + other_private_pem, _, _ = _generate_rsa_keypair() + token = self._make_external_token(private_pem=other_private_pem) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + # -------------------------------------------------------------- dispatch + def test_unknown_issuer_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self._make_external_token(payload_overrides={"iss": "https://no-such-issuer.example.com"}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("issuer", ctx.exception.detail.lower()) + + def test_missing_iss_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + now = datetime.now(tz=UTC) + payload = { + "aud": EXT_AUDIENCE, + "exp": now + timedelta(hours=1), + "iat": now, + "client_id": self.ext_api_client.client_id, + } + token = jwt.encode(payload, self.ext_private_pem, algorithm="RS256") + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_inactive_issuer_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + self.issuer_rec.active = False + self.addCleanup(setattr, self.issuer_rec, "active", True) + + token = self._make_external_token() + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + # -------------------------------------------------------------- claim checks + def test_external_token_wrong_audience_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self._make_external_token(payload_overrides={"aud": "some-other-aud"}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_external_token_expired_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + past = datetime.now(tz=UTC) - timedelta(hours=1) + token = self._make_external_token(payload_overrides={"exp": past, "iat": past - timedelta(hours=1)}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + # Detail must come from the verifying jwt.decode(), not the iss-routing decode. + self.assertIn("expired", ctx.exception.detail.lower()) + + # -------------------------------------------------------------- client_claim mapping + def test_client_claim_default_uses_client_id(self): + """With default client_claim='client_id', the bridge reads `client_id`.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self._make_external_token() + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.ext_api_client.client_id) + + def test_client_claim_custom_used_for_lookup(self): + """If client_claim is set to 'azp', the bridge reads `azp` for lookup.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + self.issuer_rec.client_claim = "azp" + self.addCleanup(setattr, self.issuer_rec, "client_claim", "client_id") + + # Put the linked-client value in `azp` and something else in `client_id` + token = self._make_external_token( + payload_overrides={ + "azp": self.ext_api_client.client_id, + "client_id": "not-the-right-value", + } + ) + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.ext_api_client.client_id) + + def test_client_claim_missing_value_rejected(self): + """If the configured claim is absent from the token payload, reject.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + self.issuer_rec.client_claim = "azp" + self.addCleanup(setattr, self.issuer_rec, "client_claim", "client_id") + + token = self._make_external_token() # no `azp` claim + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + # -------------------------------------------------------------- isolation + def test_internal_path_still_uses_spp_oauth_key(self): + """Adding an external issuer must not break the internal RS256 path.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self.generate_rs256_token() # uses internal JWT_ISSUER + internal api_client + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.api_client.client_id) + + # -------------------------------------------------------------- namespace isolation + def test_external_token_cannot_resolve_internal_client(self): + """An external-issuer token whose client_claim matches an internal + client's client_id (oauth_issuer_id IS NULL) must be rejected.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # Token from the external issuer, but claiming the *internal* client's id. + token = self._make_external_token(payload_overrides={"client_id": self.api_client.client_id}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("client not found", ctx.exception.detail.lower()) + + def test_external_token_cannot_resolve_other_issuers_client(self): + """A token from issuer A cannot resolve to a client linked to issuer B.""" + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # Set up a second external issuer record with a separate keypair, and a + # client linked to that second issuer. + other_private_pem, other_public_pem, _ = _generate_rsa_keypair() + other_issuer = self.env["spp.oauth.issuer"].create( + { + "name": "Second External IdP", + "issuer": "https://idp.example.com/realms/other", + "audience": EXT_AUDIENCE, + "key_source": "public_key", + "public_key": other_public_pem, + } + ) + other_client = self._make_api_client( + "OAuth Bridge Other-Issuer Client", + oauth_issuer_id=other_issuer.id, + ) + + # Token signed by issuer A (cls.issuer_rec), but client_id claims the + # client that belongs to issuer B. + token = self._make_external_token(payload_overrides={"client_id": other_client.client_id}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + # -------------------------------------------------------------- clock-skew leeway + def test_token_within_leeway_window_accepted(self): + """A token expired by less than JWT_CLOCK_SKEW_LEEWAY_SECONDS is accepted.""" + from ..constants import JWT_CLOCK_SKEW_LEEWAY_SECONDS + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # exp 10s in the past — well inside the 30s leeway. + slightly_expired = datetime.now(tz=UTC) - timedelta(seconds=JWT_CLOCK_SKEW_LEEWAY_SECONDS // 3) + token = self._make_external_token(payload_overrides={"exp": slightly_expired}) + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.ext_api_client.client_id) + + def test_token_outside_leeway_window_rejected(self): + """A token expired by more than JWT_CLOCK_SKEW_LEEWAY_SECONDS is rejected.""" + from ..constants import JWT_CLOCK_SKEW_LEEWAY_SECONDS + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + # exp 2× leeway in the past — outside tolerance. + well_expired = datetime.now(tz=UTC) - timedelta(seconds=JWT_CLOCK_SKEW_LEEWAY_SECONDS * 2) + token = self._make_external_token(payload_overrides={"exp": well_expired}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("expired", ctx.exception.detail.lower()) + + +@tagged("post_install", "-at_install") +class TestExternalRS256JWKS(OAuthBridgeTestCase): + """Bridge accepts tokens signed by an external issuer (JWKS key source). + + PyJWKClient.get_signing_key_from_jwt is patched so tests never make real + HTTP calls. + """ + + JWKS_URI = "https://idp.example.com/realms/ext/protocol/openid-connect/certs" + KID = "test-kid-1" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ext_private_pem, cls.ext_public_pem, cls.ext_private_key_obj = _generate_rsa_keypair() + cls.issuer_rec = cls.env["spp.oauth.issuer"].create( + { + "name": "Test External IdP (JWKS)", + "issuer": EXT_ISSUER_URL + "/jwks", + "audience": EXT_AUDIENCE, + "key_source": "jwks_uri", + "jwks_uri": cls.JWKS_URI, + } + ) + # External-issuer tokens only resolve to clients linked to that issuer. + cls.ext_api_client = cls._make_api_client( + "OAuth Bridge External-JWKS Client", + oauth_issuer_id=cls.issuer_rec.id, + ) + + def setUp(self): + super().setUp() + from ..tools import jwks_cache + + jwks_cache.clear() + + signing_key = mock.MagicMock() + signing_key.key = self.ext_public_key_obj_for_pyjwt() + + patcher = mock.patch( + "jwt.PyJWKClient.get_signing_key_from_jwt", + return_value=signing_key, + ) + self.mock_get_signing_key = patcher.start() + self.addCleanup(patcher.stop) + + def ext_public_key_obj_for_pyjwt(self): + """PyJWT accepts a cryptography public-key object as the `key` argument.""" + return self.ext_private_key_obj.public_key() + + def _make_external_token(self, payload_overrides=None, private_pem=None, headers=None): + now = datetime.now(tz=UTC) + payload = { + "iss": self.issuer_rec.issuer, + "aud": EXT_AUDIENCE, + "exp": now + timedelta(hours=1), + "iat": now, + "sub": "external-subject-uuid", + "client_id": self.ext_api_client.client_id, + } + if payload_overrides: + payload.update(payload_overrides) + key = private_pem or self.ext_private_pem + token_headers = {"kid": self.KID} + if headers: + token_headers.update(headers) + return jwt.encode(payload, key, algorithm="RS256", headers=token_headers) + + def test_jwks_token_accepted(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self._make_external_token() + creds = self.make_credentials(token) + + client = get_authenticated_client_rs256(creds, self.env) + self.assertEqual(client.client_id, self.ext_api_client.client_id) + self.mock_get_signing_key.assert_called_once() + + def test_jwks_token_expired_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + past = datetime.now(tz=UTC) - timedelta(hours=1) + token = self._make_external_token(payload_overrides={"exp": past, "iat": past - timedelta(hours=1)}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + self.assertIn("expired", ctx.exception.detail.lower()) + + def test_jwks_token_wrong_audience_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + token = self._make_external_token(payload_overrides={"aud": "wrong-aud"}) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_jwks_token_signed_with_other_key_rejected(self): + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + other_private_pem, _, _ = _generate_rsa_keypair() + token = self._make_external_token(private_pem=other_private_pem) + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) + + def test_jwks_fetch_failure_rejected(self): + from jwt.exceptions import PyJWKClientError + + from ..middleware.auth_rs256 import get_authenticated_client_rs256 + + self.mock_get_signing_key.side_effect = PyJWKClientError("network down") + + token = self._make_external_token() + creds = self.make_credentials(token) + + with self.assertRaises(HTTPException) as ctx: + get_authenticated_client_rs256(creds, self.env) + self.assertEqual(ctx.exception.status_code, 401) diff --git a/spp_api_v2_oauth/tests/test_oauth_issuer.py b/spp_api_v2_oauth/tests/test_oauth_issuer.py new file mode 100644 index 00000000..8fc2d6de --- /dev/null +++ b/spp_api_v2_oauth/tests/test_oauth_issuer.py @@ -0,0 +1,200 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for the spp.oauth.issuer model. + +The model represents an external RS256 token issuer (e.g., a Keycloak realm) +that the bridge will accept tokens from. Constraints exist so that bad +configuration is rejected at write time rather than at token-verification time. +""" + +import psycopg2 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from odoo.exceptions import ValidationError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +def _fresh_public_key_pem(): + """Generate a throwaway RSA public key in PEM form for use as a fixture.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return ( + key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + + +@tagged("post_install", "-at_install") +class TestOAuthIssuerModel(TransactionCase): + """Constraint tests for spp.oauth.issuer.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.IssuerModel = cls.env["spp.oauth.issuer"] + cls.sample_pem = _fresh_public_key_pem() + + def _make_jwks_vals(self, **overrides): + vals = { + "name": "Test Keycloak", + "issuer": "https://keycloak.example.com/realms/test", + "audience": "openspp", + "key_source": "jwks_uri", + "jwks_uri": "https://keycloak.example.com/realms/test/protocol/openid-connect/certs", + } + vals.update(overrides) + return vals + + def _make_pem_vals(self, **overrides): + vals = { + "name": "Test Static-PEM Issuer", + "issuer": "https://idp.example.com", + "audience": "openspp", + "key_source": "public_key", + "public_key": self.sample_pem, + } + vals.update(overrides) + return vals + + # ------------------------------------------------------------------ valid + def test_create_jwks_issuer(self): + """A well-formed JWKS issuer record is accepted.""" + rec = self.IssuerModel.create(self._make_jwks_vals()) + self.assertTrue(rec.id) + self.assertEqual(rec.client_claim, "client_id") + self.assertEqual(rec.algorithms, "RS256") + self.assertTrue(rec.active) + + def test_create_pem_issuer(self): + """A well-formed static-PEM issuer record is accepted.""" + rec = self.IssuerModel.create(self._make_pem_vals()) + self.assertTrue(rec.id) + self.assertEqual(rec.key_source, "public_key") + + # ------------------------------------------------------------------ required fields + def test_name_required(self): + with self.assertRaises(psycopg2.IntegrityError), mute_logger("odoo.sql_db"): + self.IssuerModel.create(self._make_jwks_vals(name=False)) + + def test_issuer_required(self): + with self.assertRaises(psycopg2.IntegrityError), mute_logger("odoo.sql_db"): + self.IssuerModel.create(self._make_jwks_vals(issuer=False)) + + def test_audience_required(self): + with self.assertRaises(psycopg2.IntegrityError), mute_logger("odoo.sql_db"): + self.IssuerModel.create(self._make_jwks_vals(audience=False)) + + def test_unique_issuer(self): + """The issuer claim value must be unique across records.""" + self.IssuerModel.create(self._make_jwks_vals(issuer="https://idp.example.com/dup")) + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_pem_vals(name="Another", issuer="https://idp.example.com/dup")) + + # ------------------------------------------------------------------ key_source consistency + def test_jwks_uri_required_when_jwks_source(self): + """key_source=jwks_uri without a jwks_uri value is rejected.""" + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_jwks_vals(jwks_uri=False)) + + def test_public_key_required_when_pem_source(self): + """key_source=public_key without a public_key value is rejected.""" + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_pem_vals(public_key=False)) + + # ------------------------------------------------------------------ URL scheme + def test_jwks_uri_https_accepted(self): + rec = self.IssuerModel.create( + self._make_jwks_vals(jwks_uri="https://example.com/jwks.json", issuer="iss-https") + ) + self.assertTrue(rec.id) + + def test_jwks_uri_plain_http_rejected(self): + """Plain http:// (non-loopback) is rejected to prevent MitM key swap.""" + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_jwks_vals(jwks_uri="http://example.com/jwks.json", issuer="iss-http")) + + def test_jwks_uri_localhost_http_accepted(self): + """http://localhost is allowed for local-dev IdPs (Keycloak on a dev host).""" + rec = self.IssuerModel.create(self._make_jwks_vals(jwks_uri="http://localhost:8080/certs", issuer="iss-local")) + self.assertTrue(rec.id) + + def test_jwks_uri_loopback_http_accepted(self): + rec = self.IssuerModel.create( + self._make_jwks_vals(jwks_uri="http://127.0.0.1:8080/certs", issuer="iss-loopback") + ) + self.assertTrue(rec.id) + + def test_jwks_uri_malformed_rejected(self): + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_jwks_vals(jwks_uri="not-a-url", issuer="iss-malformed")) + + # ------------------------------------------------------------------ PEM validation + def test_invalid_pem_rejected(self): + with self.assertRaises(ValidationError): + self.IssuerModel.create( + self._make_pem_vals(public_key="-----BEGIN PUBLIC KEY-----\nnot a real key\n-----END PUBLIC KEY-----") + ) + + def test_private_key_pem_rejected(self): + """A PRIVATE-key PEM in the public_key field must be rejected.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + priv_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_pem_vals(public_key=priv_pem, issuer="iss-priv")) + + # ------------------------------------------------------------------ algorithms + def test_algorithms_default_is_rs256(self): + rec = self.IssuerModel.create(self._make_jwks_vals(issuer="iss-alg-default")) + self.assertEqual(rec.algorithms, "RS256") + + def test_algorithms_whitelist_accepted(self): + rec = self.IssuerModel.create(self._make_jwks_vals(algorithms="RS256,RS384", issuer="iss-alg-ok")) + self.assertEqual(rec.algorithms, "RS256,RS384") + + def test_algorithms_unknown_rejected(self): + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_jwks_vals(algorithms="HS256", issuer="iss-alg-bad-1")) + + def test_algorithms_none_alg_rejected(self): + """`none` must never be allowed — JWT-none-alg is a classic attack.""" + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_jwks_vals(algorithms="none", issuer="iss-alg-bad-2")) + + def test_algorithms_empty_rejected(self): + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_jwks_vals(algorithms="", issuer="iss-alg-empty")) + + # ------------------------------------------------------------------ client_claim + def test_client_claim_default(self): + rec = self.IssuerModel.create(self._make_jwks_vals(issuer="iss-claim-default")) + self.assertEqual(rec.client_claim, "client_id") + + def test_client_claim_custom_accepted(self): + rec = self.IssuerModel.create(self._make_jwks_vals(client_claim="azp", issuer="iss-claim-azp")) + self.assertEqual(rec.client_claim, "azp") + + def test_client_claim_empty_rejected(self): + with self.assertRaises(ValidationError): + self.IssuerModel.create(self._make_jwks_vals(client_claim="", issuer="iss-claim-empty")) + + # ------------------------------------------------------------------ defaults / misc + def test_active_defaults_true(self): + rec = self.IssuerModel.create(self._make_jwks_vals(issuer="iss-active")) + self.assertTrue(rec.active) + + def test_jwks_cache_ttl_default(self): + rec = self.IssuerModel.create(self._make_jwks_vals(issuer="iss-ttl")) + self.assertEqual(rec.jwks_cache_ttl_seconds, 3600) + + def test_http_timeout_default(self): + rec = self.IssuerModel.create(self._make_jwks_vals(issuer="iss-timeout")) + self.assertEqual(rec.http_timeout_seconds, 5) diff --git a/spp_api_v2_oauth/tests/test_token_generation.py b/spp_api_v2_oauth/tests/test_token_generation.py new file mode 100644 index 00000000..b9063104 --- /dev/null +++ b/spp_api_v2_oauth/tests/test_token_generation.py @@ -0,0 +1,229 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for RS256 token generation endpoint.""" + +import asyncio + +import jwt + +from odoo.tests import tagged + +from .common import JWT_AUDIENCE, JWT_ISSUER, OAuthBridgeTestCase + + +@tagged("post_install", "-at_install") +class TestRS256TokenGeneration(OAuthBridgeTestCase): + """Test the RS256 token generation function.""" + + def test_generate_rs256_token(self): + """Generated RS256 token has correct payload structure.""" + from ..routers.oauth_rs256 import _generate_rs256_jwt_token + + token = _generate_rs256_jwt_token(self.rsa_private_key_pem, self.api_client, 24) + + # Decode without verification to check payload structure + payload = jwt.decode( + token, + self.rsa_public_key_pem, + algorithms=["RS256"], + audience=JWT_AUDIENCE, + issuer=JWT_ISSUER, + ) + + self.assertEqual(payload["iss"], JWT_ISSUER) + self.assertEqual(payload["sub"], self.api_client.client_id) + self.assertEqual(payload["aud"], JWT_AUDIENCE) + self.assertEqual(payload["client_id"], self.api_client.client_id) + self.assertIn("exp", payload) + self.assertIn("iat", payload) + self.assertIsInstance(payload["scopes"], list) + + def test_generated_token_verifiable_by_bridge(self): + """RS256 token generated by the endpoint can be verified by the bridge auth.""" + from ..middleware.auth_rs256 import _validate_rs256_token_with_issuer + from ..routers.oauth_rs256 import _generate_rs256_jwt_token + + token = _generate_rs256_jwt_token(self.rsa_private_key_pem, self.api_client, 1) + + # Verify using the bridge's RS256 validation + payload, issuer_rec = _validate_rs256_token_with_issuer(self.env, token) + self.assertEqual(payload["client_id"], self.api_client.client_id) + self.assertFalse(issuer_rec, "internal-issuer token should return no issuer record") + + def test_token_uses_rs256_algorithm(self): + """Generated token uses RS256 algorithm in header.""" + from ..routers.oauth_rs256 import _generate_rs256_jwt_token + + token = _generate_rs256_jwt_token(self.rsa_private_key_pem, self.api_client, 24) + + header = jwt.get_unverified_header(token) + self.assertEqual(header["alg"], "RS256") + + def test_token_payload_no_database_ids(self): + """SECURITY: Generated RS256 token must not contain database IDs.""" + from ..routers.oauth_rs256 import _generate_rs256_jwt_token + + token = _generate_rs256_jwt_token(self.rsa_private_key_pem, self.api_client, 24) + + payload = jwt.decode( + token, + self.rsa_public_key_pem, + algorithms=["RS256"], + audience=JWT_AUDIENCE, + issuer=JWT_ISSUER, + ) + + # Payload should not contain any database IDs + self.assertNotIn("id", payload) + self.assertNotIn("partner_id", payload) + self.assertNotIn("db_id", payload) + + def test_token_lifetime_configurable(self): + """Token lifetime is controlled by the hours parameter.""" + from ..routers.oauth_rs256 import _generate_rs256_jwt_token + + token_1h = _generate_rs256_jwt_token(self.rsa_private_key_pem, self.api_client, 1) + token_48h = _generate_rs256_jwt_token(self.rsa_private_key_pem, self.api_client, 48) + + decode_kwargs = dict(algorithms=["RS256"], audience=JWT_AUDIENCE, issuer=JWT_ISSUER) + payload_1h = jwt.decode(token_1h, self.rsa_public_key_pem, **decode_kwargs) + payload_48h = jwt.decode(token_48h, self.rsa_public_key_pem, **decode_kwargs) + + # 48h token should expire much later than 1h token + self.assertGreater(payload_48h["exp"], payload_1h["exp"]) + + def test_token_scopes_from_client(self): + """Token scopes match the API client's configured scopes.""" + from ..routers.oauth_rs256 import _generate_rs256_jwt_token + + token = _generate_rs256_jwt_token(self.rsa_private_key_pem, self.api_client, 24) + + payload = jwt.decode( + token, + self.rsa_public_key_pem, + algorithms=["RS256"], + audience=JWT_AUDIENCE, + issuer=JWT_ISSUER, + ) + + expected_scopes = [f"{s.resource}:{s.action}" for s in self.api_client.scope_ids] + self.assertEqual(payload["scopes"], expected_scopes) + + def test_missing_private_key_raises_clear_error(self): + """Calling get_private_key when not configured raises OpenSPPOAuthJWTException.""" + from odoo.addons.spp_oauth.tools import OpenSPPOAuthJWTException, get_private_key + + # Clear private key + self.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_private_key", False) + self.addCleanup( + self.env["ir.config_parameter"].sudo().set_param, + "spp_oauth.oauth_private_key", + self.rsa_private_key_pem, + ) + + with self.assertRaises(OpenSPPOAuthJWTException): + get_private_key(self.env) + + +@tagged("post_install", "-at_install") +class TestRS256TokenEndpoint(OAuthBridgeTestCase): + """Test the RS256 token endpoint function directly (not via HTTP). + + Calls the async get_rs256_token coroutine with constructed dependencies + to test endpoint logic without needing a full FastAPI test client. + """ + + def _run_async(self, coro): + """Run an async coroutine synchronously for testing.""" + return asyncio.run(coro) + + def _make_token_request(self, grant_type="client_credentials", client_id=None, client_secret=None): + """Create a TokenRequest-like object for endpoint testing.""" + from odoo.addons.spp_api_v2.routers.oauth import TokenRequest + + return TokenRequest( + grant_type=grant_type, + client_id=client_id or self.api_client.client_id, + client_secret=client_secret or self.api_client.client_secret, + ) + + def _make_mock_request(self): + """Create a minimal mock HTTP request object.""" + + class _MockRequest: + def __init__(self): + class _Client: + host = "127.0.0.1" + + self.client = _Client() + + return _MockRequest() + + def test_endpoint_valid_credentials(self): + """RS256 endpoint returns valid token response with correct credentials.""" + from ..routers.oauth_rs256 import get_rs256_token + + token_request = self._make_token_request() + response = self._run_async(get_rs256_token(self._make_mock_request(), token_request, self.env, None)) + + self.assertEqual(response.token_type, "Bearer") + self.assertIsNotNone(response.access_token) + self.assertGreater(response.expires_in, 0) + + # Verify the returned token is valid RS256 + header = jwt.get_unverified_header(response.access_token) + self.assertEqual(header["alg"], "RS256") + + def test_endpoint_invalid_grant_type(self): + """RS256 endpoint rejects unsupported grant_type.""" + from fastapi import HTTPException + + from ..routers.oauth_rs256 import get_rs256_token + + token_request = self._make_token_request(grant_type="authorization_code") + + with self.assertRaises(HTTPException) as ctx: + self._run_async(get_rs256_token(self._make_mock_request(), token_request, self.env, None)) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("grant_type", ctx.exception.detail.lower()) + + def test_endpoint_invalid_credentials(self): + """RS256 endpoint rejects invalid client credentials.""" + from fastapi import HTTPException + + from ..routers.oauth_rs256 import get_rs256_token + + token_request = self._make_token_request(client_secret="wrong-secret") + + with self.assertRaises(HTTPException) as ctx: + self._run_async(get_rs256_token(self._make_mock_request(), token_request, self.env, None)) + self.assertEqual(ctx.exception.status_code, 401) + + def test_endpoint_missing_private_key(self): + """RS256 endpoint returns 400 when RSA keys not configured.""" + from fastapi import HTTPException + + from ..routers.oauth_rs256 import get_rs256_token + + # Clear private key + self.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_private_key", False) + self.addCleanup( + self.env["ir.config_parameter"].sudo().set_param, + "spp_oauth.oauth_private_key", + self.rsa_private_key_pem, + ) + + token_request = self._make_token_request() + + with self.assertRaises(HTTPException) as ctx: + self._run_async(get_rs256_token(self._make_mock_request(), token_request, self.env, None)) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("not available", ctx.exception.detail.lower()) + + def test_endpoint_scope_string(self): + """RS256 endpoint returns correct scope string from client scopes.""" + from ..routers.oauth_rs256 import get_rs256_token + + token_request = self._make_token_request() + response = self._run_async(get_rs256_token(self._make_mock_request(), token_request, self.env, None)) + + self.assertEqual(response.scope, "individual:read") diff --git a/spp_api_v2_oauth/tools/__init__.py b/spp_api_v2_oauth/tools/__init__.py new file mode 100644 index 00000000..69275a8a --- /dev/null +++ b/spp_api_v2_oauth/tools/__init__.py @@ -0,0 +1 @@ +from . import jwks_cache diff --git a/spp_api_v2_oauth/tools/jwks_cache.py b/spp_api_v2_oauth/tools/jwks_cache.py new file mode 100644 index 00000000..d1242e67 --- /dev/null +++ b/spp_api_v2_oauth/tools/jwks_cache.py @@ -0,0 +1,56 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Process-local cache of PyJWKClient instances keyed by issuer record id. + +Each `spp.oauth.issuer` record with `key_source == 'jwks_uri'` gets its own +PyJWKClient, which in turn manages key caching with its own TTL. We keep one +PyJWKClient per record so that TTL/timeout config changes take effect on next +fetch and so that records can be invalidated independently when admins edit +or unlink them. + +The cache is process-local. Multi-worker Odoo deployments each maintain their +own copy; that's fine — JWKS responses are public. +""" + +import logging +import threading + +from jwt import PyJWKClient + +_logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_clients: dict[int, PyJWKClient] = {} + + +def get_jwks_client(issuer_record) -> PyJWKClient: + """Return a (cached) PyJWKClient for the given spp.oauth.issuer record.""" + issuer_record.ensure_one() + issuer_id = issuer_record.id + with _lock: + client = _clients.get(issuer_id) + if client is None: + client = PyJWKClient( + issuer_record.jwks_uri, + cache_keys=True, + lifespan=issuer_record.jwks_cache_ttl_seconds or 3600, + timeout=issuer_record.http_timeout_seconds or 5, + ) + _clients[issuer_id] = client + _logger.debug("Built new PyJWKClient for issuer id=%s uri=%s", issuer_id, issuer_record.jwks_uri) + return client + + +def invalidate(issuer_ids): + """Drop cached PyJWKClient(s) for the given record IDs.""" + if not issuer_ids: + return + with _lock: + for issuer_id in issuer_ids: + _clients.pop(issuer_id, None) + _logger.debug("Invalidated JWKS client cache for issuer ids=%s", list(issuer_ids)) + + +def clear(): + """Drop the entire cache. Intended for tests.""" + with _lock: + _clients.clear() diff --git a/spp_api_v2_oauth/views/api_client_views.xml b/spp_api_v2_oauth/views/api_client_views.xml new file mode 100644 index 00000000..69f0556c --- /dev/null +++ b/spp_api_v2_oauth/views/api_client_views.xml @@ -0,0 +1,23 @@ + + + + + spp.api.client.form.inherit.oauth + spp.api.client + + + + + + + + diff --git a/spp_api_v2_oauth/views/oauth_issuer_views.xml b/spp_api_v2_oauth/views/oauth_issuer_views.xml new file mode 100644 index 00000000..447a34ab --- /dev/null +++ b/spp_api_v2_oauth/views/oauth_issuer_views.xml @@ -0,0 +1,97 @@ + + + + + spp.oauth.issuer.tree + spp.oauth.issuer + + + + + + + + + + + + + + + + spp.oauth.issuer.form + spp.oauth.issuer + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + Trusted OAuth Issuers + spp.oauth.issuer + list,form + +

+ Register an external OAuth issuer +

+

+ Each record represents an external Identity Provider (e.g., a Keycloak realm) + whose RS256-signed JWTs the API will accept. The bridge looks up records by + the token's `iss` claim and uses the configured JWKS URI or static public key + to verify the signature. +

+
+
+ + + +
diff --git a/spp_oauth/README.rst b/spp_oauth/README.rst index 6bdb7597..2f870de0 100644 --- a/spp_oauth/README.rst +++ b/spp_oauth/README.rst @@ -56,6 +56,12 @@ Utility Functions +-----------------------------------+----------------------------------+ | Function | Purpose | +===================================+==================================+ +| ``get_private_key()`` | Retrieves OAuth private key from | +| | system parameters | ++-----------------------------------+----------------------------------+ +| ``get_public_key()`` | Retrieves OAuth public key from | +| | system parameters | ++-----------------------------------+----------------------------------+ | ``calculate_signature()`` | Encodes JWT with header and | | | payload using RS256 | +-----------------------------------+----------------------------------+ @@ -80,34 +86,36 @@ After installing: The keys are stored as system parameters: -- ``spp_oauth.oauth_priv_key`` -- ``spp_oauth.oauth_pub_key`` +- ``spp_oauth.oauth_private_key`` +- ``spp_oauth.oauth_public_key`` UI Location ~~~~~~~~~~~ - **Settings App Block**: SPP OAuth Settings (within Settings > General Settings) -- **Access**: Available to users with Settings access +- **Access**: System administrators only (``base.group_system``) Security ~~~~~~~~ -=================== ============================= -Group Access -=================== ============================= -``base.group_user`` Read/Write (no create/delete) -=================== ============================= +===================== ============================= +Group Access +===================== ============================= +``base.group_system`` Read/Write (no create/delete) +===================== ============================= -Keys are displayed as password fields in the UI but stored as plain text -in ``ir.config_parameter``. +Only system administrators can modify OAuth key settings. Keys are +displayed as password fields in the UI but stored as plain text in +``ir.config_parameter``. Extension Points ~~~~~~~~~~~~~~~~ -- Import ``calculate_signature()`` and ``verify_and_decode_signature()`` - from ``odoo.addons.spp_oauth.tools`` to implement OAuth 2.0 - authentication in custom API endpoints +- Import ``calculate_signature()``, ``verify_and_decode_signature()``, + ``get_private_key()``, and ``get_public_key()`` from + ``odoo.addons.spp_oauth.tools`` to implement OAuth 2.0 authentication + in custom API endpoints - Catch ``OpenSPPOAuthJWTException`` for OAuth-specific error handling in API controllers @@ -116,16 +124,249 @@ Dependencies ``spp_security``, ``base`` -**External Python**: ``pyjwt>=2.4.0`` +**External Python**: ``pyjwt>=2.4.0``, ``cryptography`` **Table of contents** .. contents:: :local: +Usage +===== + +This module provides RSA-based JWT signing and verification utilities. +It does not expose API endpoints — it is a utility library consumed by +other modules that need RS256 JWT authentication. Testing focuses on the +Settings UI and the JWT utility functions. + +Prerequisites +~~~~~~~~~~~~~ + +- ``spp_oauth`` module installed +- Admin or Settings-group access to the Odoo instance +- A signing keypair generated externally. RSA-2048 is the default + recommendation — NIST-approved through 2030 and ~5× faster on + sign/verify than RSA-4096. Use RSA-3072 or RSA-4096 only if your + compliance policy requires it. + +.. code:: bash + + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem + openssl rsa -in private.pem -pubout -out public.pem + +For new deployments, EC keys (P-256 → ``ES256``) are even faster and +produce shorter tokens. Consuming modules that surface algorithm choice +(e.g. the ``spp_api_v2_oauth`` Trusted-Issuer model) accept +``ES256/ES384/ES512`` directly. + +.. code:: bash + + openssl ecparam -name prime256v1 -genkey -noout -out private.pem + openssl ec -in private.pem -pubout -out public.pem + +UI Tests +~~~~~~~~ + +**Test 1: Settings UI Renders Correctly** + +1. Log in as a user with **Settings** access +2. Navigate to **Settings > General Settings** +3. Scroll down to the **SPP OAuth Settings** app block + +**Expected**: + +- The app block is visible with the module icon and title "SPP OAuth + Settings" +- Inside is a block titled **OAuth Settings (RSA or EC keys)** +- Two settings are displayed: **Private Key** and **Public Key** +- Both fields are masked (password input type) — values appear as dots + +**Test 2: Save and Persist RSA Keys** + +1. In the **SPP OAuth Settings** block, click the **Private Key** field + and paste the contents of ``private.pem`` +2. Click the **Public Key** field and paste the contents of + ``public.pem`` +3. Click **Save** +4. Navigate away from Settings, then return to **Settings > General + Settings** +5. Scroll to **SPP OAuth Settings** + +**Expected**: + +- Both fields show masked content (dots), indicating values were saved +- The values persist after navigating away and returning + +**Test 3: Verify Keys Stored in System Parameters** + +1. Navigate to **Settings > Technical > Parameters > System Parameters** +2. Search for ``spp_oauth`` + +**Expected**: + +- Two parameters exist: + + - ``spp_oauth.oauth_private_key`` — contains the private key PEM text + - ``spp_oauth.oauth_public_key`` — contains the public key PEM text + +**Test 4: Non-Admin Users Cannot Access OAuth Settings** + +1. Log in as a regular user (not in ``base.group_system``) +2. Attempt to navigate to **Settings > General Settings** + +**Expected**: + +- The user cannot access the Settings page (menu is not visible or + access is denied) +- OAuth keys are not exposed to non-admin users through the UI +- Only system administrators (``base.group_system``) can read or modify + OAuth key settings + +Utility Function Tests +~~~~~~~~~~~~~~~~~~~~~~ + +These tests require Odoo shell access (``odoo-bin shell``). They verify +the JWT signing and verification functions that consuming modules rely +on. + +**Test 5: Missing Keys Produce Clear Error** + +Precondition: RSA keys are **not** configured (clear both +``spp_oauth.oauth_private_key`` and ``spp_oauth.oauth_public_key`` in +System Parameters). + +.. code:: python + + from odoo.addons.spp_oauth.tools import calculate_signature, OpenSPPOAuthJWTException + + try: + calculate_signature(env=env, header=None, payload={"test": "data"}) + except OpenSPPOAuthJWTException as e: + # Expected: OpenSPPOAuthJWTException raised + +**Expected**: + +- An ``OpenSPPOAuthJWTException`` is raised with message: "OAuth private + key not configured in settings." + +**Test 6: JWT Sign and Verify Round-Trip** + +Precondition: RSA keys are configured (Test 2 completed). + +.. code:: python + + from odoo.addons.spp_oauth.tools import calculate_signature, verify_and_decode_signature + + # Sign a payload + token = calculate_signature( + env=env, + header=None, + payload={"user": "test", "action": "verify"}, + ) + # token is a JWT string (three base64 segments separated by dots) + + # Verify and decode + decoded = verify_and_decode_signature(env=env, access_token=token) + # decoded contains {"user": "test", "action": "verify"} + +**Expected**: + +- ``token`` is a non-empty string in JWT format (three base64 segments + separated by dots) +- ``decoded`` is a dict containing + ``{"user": "test", "action": "verify"}`` + +**Test 7: Tampered Token Is Rejected** + +Precondition: RSA keys are configured (Test 2 completed). + +.. code:: python + + from odoo.addons.spp_oauth.tools import calculate_signature, verify_and_decode_signature, OpenSPPOAuthJWTException + + token = calculate_signature( + env=env, + header=None, + payload={"data": "original"}, + ) + + # Tamper with the token signature + tampered = token[:-5] + "XXXXX" + + try: + verify_and_decode_signature(env=env, access_token=tampered) + except OpenSPPOAuthJWTException as e: + # Expected: OpenSPPOAuthJWTException raised + +**Expected**: + +- An ``OpenSPPOAuthJWTException`` is raised + +**Test 8: Token Signed With Wrong Key Is Rejected** + +This test verifies that a token signed with a different private key +cannot be verified with the configured public key. + +Precondition: RSA keys are configured (Test 2 completed). + +.. code:: python + + import jwt + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization + from odoo.addons.spp_oauth.tools import verify_and_decode_signature, OpenSPPOAuthJWTException + + # Generate a different RSA key pair + other_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + other_pem = other_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + # Sign a token with the wrong key + wrong_token = jwt.encode( + payload={"data": "forged"}, + key=other_pem, + algorithm="RS256", + ) + + try: + verify_and_decode_signature(env=env, access_token=wrong_token) + except OpenSPPOAuthJWTException as e: + # Expected: OpenSPPOAuthJWTException raised + +**Expected**: + +- An ``OpenSPPOAuthJWTException`` is raised (signature verification + fails) +- The configured public key correctly rejects the foreign-signed token + Changelog ========= +19.0.2.1.0 +~~~~~~~~~~ + +- refactor: rename config parameters ``spp_oauth.oauth_priv_key`` → + ``spp_oauth.oauth_private_key`` and ``spp_oauth.oauth_pub_key`` → + ``spp_oauth.oauth_public_key``, and the model class ``RegistryConfig`` + → ``OAuthConfig``, per naming conventions. Update any deployment that + reads these parameters directly. +- fix: empty the placeholder default values for the OAuth key config + parameters so ``get_private_key()`` / ``get_public_key()`` raise a + clear ``OpenSPPOAuthJWTException`` when keys are not configured, + instead of failing later with a cryptic PyJWT error on the placeholder + strings. +- feat: export ``get_private_key`` and ``get_public_key`` from + ``spp_oauth.tools`` for use by downstream modules. +- security: restrict the OAuth Settings ACL to ``base.group_system``. +- chore: remove ERROR logging from ``OpenSPPOAuthJWTException``'s + constructor (callers decide whether to log). +- docs: recommend RSA-2048 (not 4096) for new keypairs and mention + ES256/EC keys as a faster alternative; drop the "4096 bits" qualifier + from the OAuth Settings block title. + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_oauth/__manifest__.py b/spp_oauth/__manifest__.py index e3b2530a..4cf28117 100644 --- a/spp_oauth/__manifest__.py +++ b/spp_oauth/__manifest__.py @@ -3,7 +3,7 @@ "name": "OpenSPP API: Oauth", "summary": "The module establishes an OAuth 2.0 authentication framework, securing OpenSPP API communication for integrated systems and applications.", "category": "OpenSPP", - "version": "19.0.2.0.0", + "version": "19.0.2.1.0", "author": "OpenSPP.org", "development_status": "Production/Stable", "maintainers": ["jeremi", "gonzalesedwin1123", "reichie020212"], diff --git a/spp_oauth/data/ir_config_parameter_data.xml b/spp_oauth/data/ir_config_parameter_data.xml index 24d8b929..a43c58c2 100644 --- a/spp_oauth/data/ir_config_parameter_data.xml +++ b/spp_oauth/data/ir_config_parameter_data.xml @@ -1,11 +1,11 @@ - - spp_oauth.oauth_priv_key - YourPrivateKeyHere + + spp_oauth.oauth_private_key + - - spp_oauth.oauth_pub_key - YourPublicKeyHere + + spp_oauth.oauth_public_key + diff --git a/spp_oauth/models/res_config_settings.py b/spp_oauth/models/res_config_settings.py index c0ee50f9..bd1e0fbf 100644 --- a/spp_oauth/models/res_config_settings.py +++ b/spp_oauth/models/res_config_settings.py @@ -1,14 +1,14 @@ from odoo import fields, models -class RegistryConfig(models.TransientModel): +class OAuthConfig(models.TransientModel): _inherit = "res.config.settings" - oauth_priv_key = fields.Char( + oauth_private_key = fields.Char( string="OAuth Private Key", - config_parameter="spp_oauth.oauth_priv_key", + config_parameter="spp_oauth.oauth_private_key", ) - oauth_pub_key = fields.Char( + oauth_public_key = fields.Char( string="OAuth Public Key", - config_parameter="spp_oauth.oauth_pub_key", + config_parameter="spp_oauth.oauth_public_key", ) diff --git a/spp_oauth/readme/DESCRIPTION.md b/spp_oauth/readme/DESCRIPTION.md index 41239387..f5373388 100644 --- a/spp_oauth/readme/DESCRIPTION.md +++ b/spp_oauth/readme/DESCRIPTION.md @@ -17,6 +17,8 @@ OAuth 2.0 authentication framework for securing OpenSPP API communications using | Function | Purpose | | ------------------------------- | ---------------------------------------------------- | +| `get_private_key()` | Retrieves OAuth private key from system parameters | +| `get_public_key()` | Retrieves OAuth public key from system parameters | | `calculate_signature()` | Encodes JWT with header and payload using RS256 | | `verify_and_decode_signature()` | Decodes and verifies JWT token, returns payload | | `OpenSPPOAuthJWTException` | Custom exception for OAuth JWT errors with logging | @@ -32,29 +34,29 @@ After installing: 5. Save settings The keys are stored as system parameters: -- `spp_oauth.oauth_priv_key` -- `spp_oauth.oauth_pub_key` +- `spp_oauth.oauth_private_key` +- `spp_oauth.oauth_public_key` ### UI Location - **Settings App Block**: SPP OAuth Settings (within Settings > General Settings) -- **Access**: Available to users with Settings access +- **Access**: System administrators only (`base.group_system`) ### Security -| Group | Access | -| ------------------ | -------------------------------------- | -| `base.group_user` | Read/Write (no create/delete) | +| Group | Access | +| ------------------- | -------------------------------------- | +| `base.group_system` | Read/Write (no create/delete) | -Keys are displayed as password fields in the UI but stored as plain text in `ir.config_parameter`. +Only system administrators can modify OAuth key settings. Keys are displayed as password fields in the UI but stored as plain text in `ir.config_parameter`. ### Extension Points -- Import `calculate_signature()` and `verify_and_decode_signature()` from `odoo.addons.spp_oauth.tools` to implement OAuth 2.0 authentication in custom API endpoints +- Import `calculate_signature()`, `verify_and_decode_signature()`, `get_private_key()`, and `get_public_key()` from `odoo.addons.spp_oauth.tools` to implement OAuth 2.0 authentication in custom API endpoints - Catch `OpenSPPOAuthJWTException` for OAuth-specific error handling in API controllers ### Dependencies `spp_security`, `base` -**External Python**: `pyjwt>=2.4.0` +**External Python**: `pyjwt>=2.4.0`, `cryptography` diff --git a/spp_oauth/readme/HISTORY.md b/spp_oauth/readme/HISTORY.md index 4aaf9afe..756f7cf7 100644 --- a/spp_oauth/readme/HISTORY.md +++ b/spp_oauth/readme/HISTORY.md @@ -1,3 +1,12 @@ +### 19.0.2.1.0 + +- refactor: rename config parameters `spp_oauth.oauth_priv_key` → `spp_oauth.oauth_private_key` and `spp_oauth.oauth_pub_key` → `spp_oauth.oauth_public_key`, and the model class `RegistryConfig` → `OAuthConfig`, per naming conventions. Update any deployment that reads these parameters directly. +- fix: empty the placeholder default values for the OAuth key config parameters so `get_private_key()` / `get_public_key()` raise a clear `OpenSPPOAuthJWTException` when keys are not configured, instead of failing later with a cryptic PyJWT error on the placeholder strings. +- feat: export `get_private_key` and `get_public_key` from `spp_oauth.tools` for use by downstream modules. +- security: restrict the OAuth Settings ACL to `base.group_system`. +- chore: remove ERROR logging from `OpenSPPOAuthJWTException`'s constructor (callers decide whether to log). +- docs: recommend RSA-2048 (not 4096) for new keypairs and mention ES256/EC keys as a faster alternative; drop the "4096 bits" qualifier from the OAuth Settings block title. + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_oauth/readme/USAGE.md b/spp_oauth/readme/USAGE.md new file mode 100644 index 00000000..e2ae72f2 --- /dev/null +++ b/spp_oauth/readme/USAGE.md @@ -0,0 +1,184 @@ +This module provides RSA-based JWT signing and verification utilities. It does not expose API endpoints — it is a utility library consumed by other modules that need RS256 JWT authentication. Testing focuses on the Settings UI and the JWT utility functions. + +### Prerequisites + +- `spp_oauth` module installed +- Admin or Settings-group access to the Odoo instance +- A signing keypair generated externally. RSA-2048 is the default + recommendation — NIST-approved through 2030 and ~5× faster on sign/verify + than RSA-4096. Use RSA-3072 or RSA-4096 only if your compliance policy + requires it. + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem +openssl rsa -in private.pem -pubout -out public.pem +``` + +For new deployments, EC keys (P-256 → `ES256`) are even faster and produce +shorter tokens. Consuming modules that surface algorithm choice (e.g. the +`spp_api_v2_oauth` Trusted-Issuer model) accept `ES256/ES384/ES512` directly. + +```bash +openssl ecparam -name prime256v1 -genkey -noout -out private.pem +openssl ec -in private.pem -pubout -out public.pem +``` + +### UI Tests + +**Test 1: Settings UI Renders Correctly** + +1. Log in as a user with **Settings** access +2. Navigate to **Settings > General Settings** +3. Scroll down to the **SPP OAuth Settings** app block + +**Expected**: + +- The app block is visible with the module icon and title "SPP OAuth Settings" +- Inside is a block titled **OAuth Settings (RSA or EC keys)** +- Two settings are displayed: **Private Key** and **Public Key** +- Both fields are masked (password input type) — values appear as dots + +**Test 2: Save and Persist RSA Keys** + +1. In the **SPP OAuth Settings** block, click the **Private Key** field and paste the contents of `private.pem` +2. Click the **Public Key** field and paste the contents of `public.pem` +3. Click **Save** +4. Navigate away from Settings, then return to **Settings > General Settings** +5. Scroll to **SPP OAuth Settings** + +**Expected**: + +- Both fields show masked content (dots), indicating values were saved +- The values persist after navigating away and returning + +**Test 3: Verify Keys Stored in System Parameters** + +1. Navigate to **Settings > Technical > Parameters > System Parameters** +2. Search for `spp_oauth` + +**Expected**: + +- Two parameters exist: + - `spp_oauth.oauth_private_key` — contains the private key PEM text + - `spp_oauth.oauth_public_key` — contains the public key PEM text + +**Test 4: Non-Admin Users Cannot Access OAuth Settings** + +1. Log in as a regular user (not in `base.group_system`) +2. Attempt to navigate to **Settings > General Settings** + +**Expected**: + +- The user cannot access the Settings page (menu is not visible or access is denied) +- OAuth keys are not exposed to non-admin users through the UI +- Only system administrators (`base.group_system`) can read or modify OAuth key settings + +### Utility Function Tests + +These tests require Odoo shell access (`odoo-bin shell`). They verify the JWT signing and verification functions that consuming modules rely on. + +**Test 5: Missing Keys Produce Clear Error** + +Precondition: RSA keys are **not** configured (clear both `spp_oauth.oauth_private_key` and `spp_oauth.oauth_public_key` in System Parameters). + +```python +from odoo.addons.spp_oauth.tools import calculate_signature, OpenSPPOAuthJWTException + +try: + calculate_signature(env=env, header=None, payload={"test": "data"}) +except OpenSPPOAuthJWTException as e: + # Expected: OpenSPPOAuthJWTException raised +``` + +**Expected**: + +- An `OpenSPPOAuthJWTException` is raised with message: "OAuth private key not configured in settings." + +**Test 6: JWT Sign and Verify Round-Trip** + +Precondition: RSA keys are configured (Test 2 completed). + +```python +from odoo.addons.spp_oauth.tools import calculate_signature, verify_and_decode_signature + +# Sign a payload +token = calculate_signature( + env=env, + header=None, + payload={"user": "test", "action": "verify"}, +) +# token is a JWT string (three base64 segments separated by dots) + +# Verify and decode +decoded = verify_and_decode_signature(env=env, access_token=token) +# decoded contains {"user": "test", "action": "verify"} +``` + +**Expected**: + +- `token` is a non-empty string in JWT format (three base64 segments separated by dots) +- `decoded` is a dict containing `{"user": "test", "action": "verify"}` + +**Test 7: Tampered Token Is Rejected** + +Precondition: RSA keys are configured (Test 2 completed). + +```python +from odoo.addons.spp_oauth.tools import calculate_signature, verify_and_decode_signature, OpenSPPOAuthJWTException + +token = calculate_signature( + env=env, + header=None, + payload={"data": "original"}, +) + +# Tamper with the token signature +tampered = token[:-5] + "XXXXX" + +try: + verify_and_decode_signature(env=env, access_token=tampered) +except OpenSPPOAuthJWTException as e: + # Expected: OpenSPPOAuthJWTException raised +``` + +**Expected**: + +- An `OpenSPPOAuthJWTException` is raised + +**Test 8: Token Signed With Wrong Key Is Rejected** + +This test verifies that a token signed with a different private key cannot be verified with the configured public key. + +Precondition: RSA keys are configured (Test 2 completed). + +```python +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from odoo.addons.spp_oauth.tools import verify_and_decode_signature, OpenSPPOAuthJWTException + +# Generate a different RSA key pair +other_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) +other_pem = other_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), +).decode("utf-8") + +# Sign a token with the wrong key +wrong_token = jwt.encode( + payload={"data": "forged"}, + key=other_pem, + algorithm="RS256", +) + +try: + verify_and_decode_signature(env=env, access_token=wrong_token) +except OpenSPPOAuthJWTException as e: + # Expected: OpenSPPOAuthJWTException raised +``` + +**Expected**: + +- An `OpenSPPOAuthJWTException` is raised (signature verification fails) +- The configured public key correctly rejects the foreign-signed token diff --git a/spp_oauth/security/ir.model.access.csv b/spp_oauth/security/ir.model.access.csv index fb353758..45d3d787 100644 --- a/spp_oauth/security/ir.model.access.csv +++ b/spp_oauth/security/ir.model.access.csv @@ -1,2 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_res_config_settings_spp_oauth_user,res.config.settings spp_oauth user,base.model_res_config_settings,base.group_user,1,1,0,0 +access_res_config_settings_spp_oauth_admin,res.config.settings spp_oauth admin,base.model_res_config_settings,base.group_system,1,1,1,0 diff --git a/spp_oauth/static/description/index.html b/spp_oauth/static/description/index.html index 7734f64a..93d758b9 100644 --- a/spp_oauth/static/description/index.html +++ b/spp_oauth/static/description/index.html @@ -421,6 +421,14 @@

Utility Functions

+get_private_key() +Retrieves OAuth private key from +system parameters + +get_public_key() +Retrieves OAuth public key from +system parameters + calculate_signature() Encodes JWT with header and payload using RS256 @@ -449,8 +457,8 @@

Configuration

The keys are stored as system parameters:

    -
  • spp_oauth.oauth_priv_key
  • -
  • spp_oauth.oauth_pub_key
  • +
  • spp_oauth.oauth_private_key
  • +
  • spp_oauth.oauth_public_key
@@ -458,15 +466,15 @@

UI Location

  • Settings App Block: SPP OAuth Settings (within Settings > General Settings)
  • -
  • Access: Available to users with Settings access
  • +
  • Access: System administrators only (base.group_system)

Security

--++ @@ -474,20 +482,22 @@

Security

- +
Group
base.group_user
base.group_system Read/Write (no create/delete)
-

Keys are displayed as password fields in the UI but stored as plain text -in ir.config_parameter.

+

Only system administrators can modify OAuth key settings. Keys are +displayed as password fields in the UI but stored as plain text in +ir.config_parameter.

Extension Points

    -
  • Import calculate_signature() and verify_and_decode_signature() -from odoo.addons.spp_oauth.tools to implement OAuth 2.0 -authentication in custom API endpoints
  • +
  • Import calculate_signature(), verify_and_decode_signature(), +get_private_key(), and get_public_key() from +odoo.addons.spp_oauth.tools to implement OAuth 2.0 authentication +in custom API endpoints
  • Catch OpenSPPOAuthJWTException for OAuth-specific error handling in API controllers
@@ -495,18 +505,236 @@

Extension Points

Dependencies

spp_security, base

-

External Python: pyjwt>=2.4.0

+

External Python: pyjwt>=2.4.0, cryptography

Table of contents

+
+

Usage

+

This module provides RSA-based JWT signing and verification utilities. +It does not expose API endpoints — it is a utility library consumed by +other modules that need RS256 JWT authentication. Testing focuses on the +Settings UI and the JWT utility functions.

+
+
+
+

Prerequisites

+
    +
  • spp_oauth module installed
  • +
  • Admin or Settings-group access to the Odoo instance
  • +
  • A signing keypair generated externally. RSA-2048 is the default +recommendation — NIST-approved through 2030 and ~5× faster on +sign/verify than RSA-4096. Use RSA-3072 or RSA-4096 only if your +compliance policy requires it.
+
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
+openssl rsa -in private.pem -pubout -out public.pem
+
+

For new deployments, EC keys (P-256 → ES256) are even faster and +produce shorter tokens. Consuming modules that surface algorithm choice +(e.g. the spp_api_v2_oauth Trusted-Issuer model) accept +ES256/ES384/ES512 directly.

+
+openssl ecparam -name prime256v1 -genkey -noout -out private.pem
+openssl ec -in private.pem -pubout -out public.pem
+
+
+

UI Tests

+

Test 1: Settings UI Renders Correctly

+
    +
  1. Log in as a user with Settings access
  2. +
  3. Navigate to Settings > General Settings
  4. +
  5. Scroll down to the SPP OAuth Settings app block
  6. +
+

Expected:

+
    +
  • The app block is visible with the module icon and title “SPP OAuth +Settings”
  • +
  • Inside is a block titled OAuth Settings (RSA or EC keys)
  • +
  • Two settings are displayed: Private Key and Public Key
  • +
  • Both fields are masked (password input type) — values appear as dots
  • +
+

Test 2: Save and Persist RSA Keys

+
    +
  1. In the SPP OAuth Settings block, click the Private Key field +and paste the contents of private.pem
  2. +
  3. Click the Public Key field and paste the contents of +public.pem
  4. +
  5. Click Save
  6. +
  7. Navigate away from Settings, then return to Settings > General +Settings
  8. +
  9. Scroll to SPP OAuth Settings
  10. +
+

Expected:

+
    +
  • Both fields show masked content (dots), indicating values were saved
  • +
  • The values persist after navigating away and returning
  • +
+

Test 3: Verify Keys Stored in System Parameters

+
    +
  1. Navigate to Settings > Technical > Parameters > System Parameters
  2. +
  3. Search for spp_oauth
  4. +
+

Expected:

+
    +
  • Two parameters exist:
      +
    • spp_oauth.oauth_private_key — contains the private key PEM text
    • +
    • spp_oauth.oauth_public_key — contains the public key PEM text
    • +
    +
  • +
+

Test 4: Non-Admin Users Cannot Access OAuth Settings

+
    +
  1. Log in as a regular user (not in base.group_system)
  2. +
  3. Attempt to navigate to Settings > General Settings
  4. +
+

Expected:

+
    +
  • The user cannot access the Settings page (menu is not visible or +access is denied)
  • +
  • OAuth keys are not exposed to non-admin users through the UI
  • +
  • Only system administrators (base.group_system) can read or modify +OAuth key settings
  • +
+
+
+

Utility Function Tests

+

These tests require Odoo shell access (odoo-bin shell). They verify +the JWT signing and verification functions that consuming modules rely +on.

+

Test 5: Missing Keys Produce Clear Error

+

Precondition: RSA keys are not configured (clear both +spp_oauth.oauth_private_key and spp_oauth.oauth_public_key in +System Parameters).

+
+from odoo.addons.spp_oauth.tools import calculate_signature, OpenSPPOAuthJWTException
+
+try:
+    calculate_signature(env=env, header=None, payload={"test": "data"})
+except OpenSPPOAuthJWTException as e:
+    # Expected: OpenSPPOAuthJWTException raised
+
+

Expected:

+
    +
  • An OpenSPPOAuthJWTException is raised with message: “OAuth private +key not configured in settings.”
  • +
+

Test 6: JWT Sign and Verify Round-Trip

+

Precondition: RSA keys are configured (Test 2 completed).

+
+from odoo.addons.spp_oauth.tools import calculate_signature, verify_and_decode_signature
+
+# Sign a payload
+token = calculate_signature(
+    env=env,
+    header=None,
+    payload={"user": "test", "action": "verify"},
+)
+# token is a JWT string (three base64 segments separated by dots)
+
+# Verify and decode
+decoded = verify_and_decode_signature(env=env, access_token=token)
+# decoded contains {"user": "test", "action": "verify"}
+
+

Expected:

+
    +
  • token is a non-empty string in JWT format (three base64 segments +separated by dots)
  • +
  • decoded is a dict containing +{"user": "test", "action": "verify"}
  • +
+

Test 7: Tampered Token Is Rejected

+

Precondition: RSA keys are configured (Test 2 completed).

+
+from odoo.addons.spp_oauth.tools import calculate_signature, verify_and_decode_signature, OpenSPPOAuthJWTException
+
+token = calculate_signature(
+    env=env,
+    header=None,
+    payload={"data": "original"},
+)
+
+# Tamper with the token signature
+tampered = token[:-5] + "XXXXX"
+
+try:
+    verify_and_decode_signature(env=env, access_token=tampered)
+except OpenSPPOAuthJWTException as e:
+    # Expected: OpenSPPOAuthJWTException raised
+
+

Expected:

+
    +
  • An OpenSPPOAuthJWTException is raised
  • +
+

Test 8: Token Signed With Wrong Key Is Rejected

+

This test verifies that a token signed with a different private key +cannot be verified with the configured public key.

+

Precondition: RSA keys are configured (Test 2 completed).

+
+import jwt
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import serialization
+from odoo.addons.spp_oauth.tools import verify_and_decode_signature, OpenSPPOAuthJWTException
+
+# Generate a different RSA key pair
+other_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+other_pem = other_private_key.private_bytes(
+    encoding=serialization.Encoding.PEM,
+    format=serialization.PrivateFormat.TraditionalOpenSSL,
+    encryption_algorithm=serialization.NoEncryption(),
+).decode("utf-8")
+
+# Sign a token with the wrong key
+wrong_token = jwt.encode(
+    payload={"data": "forged"},
+    key=other_pem,
+    algorithm="RS256",
+)
+
+try:
+    verify_and_decode_signature(env=env, access_token=wrong_token)
+except OpenSPPOAuthJWTException as e:
+    # Expected: OpenSPPOAuthJWTException raised
+
+

Expected:

+
    +
  • An OpenSPPOAuthJWTException is raised (signature verification +fails)
  • +
  • The configured public key correctly rejects the foreign-signed token
  • +
-

Changelog

+

Changelog

+

19.0.2.1.0

+
    +
  • refactor: rename config parameters spp_oauth.oauth_priv_key → +spp_oauth.oauth_private_key and spp_oauth.oauth_pub_key → +spp_oauth.oauth_public_key, and the model class RegistryConfig +→ OAuthConfig, per naming conventions. Update any deployment that +reads these parameters directly.
  • +
  • fix: empty the placeholder default values for the OAuth key config +parameters so get_private_key() / get_public_key() raise a +clear OpenSPPOAuthJWTException when keys are not configured, +instead of failing later with a cryptic PyJWT error on the placeholder +strings.
  • +
  • feat: export get_private_key and get_public_key from +spp_oauth.tools for use by downstream modules.
  • +
  • security: restrict the OAuth Settings ACL to base.group_system.
  • +
  • chore: remove ERROR logging from OpenSPPOAuthJWTException’s +constructor (callers decide whether to log).
  • +
  • docs: recommend RSA-2048 (not 4096) for new keypairs and mention +ES256/EC keys as a faster alternative; drop the “4096 bits” qualifier +from the OAuth Settings block title.
  • +
+
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • diff --git a/spp_oauth/tests/__init__.py b/spp_oauth/tests/__init__.py index 89149670..2547b907 100644 --- a/spp_oauth/tests/__init__.py +++ b/spp_oauth/tests/__init__.py @@ -1,2 +1,3 @@ -from . import test_rsa_encode_decode from . import test_oauth_errors +from . import test_res_config_settings +from . import test_rsa_encode_decode diff --git a/spp_oauth/tests/common.py b/spp_oauth/tests/common.py index 9e012298..5c9a1012 100644 --- a/spp_oauth/tests/common.py +++ b/spp_oauth/tests/common.py @@ -1,26 +1,23 @@ -from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from odoo.tests import TransactionCase -MOCK_PRIVATE_KEY = "any_private_key" - class Common(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_priv_key", None) - cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_pub_key", None) + cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_private_key", None) + cls.env["ir.config_parameter"].sudo().set_param("spp_oauth.oauth_public_key", None) def set_parameters(self): # Generate test RSA keys - private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) public_key = private_key.public_key() self.env["ir.config_parameter"].sudo().set_param( - "spp_oauth.oauth_priv_key", + "spp_oauth.oauth_private_key", private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, @@ -29,7 +26,7 @@ def set_parameters(self): ) self.env["ir.config_parameter"].sudo().set_param( - "spp_oauth.oauth_pub_key", + "spp_oauth.oauth_public_key", public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, diff --git a/spp_oauth/tests/test_oauth_errors.py b/spp_oauth/tests/test_oauth_errors.py index 3321253f..6b840f4c 100644 --- a/spp_oauth/tests/test_oauth_errors.py +++ b/spp_oauth/tests/test_oauth_errors.py @@ -56,6 +56,38 @@ def test_exception_message(self): exc = OpenSPPOAuthJWTException("test error message") self.assertEqual(str(exc), "test error message") + def test_exception_inherits_from_exception(self): + """Test that OpenSPPOAuthJWTException is a proper Exception subclass.""" + exc = OpenSPPOAuthJWTException("something went wrong") + self.assertIsInstance(exc, Exception) + self.assertEqual(str(exc), "something went wrong") + + def test_verify_wrong_key_rejected(self): + """Test that a token signed with a different private key is rejected.""" + import jwt as pyjwt + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + self.set_parameters() + + # Generate a different RSA key pair + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + other_pem = other_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + # Sign a token with the wrong key + wrong_token = pyjwt.encode( + payload={"data": "forged"}, + key=other_pem, + algorithm="RS256", + ) + + with self.assertRaises(OpenSPPOAuthJWTException): + verify_and_decode_signature(env=self.env, access_token=wrong_token) + def test_calculate_signature_with_header(self): """Test calculate_signature with explicit header dict.""" self.set_parameters() diff --git a/spp_oauth/tests/test_res_config_settings.py b/spp_oauth/tests/test_res_config_settings.py new file mode 100644 index 00000000..ad9c45e9 --- /dev/null +++ b/spp_oauth/tests/test_res_config_settings.py @@ -0,0 +1,28 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests import TransactionCase + + +class TestResConfigSettings(TransactionCase): + """Test res.config.settings OAuth key fields.""" + + def test_set_oauth_keys_via_settings(self): + """Test that OAuth keys set through settings are persisted to config parameters.""" + config = self.env["res.config.settings"].create({}) + config.oauth_private_key = "test-private-key" + config.oauth_public_key = "test-public-key" + config.execute() + + icp = self.env["ir.config_parameter"].sudo() + self.assertEqual(icp.get_param("spp_oauth.oauth_private_key"), "test-private-key") + self.assertEqual(icp.get_param("spp_oauth.oauth_public_key"), "test-public-key") + + def test_get_oauth_keys_from_settings(self): + """Test that OAuth keys stored in config parameters are loaded into settings.""" + icp = self.env["ir.config_parameter"].sudo() + icp.set_param("spp_oauth.oauth_private_key", "stored-private-key") + icp.set_param("spp_oauth.oauth_public_key", "stored-public-key") + + config = self.env["res.config.settings"].create({}) + self.assertEqual(config.oauth_private_key, "stored-private-key") + self.assertEqual(config.oauth_public_key, "stored-public-key") diff --git a/spp_oauth/tests/test_rsa_encode_decode.py b/spp_oauth/tests/test_rsa_encode_decode.py index de4b6dfc..159e6cbb 100644 --- a/spp_oauth/tests/test_rsa_encode_decode.py +++ b/spp_oauth/tests/test_rsa_encode_decode.py @@ -1,31 +1,30 @@ import uuid -from ..tools.rsa_encode_decode import calculate_signature +from ..tools.rsa_encode_decode import ( + calculate_signature, + get_private_key, + get_public_key, + verify_and_decode_signature, +) from .common import Common class TestRSA(Common): - def test_01_get_private_key(self): + def test_get_private_key(self): self.set_parameters() - from ..tools.rsa_encode_decode import get_private_key - private_key = get_private_key(self.env) - self.assertTrue(private_key is not None) + self.assertIsNotNone(private_key) - def test_02_get_public_key(self): + def test_get_public_key(self): self.set_parameters() - from ..tools.rsa_encode_decode import get_public_key - public_key = get_public_key(self.env) - self.assertTrue(public_key is not None) + self.assertIsNotNone(public_key) - def test_03_calculate_signature(self): + def test_calculate_signature(self): self.set_parameters() - from ..tools.rsa_encode_decode import calculate_signature - openapi_token = str(uuid.uuid4()) token = calculate_signature( @@ -36,13 +35,11 @@ def test_03_calculate_signature(self): "token": openapi_token, }, ) - self.assertTrue(token is not None) + self.assertIsNotNone(token) - def test_04_verify_and_decode_signature(self): + def test_verify_and_decode_signature(self): self.set_parameters() - from ..tools.rsa_encode_decode import verify_and_decode_signature - openapi_token = str(uuid.uuid4()) token = calculate_signature( @@ -53,12 +50,12 @@ def test_04_verify_and_decode_signature(self): "token": openapi_token, }, ) - self.assertTrue(token is not None) + self.assertIsNotNone(token) decoded = verify_and_decode_signature( env=self.env, access_token=token, ) - self.assertTrue(decoded is not None) + self.assertIsNotNone(decoded) self.assertEqual(decoded.get("database"), self.env.cr.dbname) self.assertEqual(decoded.get("token"), openapi_token) diff --git a/spp_oauth/tools/__init__.py b/spp_oauth/tools/__init__.py index e549289a..0a50f382 100644 --- a/spp_oauth/tools/__init__.py +++ b/spp_oauth/tools/__init__.py @@ -1,3 +1,5 @@ +from .oauth_exception import OpenSPPOAuthJWTException from .rsa_encode_decode import calculate_signature +from .rsa_encode_decode import get_private_key +from .rsa_encode_decode import get_public_key from .rsa_encode_decode import verify_and_decode_signature -from .oauth_exception import OpenSPPOAuthJWTException diff --git a/spp_oauth/tools/oauth_exception.py b/spp_oauth/tools/oauth_exception.py index f17bcb17..5c2d36df 100644 --- a/spp_oauth/tools/oauth_exception.py +++ b/spp_oauth/tools/oauth_exception.py @@ -1,9 +1,2 @@ -import logging - -_logger = logging.getLogger(__name__) - - class OpenSPPOAuthJWTException(Exception): - def __init__(self, message): - super().__init__(message) - _logger.error("OAuth JWT error: %s", message) + """Raised when an OAuth JWT operation fails (missing keys, invalid tokens, etc.).""" diff --git a/spp_oauth/tools/rsa_encode_decode.py b/spp_oauth/tools/rsa_encode_decode.py index af746649..671d91d3 100644 --- a/spp_oauth/tools/rsa_encode_decode.py +++ b/spp_oauth/tools/rsa_encode_decode.py @@ -14,10 +14,10 @@ def get_private_key(env): :raises OpenSPPOAuthJWTException: If the private key is not configured. """ # nosemgrep: odoo-sudo-without-context - system parameter access requires sudo - priv_key = env["ir.config_parameter"].sudo().get_param("spp_oauth.oauth_priv_key") - if not priv_key: + private_key = env["ir.config_parameter"].sudo().get_param("spp_oauth.oauth_private_key") + if not private_key: raise OpenSPPOAuthJWTException("OAuth private key not configured in settings.") - return priv_key + return private_key def get_public_key(env): @@ -29,10 +29,10 @@ def get_public_key(env): :raises OpenSPPOAuthJWTException: If the public key is not configured. """ # nosemgrep: odoo-sudo-without-context - system parameter access requires sudo - pub_key = env["ir.config_parameter"].sudo().get_param("spp_oauth.oauth_pub_key") - if not pub_key: + public_key = env["ir.config_parameter"].sudo().get_param("spp_oauth.oauth_public_key") + if not public_key: raise OpenSPPOAuthJWTException("OAuth public key not configured in settings.") - return pub_key + return public_key def calculate_signature(env, header, payload): @@ -45,8 +45,8 @@ def calculate_signature(env, header, payload): :return: The encoded JWT. """ - privkey = get_private_key(env) - return jwt.encode(headers=header, payload=payload, key=privkey, algorithm=JWT_ALGORITHM) + private_key = get_private_key(env) + return jwt.encode(headers=header, payload=payload, key=private_key, algorithm=JWT_ALGORITHM) def verify_and_decode_signature(env, access_token): @@ -58,8 +58,8 @@ def verify_and_decode_signature(env, access_token): :return: The decoded payload. :raises OpenSPPOAuthJWTException: If verification fails or for any other JWT error. """ - pubkey = get_public_key(env) + public_key = get_public_key(env) try: - return jwt.decode(access_token, key=pubkey, algorithms=[JWT_ALGORITHM]) + return jwt.decode(access_token, key=public_key, algorithms=[JWT_ALGORITHM]) except jwt.exceptions.PyJWTError as e: raise OpenSPPOAuthJWTException(str(e)) from e diff --git a/spp_oauth/views/res_config_view.xml b/spp_oauth/views/res_config_view.xml index ca750153..200b77df 100644 --- a/spp_oauth/views/res_config_view.xml +++ b/spp_oauth/views/res_config_view.xml @@ -12,12 +12,12 @@ name="spp_oauth_config_settings" logo="/spp_oauth/static/description/icon.png" > - + - + - +