From 796964364b37f6466659fc11cb75e28cba6aef4c Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Fri, 17 Jan 2025 20:44:00 -0500 Subject: [PATCH] [IMP] auth_jwt: allow more authorization options over aud --- auth_jwt/README.rst | 51 +++++++++++++-- auth_jwt/__manifest__.py | 2 +- auth_jwt/models/auth_jwt_validator.py | 61 ++++++++++++++++-- auth_jwt/readme/CONTRIBUTORS.md | 1 + auth_jwt/readme/USAGE.md | 44 ++++++++++++- auth_jwt/static/description/index.html | 71 +++++++++++++++------ auth_jwt/tests/test_auth_jwt.py | 54 ++++++++++++++++ auth_jwt/views/auth_jwt_validator_views.xml | 7 +- 8 files changed, 256 insertions(+), 35 deletions(-) diff --git a/auth_jwt/README.rst b/auth_jwt/README.rst index 4446364614..2780f774a5 100644 --- a/auth_jwt/README.rst +++ b/auth_jwt/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ======== Auth JWT ======== @@ -17,7 +13,7 @@ Auth JWT .. |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 +.. |badge2| image:: https://img.shields.io/badge/licence-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-OCA%2Fserver--auth-lightgray.png?logo=github @@ -65,14 +61,54 @@ The JWT validator can be configured with the following properties: - ``name``: the validator name, to match the ``auth="jwt_{validator-name}"`` route property. -- ``audience``: a comma-separated list of allowed audiences, used to - validate the ``aud`` claim. +- ``audience``: a comma-separated list of values that must intersect + with the JWT claim selected by ``audience_type`` (by default the + standard ``aud`` claim — see "Audience type" below for matching + against other claims like ``groups`` or ``scope``). +- ``audience_type``: selects which JWT payload claim the ``audience`` + list is matched against — ``Audience`` (default, validates ``aud``), + ``Group``, ``Scope``, or ``Custom``. See "Audience type" below. +- ``audience_type_custom``: when ``audience_type`` is ``Custom``, the + JWT payload key to validate against the ``audience`` list (e.g. + ``cognito:groups``, ``permissions``). - ``issuer``: used to validate the ``iss`` claim. - Signature type (secret or public key), algorithm, secret and JWK URI are used to validate the token signature. In addition, the ``exp`` claim is validated to reject expired tokens. +**Audience type — matching non-standard JWT claims.** The ``audience`` +setting is matched against the standard JWT ``aud`` claim by default +(RFC 7519). Some identity providers — notably AWS Cognito and several +OAuth2-only IdPs — issue access tokens without an ``aud`` claim but +expose authorization information under other claims (``cognito:groups``, +``scope``, ``roles``). The ``audience_type`` field controls which claim +the ``audience`` list is matched against: + +- **Audience** (default): standard ``aud`` claim validation; at least + one configured value must be present in the token's ``aud`` claim. +- **Group**: validates against the ``groups`` claim (array or + space-separated string). +- **Scope**: validates against the ``scope`` claim (space-separated per + OAuth2 RFC 6749 §3.3, or an array). +- **Custom**: validates against the arbitrary payload key configured in + *Custom Audience Type Key* (e.g. ``cognito:groups``, ``permissions``, + ``https://example.com/claims/roles``). + +For all non-``aud`` types the JWT library's built-in ``aud`` +verification is skipped (the token has no ``aud``) and the match is a +set intersection: any one of the configured ``audience`` values +appearing in the token's claim authorizes the request. + +**Example — AWS Cognito access token.** Cognito access tokens carry no +``aud`` claim but include ``cognito:groups`` (e.g. +``["odoo-admin", "odoo-portal"]``) and ``scope`` (e.g. +``"openid profile odoo/read"``). To restrict a route to clients in the +``odoo-admin`` Cognito group, configure the validator with +``audience_type = Custom``, ``audience_type_custom = cognito:groups``, +and ``audience = odoo-admin``. To restrict by OAuth scope instead, +configure ``audience_type = Scope`` and ``audience = odoo/read``. + If the ``Authorization`` HTTP header is missing, malformed, or contains an invalid token, the request is rejected with a 401 (Unauthorized) code, unless the cookie mode is enabled (see below). @@ -141,6 +177,7 @@ Contributors - Stéphane Bidoul - Mohamed Alkobrosli +- Don Kendall Maintainers ----------- diff --git a/auth_jwt/__manifest__.py b/auth_jwt/__manifest__.py index 3791933393..1acd7340a6 100644 --- a/auth_jwt/__manifest__.py +++ b/auth_jwt/__manifest__.py @@ -5,7 +5,7 @@ "name": "Auth JWT", "summary": """ JWT bearer token authentication.""", - "version": "18.0.1.0.2", + "version": "18.0.1.1.0", "license": "LGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", "maintainers": ["sbidoul"], diff --git a/auth_jwt/models/auth_jwt_validator.py b/auth_jwt/models/auth_jwt_validator.py index 73bc8a393b..58b4a1cdab 100644 --- a/auth_jwt/models/auth_jwt_validator.py +++ b/auth_jwt/models/auth_jwt_validator.py @@ -65,8 +65,44 @@ class AuthJwtValidator(models.Model): ], default="RS256", ) + audience_type = fields.Selection( + [ + ("aud", "Audience"), + ("group", "Group"), + ("scope", "Scope"), + ("custom", "Custom"), + ], + required=True, + default="aud", + help=( + "Which JWT payload claim to validate the Audience list against:\n" + "- Audience (default): standard `aud` claim per RFC 7519.\n" + "- Group: matches against the token's `groups` claim. Useful " + "when the IdP exposes group membership but doesn't set `aud` " + "(typical for first-party OAuth2 access tokens).\n" + "- Scope: matches against the `scope` claim (space-separated " + "per OAuth2 RFC 6749 §3.3, or an array).\n" + "- Custom: matches against an arbitrary payload key specified " + "in Custom Audience Type Key (e.g. `cognito:groups`)." + ), + ) + audience_type_custom = fields.Char( + help=( + "JWT payload key to validate against the Audience list. Only " + "used when Audience Type is Custom. Example: `cognito:groups`, " + "`roles`, `permissions`, `https://example.com/claims/roles`." + ), + ) audience = fields.Char( - required=True, help="Comma separated list of audiences, to validate aud." + required=True, + help=( + "Comma-separated values that must intersect with the JWT claim " + "selected by Audience Type. At least one value must be present " + "in the token for the request to be authorized. For Audience " + "type this validates the standard `aud` claim; for other types " + "this is a set-intersection check against the corresponding " + "payload field." + ), ) issuer = fields.Char(required=True, help="To validate iss.") user_id_strategy = fields.Selection( @@ -161,7 +197,7 @@ def _get_validator_by_name(self, validator_name): @tools.ormcache("self.public_key_jwk_uri", "kid") def _get_key(self, kid): - jwks_client = PyJWKClient(self.public_key_jwk_uri, cache_keys=False) + jwks_client = PyJWKClient(self.public_key_jwk_uri) return jwks_client.get_signing_key(kid).key def _encode(self, payload, secret, expire): @@ -195,20 +231,35 @@ def _decode(self, token, secret=None): raise UnauthorizedInvalidToken() from e key = self._get_key(header.get("kid")) algorithm = self.public_key_algorithm + aud = (self.audience or "").split(",") if self.audience_type == "aud" else None try: payload = jwt.decode( token, key=key, algorithms=[algorithm], options=dict( - require=["exp", "aud", "iss"], + require=["exp", "iss"], verify_exp=True, - verify_aud=True, verify_iss=True, ), - audience=self.audience.split(","), + audience=aud, issuer=self.issuer, ) + payload_key = ( + self.audience_type_custom + if self.audience_type == "custom" + else self.audience_type + ) + if len((self.audience or "").split(",") or []) > 0: + for key_value in (self.audience or "").split(","): + payload_value = ( + payload.get(payload_key) + if isinstance(payload.get(payload_key), list) + else (payload.get(payload_key) or "").split(" ") + ) + if key_value in payload_value: + return payload + raise UnauthorizedInvalidToken() except Exception as e: _logger.info("Invalid token: %s", e) raise UnauthorizedInvalidToken() from e diff --git a/auth_jwt/readme/CONTRIBUTORS.md b/auth_jwt/readme/CONTRIBUTORS.md index d6260f557c..2a984e3e28 100644 --- a/auth_jwt/readme/CONTRIBUTORS.md +++ b/auth_jwt/readme/CONTRIBUTORS.md @@ -1,2 +1,3 @@ - Stéphane Bidoul \<\> - Mohamed Alkobrosli \<\> +- Don Kendall \<\> diff --git a/auth_jwt/readme/USAGE.md b/auth_jwt/readme/USAGE.md index b67c4fc331..26d2e529a7 100644 --- a/auth_jwt/readme/USAGE.md +++ b/auth_jwt/readme/USAGE.md @@ -16,14 +16,54 @@ The JWT validator can be configured with the following properties: - `name`: the validator name, to match the `auth="jwt_{validator-name}"` route property. -- `audience`: a comma-separated list of allowed audiences, used to - validate the `aud` claim. +- `audience`: a comma-separated list of values that must intersect with + the JWT claim selected by `audience_type` (by default the standard + `aud` claim — see "Audience type" below for matching against other + claims like `groups` or `scope`). +- `audience_type`: selects which JWT payload claim the `audience` list + is matched against — `Audience` (default, validates `aud`), `Group`, + `Scope`, or `Custom`. See "Audience type" below. +- `audience_type_custom`: when `audience_type` is `Custom`, the JWT + payload key to validate against the `audience` list (e.g. + `cognito:groups`, `permissions`). - `issuer`: used to validate the `iss` claim. - Signature type (secret or public key), algorithm, secret and JWK URI are used to validate the token signature. In addition, the `exp` claim is validated to reject expired tokens. +**Audience type — matching non-standard JWT claims.** The `audience` +setting is matched against the standard JWT `aud` claim by default +(RFC 7519). Some identity providers — notably AWS Cognito and several +OAuth2-only IdPs — issue access tokens without an `aud` claim but +expose authorization information under other claims (`cognito:groups`, +`scope`, `roles`). The `audience_type` field controls which claim the +`audience` list is matched against: + +- **Audience** (default): standard `aud` claim validation; at least + one configured value must be present in the token's `aud` claim. +- **Group**: validates against the `groups` claim (array or + space-separated string). +- **Scope**: validates against the `scope` claim (space-separated per + OAuth2 RFC 6749 §3.3, or an array). +- **Custom**: validates against the arbitrary payload key configured + in *Custom Audience Type Key* (e.g. `cognito:groups`, `permissions`, + `https://example.com/claims/roles`). + +For all non-`aud` types the JWT library's built-in `aud` verification +is skipped (the token has no `aud`) and the match is a set +intersection: any one of the configured `audience` values appearing in +the token's claim authorizes the request. + +**Example — AWS Cognito access token.** Cognito access tokens carry +no `aud` claim but include `cognito:groups` +(e.g. `["odoo-admin", "odoo-portal"]`) and `scope` +(e.g. `"openid profile odoo/read"`). To restrict a route to clients +in the `odoo-admin` Cognito group, configure the validator with +`audience_type = Custom`, `audience_type_custom = cognito:groups`, +and `audience = odoo-admin`. To restrict by OAuth scope instead, +configure `audience_type = Scope` and `audience = odoo/read`. + If the `Authorization` HTTP header is missing, malformed, or contains an invalid token, the request is rejected with a 401 (Unauthorized) code, unless the cookie mode is enabled (see below). diff --git a/auth_jwt/static/description/index.html b/auth_jwt/static/description/index.html index 8b4f8e4c5a..80b69982a4 100644 --- a/auth_jwt/static/description/index.html +++ b/auth_jwt/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Auth JWT -
+
+

