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..3ff666d6 --- /dev/null +++ b/spp_api_v2_oauth/README.rst @@ -0,0 +1,228 @@ +================================== +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-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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 RSA Keys +~~~~~~~~~~~~~~~~~ + +.. code:: bash + + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out private.pem + openssl rsa -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. + +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}" | ++---------------------------+-------------+---------------------------+ +| Rate limit exceeded | 429 | "Rate limit exceeded" | ++---------------------------+-------------+---------------------------+ + +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..e83d2693 --- /dev/null +++ b/spp_api_v2_oauth/__manifest__.py @@ -0,0 +1,21 @@ +# 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.1.0.0", + "author": "OpenSPP.org", + "development_status": "Beta", + "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": [], + "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..497964fc --- /dev/null +++ b/spp_api_v2_oauth/constants.py @@ -0,0 +1,8 @@ +# 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" 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..1b000d46 --- /dev/null +++ b/spp_api_v2_oauth/middleware/auth_rs256.py @@ -0,0 +1,150 @@ +# 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 to RS256 or HS256 verification based on the JWT header's `alg` field. +""" + +import logging +from typing import Annotated + +import jwt + +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_ISSUER + +_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. + + RS256 tokens are verified using the RSA public key from spp_oauth. + HS256 tokens are delegated to the original spp_api_v2 verification. + """ + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing Authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + + try: + # Read the algorithm from the JWT header (unverified) to route verification + 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 = _validate_rs256_token(env, token) + elif alg == "HS256": + payload = _validate_jwt_token(env, token) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Unsupported token algorithm: {alg}", + ) + + # Look up API client by client_id from payload + client_id = payload.get("client_id") + if not client_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token: missing client_id", + ) + + api_client = ( + env["spp.api.client"] # nosemgrep: odoo-sudo-without-context + .sudo() + .search( + [ + ("client_id", "=", client_id), + ("active", "=", True), + ], + 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(env: Environment, token: str) -> dict: + """Validate an RS256-signed JWT token. + + Uses the RSA public key from spp_oauth and validates audience, issuer, + and expiration claims to match the same security requirements as the + HS256 path in spp_api_v2. + """ + 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: + payload = jwt.decode( + token, + public_key, + algorithms=["RS256"], + audience=JWT_AUDIENCE, + issuer=JWT_ISSUER, + ) + return payload + + 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 diff --git a/spp_api_v2_oauth/models/__init__.py b/spp_api_v2_oauth/models/__init__.py new file mode 100644 index 00000000..b825fab9 --- /dev/null +++ b/spp_api_v2_oauth/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint 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/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/USAGE.md b/spp_api_v2_oauth/readme/USAGE.md new file mode 100644 index 00000000..abdfdcb6 --- /dev/null +++ b/spp_api_v2_oauth/readme/USAGE.md @@ -0,0 +1,71 @@ +### 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 RSA Keys + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out private.pem +openssl rsa -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. + +### 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}" | +| 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/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..fb667c82 --- /dev/null +++ b/spp_api_v2_oauth/static/description/index.html @@ -0,0 +1,606 @@ + + + + + +OpenSPP API V2: OAuth RS256 Bridge + + + +
+

OpenSPP API V2: OAuth RS256 Bridge

+ + +

Beta 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 RSA Keys

+
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out private.pem
+openssl rsa -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.

