Skip to content

Commit e1cfb6f

Browse files
committed
[IMP] auth_jwt: allow more authorization options over aud
1 parent 6c7d03e commit e1cfb6f

8 files changed

Lines changed: 249 additions & 35 deletions

File tree

auth_jwt/README.rst

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
.. image:: https://odoo-community.org/readme-banner-image
2-
:target: https://odoo-community.org/get-involved?utm_source=readme
3-
:alt: Odoo Community Association
4-
51
========
62
Auth JWT
73
========
@@ -17,7 +13,7 @@ Auth JWT
1713
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
1814
:target: https://odoo-community.org/page/development-status
1915
:alt: Beta
20-
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
16+
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
2117
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
2218
:alt: License: LGPL-3
2319
.. |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:
6561

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

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

80+
**Audience type — matching non-standard JWT claims.** The ``audience``
81+
setting is matched against the standard JWT ``aud`` claim by default
82+
(RFC 7519). Some identity providers — notably AWS Cognito and several
83+
OAuth2-only IdPs — issue access tokens without an ``aud`` claim but
84+
expose authorization information under other claims (``cognito:groups``,
85+
``scope``, ``roles``). The ``audience_type`` field controls which claim
86+
the ``audience`` list is matched against:
87+
88+
- **Audience** (default): standard ``aud`` claim validation; at least
89+
one configured value must be present in the token's ``aud`` claim.
90+
- **Group**: validates against the ``groups`` claim (array or
91+
space-separated string).
92+
- **Scope**: validates against the ``scope`` claim (space-separated per
93+
OAuth2 RFC 6749 §3.3, or an array).
94+
- **Custom**: validates against the arbitrary payload key configured in
95+
*Custom Audience Type Key* (e.g. ``cognito:groups``, ``permissions``,
96+
``https://example.com/claims/roles``).
97+
98+
For all non-``aud`` types the JWT library's built-in ``aud``
99+
verification is skipped (the token has no ``aud``) and the match is a
100+
set intersection: any one of the configured ``audience`` values
101+
appearing in the token's claim authorizes the request.
102+
103+
**Example — AWS Cognito access token.** Cognito access tokens carry no
104+
``aud`` claim but include ``cognito:groups`` (e.g.
105+
``["odoo-admin", "odoo-portal"]``) and ``scope`` (e.g.
106+
``"openid profile odoo/read"``). To restrict a route to clients in the
107+
``odoo-admin`` Cognito group, configure the validator with
108+
``audience_type = Custom``, ``audience_type_custom = cognito:groups``,
109+
and ``audience = odoo-admin``. To restrict by OAuth scope instead,
110+
configure ``audience_type = Scope`` and ``audience = odoo/read``.
111+
76112
If the ``Authorization`` HTTP header is missing, malformed, or contains
77113
an invalid token, the request is rejected with a 401 (Unauthorized)
78114
code, unless the cookie mode is enabled (see below).
@@ -141,6 +177,7 @@ Contributors
141177

142178
- Stéphane Bidoul <stephane.bidoul@acsone.eu>
143179
- Mohamed Alkobrosli <malkobrosly@kencove.com>
180+
- Don Kendall <kendall@donkendall.com>
144181

145182
Maintainers
146183
-----------

auth_jwt/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"name": "Auth JWT",
66
"summary": """
77
JWT bearer token authentication.""",
8-
"version": "18.0.1.0.2",
8+
"version": "18.0.1.1.0",
99
"license": "LGPL-3",
1010
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
1111
"maintainers": ["sbidoul"],

auth_jwt/models/auth_jwt_validator.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,44 @@ class AuthJwtValidator(models.Model):
6565
],
6666
default="RS256",
6767
)
68+
audience_type = fields.Selection(
69+
[
70+
("aud", "Audience"),
71+
("group", "Group"),
72+
("scope", "Scope"),
73+
("custom", "Custom"),
74+
],
75+
required=True,
76+
default="aud",
77+
help=(
78+
"Which JWT payload claim to validate the Audience list against:\n"
79+
"- Audience (default): standard `aud` claim per RFC 7519.\n"
80+
"- Group: matches against the token's `groups` claim. Useful "
81+
"when the IdP exposes group membership but doesn't set `aud` "
82+
"(typical for first-party OAuth2 access tokens).\n"
83+
"- Scope: matches against the `scope` claim (space-separated "
84+
"per OAuth2 RFC 6749 §3.3, or an array).\n"
85+
"- Custom: matches against an arbitrary payload key specified "
86+
"in Custom Audience Type Key (e.g. `cognito:groups`)."
87+
),
88+
)
89+
audience_type_custom = fields.Char(
90+
help=(
91+
"JWT payload key to validate against the Audience list. Only "
92+
"used when Audience Type is Custom. Example: `cognito:groups`, "
93+
"`roles`, `permissions`, `https://example.com/claims/roles`."
94+
),
95+
)
6896
audience = fields.Char(
69-
required=True, help="Comma separated list of audiences, to validate aud."
97+
required=True,
98+
help=(
99+
"Comma-separated values that must intersect with the JWT claim "
100+
"selected by Audience Type. At least one value must be present "
101+
"in the token for the request to be authorized. For Audience "
102+
"type this validates the standard `aud` claim; for other types "
103+
"this is a set-intersection check against the corresponding "
104+
"payload field."
105+
),
70106
)
71107
issuer = fields.Char(required=True, help="To validate iss.")
72108
user_id_strategy = fields.Selection(
@@ -161,7 +197,7 @@ def _get_validator_by_name(self, validator_name):
161197

162198
@tools.ormcache("self.public_key_jwk_uri", "kid")
163199
def _get_key(self, kid):
164-
jwks_client = PyJWKClient(self.public_key_jwk_uri, cache_keys=False)
200+
jwks_client = PyJWKClient(self.public_key_jwk_uri)
165201
return jwks_client.get_signing_key(kid).key
166202

167203
def _encode(self, payload, secret, expire):
@@ -195,20 +231,35 @@ def _decode(self, token, secret=None):
195231
raise UnauthorizedInvalidToken() from e
196232
key = self._get_key(header.get("kid"))
197233
algorithm = self.public_key_algorithm
234+
aud = (self.audience or "").split(",") if self.audience_type == "aud" else None
198235
try:
199236
payload = jwt.decode(
200237
token,
201238
key=key,
202239
algorithms=[algorithm],
203240
options=dict(
204-
require=["exp", "aud", "iss"],
241+
require=["exp", "iss"],
205242
verify_exp=True,
206-
verify_aud=True,
207243
verify_iss=True,
208244
),
209-
audience=self.audience.split(","),
245+
audience=aud,
210246
issuer=self.issuer,
211247
)
248+
payload_key = (
249+
self.audience_type_custom
250+
if self.audience_type == "custom"
251+
else self.audience_type
252+
)
253+
if len((self.audience or "").split(",") or []) > 0:
254+
for key_value in (self.audience or "").split(","):
255+
payload_value = (
256+
payload.get(payload_key)
257+
if isinstance(payload.get(payload_key), list)
258+
else (payload.get(payload_key) or "").split(" ")
259+
)
260+
if key_value in payload_value:
261+
return payload
262+
raise UnauthorizedInvalidToken()
212263
except Exception as e:
213264
_logger.info("Invalid token: %s", e)
214265
raise UnauthorizedInvalidToken() from e

auth_jwt/readme/CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
- Stéphane Bidoul \<<stephane.bidoul@acsone.eu>\>
22
- Mohamed Alkobrosli \<<malkobrosly@kencove.com>\>
3+
- Don Kendall \<<kendall@donkendall.com>\>

auth_jwt/readme/USAGE.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,54 @@ The JWT validator can be configured with the following properties:
1616

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

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

35+
**Audience type — matching non-standard JWT claims.** The `audience`
36+
setting is matched against the standard JWT `aud` claim by default
37+
(RFC 7519). Some identity providers — notably AWS Cognito and several
38+
OAuth2-only IdPs — issue access tokens without an `aud` claim but
39+
expose authorization information under other claims (`cognito:groups`,
40+
`scope`, `roles`). The `audience_type` field controls which claim the
41+
`audience` list is matched against:
42+
43+
- **Audience** (default): standard `aud` claim validation; at least
44+
one configured value must be present in the token's `aud` claim.
45+
- **Group**: validates against the `groups` claim (array or
46+
space-separated string).
47+
- **Scope**: validates against the `scope` claim (space-separated per
48+
OAuth2 RFC 6749 §3.3, or an array).
49+
- **Custom**: validates against the arbitrary payload key configured
50+
in *Custom Audience Type Key* (e.g. `cognito:groups`, `permissions`,
51+
`https://example.com/claims/roles`).
52+
53+
For all non-`aud` types the JWT library's built-in `aud` verification
54+
is skipped (the token has no `aud`) and the match is a set
55+
intersection: any one of the configured `audience` values appearing in
56+
the token's claim authorizes the request.
57+
58+
**Example — AWS Cognito access token.** Cognito access tokens carry
59+
no `aud` claim but include `cognito:groups`
60+
(e.g. `["odoo-admin", "odoo-portal"]`) and `scope`
61+
(e.g. `"openid profile odoo/read"`). To restrict a route to clients
62+
in the `odoo-admin` Cognito group, configure the validator with
63+
`audience_type = Custom`, `audience_type_custom = cognito:groups`,
64+
and `audience = odoo-admin`. To restrict by OAuth scope instead,
65+
configure `audience_type = Scope` and `audience = odoo/read`.
66+
2767
If the `Authorization` HTTP header is missing, malformed, or contains an
2868
invalid token, the request is rejected with a 401 (Unauthorized) code,
2969
unless the cookie mode is enabled (see below).

0 commit comments

Comments
 (0)