Auth JWT

- - -Odoo Community Association - -
-

Auth JWT

-

Beta License: LGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

Beta License: LGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

JWT bearer token authentication.

Table of contents

@@ -391,11 +386,11 @@

Auth JWT

-

Installation

+

Installation

This module requires the pyjwt library to be installed.

-

Usage

+

Usage

This module lets developpers add a new jwt authentication method on Odoo controller routes.

To use it, you must:

@@ -412,13 +407,51 @@

Usage

  • name: the validator name, to match the auth="jwt_{validator-name}" route property.
  • -
  • audience: a comma-separated list of allowed audiences, used to -validate the aud claim.
  • +
  • audience: a comma-separated list of values that must intersect +with the JWT claim selected by audience_type (by default the +standard aud claim — see “Audience type” below for matching +against other claims like groups or scope).
  • +
  • audience_type: selects which JWT payload claim the audience +list is matched against — Audience (default, validates aud), +Group, Scope, or Custom. See “Audience type” below.
  • +
  • audience_type_custom: when audience_type is Custom, the +JWT payload key to validate against the audience list (e.g. +cognito:groups, permissions).
  • issuer: used to validate the iss claim.
  • Signature type (secret or public key), algorithm, secret and JWK URI are used to validate the token signature.

In addition, the exp claim is validated to reject expired tokens.