+
+
+

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}”
Rate limit exceeded429“Rate limit exceeded”
+
+

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..742f2e1f --- /dev/null +++ b/spp_api_v2_oauth/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_auth_hs256 +from . import test_auth_rs256 +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..02315032 --- /dev/null +++ b/spp_api_v2_oauth/tests/common.py @@ -0,0 +1,111 @@ +# 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) + + # Create test API client + partner = cls.env["res.partner"].create({"name": "OAuth Bridge Test 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) + + client_vals = { + "name": "OAuth Bridge Test Client", + "partner_id": partner.id, + "is_require_consent": False, + "legal_basis": "consent", + } + if org_type: + client_vals["organization_type_id"] = org_type.id + + cls.api_client = cls.env["spp.api.client"].create(client_vals) + + # Create test scopes + cls.env["spp.api.client.scope"].create( + { + "client_id": cls.api_client.id, + "resource": "individual", + "action": "read", + } + ) + + 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..e3abec57 --- /dev/null +++ b/spp_api_v2_oauth/tests/test_auth_hs256.py @@ -0,0 +1,121 @@ +# 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", + ) 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..5515891d --- /dev/null +++ b/spp_api_v2_oauth/tests/test_auth_rs256.py @@ -0,0 +1,191 @@ +# 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 + + token = self.generate_rs256_token() + payload = _validate_rs256_token(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) + + def test_rs256_wrong_audience(self): + """RS256 token with wrong audience is rejected.""" + from ..middleware.auth_rs256 import _validate_rs256_token + + token = self.generate_rs256_token(payload_overrides={"aud": "wrong-audience"}) + + with self.assertRaises(HTTPException) as ctx: + _validate_rs256_token(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 + + token = self.generate_rs256_token(payload_overrides={"iss": "wrong-issuer"}) + + with self.assertRaises(HTTPException) as ctx: + _validate_rs256_token(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 + + 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(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 + + # 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(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 + + # 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(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()) 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..2fd54263 --- /dev/null +++ b/spp_api_v2_oauth/tests/test_token_generation.py @@ -0,0 +1,228 @@ +# 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 + 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 = _validate_rs256_token(self.env, token) + self.assertEqual(payload["client_id"], self.api_client.client_id) + + 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_oauth/README.rst b/spp_oauth/README.rst index d5157bd7..a07f20bb 100644 --- a/spp_oauth/README.rst +++ b/spp_oauth/README.rst @@ -1,12 +1,8 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================== OpenSPP API: Oauth ================== -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! @@ -47,12 +43,12 @@ Key Capabilities Key Models ~~~~~~~~~~ -+-------------------------+-------------------------------------------+ -| Model | Description | -+=========================+===========================================+ -| ``res.config.settings`` | Extended to add OAuth private and public | -| | key fields | -+-------------------------+-------------------------------------------+ ++-------------------------+--------------------------------------------+ +| Model | Description | ++=========================+============================================+ +| ``res.config.settings`` | Extended to add OAuth private and public | +| | key fields | ++-------------------------+--------------------------------------------+ Utility Functions ~~~~~~~~~~~~~~~~~ @@ -60,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 | +-----------------------------------+----------------------------------+ @@ -84,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 @@ -120,13 +124,211 @@ 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 +- An RSA key pair (4096-bit recommended) generated externally: + +.. code:: bash + + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out private.pem + openssl rsa -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 (4096 bits RSA 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 + Bug Tracker =========== @@ -164,4 +366,4 @@ Current maintainers: This module is part of the `OpenSPP/OpenSPP2 `_ project on GitHub. -You are welcome to contribute. +You are welcome to contribute. \ No newline at end of file diff --git a/spp_oauth/__manifest__.py b/spp_oauth/__manifest__.py index 3dcfe69b..b267328f 100644 --- a/spp_oauth/__manifest__.py +++ b/spp_oauth/__manifest__.py @@ -2,7 +2,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", + "category": "OpenSPP/Integration", "version": "19.0.1.3.1", "author": "OpenSPP.org", "development_status": "Beta", 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/USAGE.md b/spp_oauth/readme/USAGE.md new file mode 100644 index 00000000..94809b7b --- /dev/null +++ b/spp_oauth/readme/USAGE.md @@ -0,0 +1,172 @@ +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 +- An RSA key pair (4096-bit recommended) generated externally: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out private.pem +openssl rsa -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 (4096 bits RSA 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 622eaaa9..5528e019 100644 --- a/spp_oauth/static/description/index.html +++ b/spp_oauth/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +OpenSPP API: Oauth -
+
+

OpenSPP API: Oauth

- - -Odoo Community Association - -
-

OpenSPP API: Oauth