+

Audience type — matching non-standard JWT claims. The audience +setting is matched against the standard JWT aud claim by default +(RFC 7519). Some identity providers — notably AWS Cognito and several +OAuth2-only IdPs — issue access tokens without an aud claim but +expose authorization information under other claims (cognito:groups, +scope, roles). The audience_type field controls which claim +the audience list is matched against:

+
    +
  • Audience (default): standard aud claim validation; at least +one configured value must be present in the token’s aud claim.
  • +
  • Group: validates against the groups claim (array or +space-separated string).
  • +
  • Scope: validates against the scope claim (space-separated per +OAuth2 RFC 6749 §3.3, or an array).
  • +
  • Custom: validates against the arbitrary payload key configured in +Custom Audience Type Key (e.g. cognito:groups, permissions, +https://example.com/claims/roles).
  • +
+

For all non-aud types the JWT library’s built-in aud +verification is skipped (the token has no aud) and the match is a +set intersection: any one of the configured audience values +appearing in the token’s claim authorizes the request.

+

Example — AWS Cognito access token. Cognito access tokens carry no +aud claim but include cognito:groups (e.g. +["odoo-admin", "odoo-portal"]) and scope (e.g. +"openid profile odoo/read"). To restrict a route to clients in the +odoo-admin Cognito group, configure the validator with +audience_type = Custom, audience_type_custom = cognito:groups, +and audience = odoo-admin. To restrict by OAuth scope instead, +configure audience_type = Scope and audience = odoo/read.

If the Authorization HTTP header is missing, malformed, or contains an invalid token, the request is rejected with a 401 (Unauthorized) code, unless the cookie mode is enabled (see below).

@@ -459,7 +492,7 @@

Usage

with a different user by providing a new JWT token.

-

Bug Tracker

+

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 @@ -467,22 +500,23 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • ACSONE SA/NV
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -497,6 +531,5 @@

Maintainers

-
diff --git a/auth_jwt/tests/test_auth_jwt.py b/auth_jwt/tests/test_auth_jwt.py index 5175566869..a96d2797a8 100644 --- a/auth_jwt/tests/test_auth_jwt.py +++ b/auth_jwt/tests/test_auth_jwt.py @@ -440,3 +440,57 @@ def test_public_or_jwt_valid_token(self): with self._mock_request(authorization=authorization) as request: self.env["ir.http"]._auth_method_public_or_jwt_validator() assert request.jwt_payload["aud"] == "me" + + def test_valid_token_with_audience_type_aud(self): + with self._create_validator("val1", audience="client1,client2") as validator: + validator.audience_type = "aud" + token = self._create_token(audience="client1") + payload = validator._decode(token) + self.assertEqual(payload["aud"], "client1") + + def test_invalid_audience_with_audience_type_aud(self): + with self._create_validator("val2", audience="client1,client2") as validator: + validator.audience_type = "aud" + token = self._create_token(audience="otherclient") + with self.assertRaises(UnauthorizedInvalidToken): + validator._decode(token) + + def test_valid_token_with_custom_audience_type(self): + with self._create_validator("val3", audience="read,write") as validator: + validator.audience_type = "custom" + validator.audience_type_custom = "scope" + payload = { + "iss": "http://the.issuer", + "exp": time.time() + 100, + "scope": "read write", # token claim space-separated + } + token = jwt.encode( + payload, + "thesecret012345678901234567890123456789", + algorithm="HS256", + ) + decoded = validator._decode(token) + self.assertIn("read", decoded["scope"].split(" ")) + + def test_invalid_custom_audience_type(self): + with self._create_validator("val4", audience="read") as validator: + validator.audience_type = "custom" + validator.audience_type_custom = "scope" + token = self._create_token() + # No scope claim in token + with self.assertRaises(UnauthorizedInvalidToken): + validator._decode(token) + + def test_invalid_signature_rejected(self): + with self._create_validator("val5", audience="client1") as validator: + validator.audience_type = "aud" + # PyJWT raises InsecureKeyLengthWarning on HMAC keys <32 bytes, + # which the upstream test config treats as an error. Match the + # 40-byte length of the helper's default key but with different + # content so the signature actually mismatches the validator. + token = self._create_token( + audience="client1", + key="wrongsecret098765432109876543210987654321", + ) + with self.assertRaises(UnauthorizedInvalidToken): + validator._decode(token) diff --git a/auth_jwt/views/auth_jwt_validator_views.xml b/auth_jwt/views/auth_jwt_validator_views.xml index 8aac0f500f..f44587f1a6 100644 --- a/auth_jwt/views/auth_jwt_validator_views.xml +++ b/auth_jwt/views/auth_jwt_validator_views.xml @@ -12,8 +12,13 @@ + + -