Skip to content

Commit 7453ef1

Browse files
committed
[ADD] auth_autologin_via_jwt_cookie
1 parent 6286d73 commit 7453ef1

File tree

11 files changed

+806
-0
lines changed

11 files changed

+806
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
=============================
2+
Auth Autologin via JWT Cookie
3+
=============================
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:235b6c7853637cc201cf62eb0b9a6cfa257c77d6199a697f0a09c7be9c3afa8b
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
18+
:alt: License: AGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github
20+
:target: https://github.com/OCA/server-auth/tree/16.0/auth_autologin_via_jwt_cookie
21+
:alt: OCA/server-auth
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-auth_autologin_via_jwt_cookie
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
This module automatically authenticates Odoo users using a valid JWT found in a shared browser cookie.
32+
If no Odoo session exists, the JWT is verified via a JWKS endpoint, user information is retrieved from a userinfo endpoint, and the matching user is logged in transparently based on email.
33+
34+
**Table of contents**
35+
36+
.. contents::
37+
:local:
38+
39+
Bug Tracker
40+
===========
41+
42+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-auth/issues>`_.
43+
In case of trouble, please check there if your issue has already been reported.
44+
If you spotted it first, help us to smash it by providing a detailed and welcomed
45+
`feedback <https://github.com/OCA/server-auth/issues/new?body=module:%20auth_autologin_via_jwt_cookie%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
46+
47+
Do not contact contributors directly about support or help with technical issues.
48+
49+
Credits
50+
=======
51+
52+
Authors
53+
~~~~~~~
54+
55+
* Kencove
56+
57+
Maintainers
58+
~~~~~~~~~~~
59+
60+
This module is maintained by the OCA.
61+
62+
.. image:: https://odoo-community.org/logo.png
63+
:alt: Odoo Community Association
64+
:target: https://odoo-community.org
65+
66+
OCA, or the Odoo Community Association, is a nonprofit organization whose
67+
mission is to support the collaborative development of Odoo features and
68+
promote its widespread use.
69+
70+
This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/16.0/auth_autologin_via_jwt_cookie>`_ project on GitHub.
71+
72+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2025 Kencove (https://www.kencove.com/)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
{
5+
"name": "Auth Autologin via JWT Cookie",
6+
"summary": "Auto-authenticate users using a shared JWT cookie",
7+
"version": "16.0.1.0.0",
8+
"category": "Authentication",
9+
"author": "Kencove,Odoo Community Association (OCA)",
10+
"website": "https://github.com/OCA/server-auth",
11+
"license": "AGPL-3",
12+
"depends": ["base_setup"],
13+
"data": [
14+
"views/res_config_settings_view.xml",
15+
],
16+
"installable": True,
17+
"application": False,
18+
"external_dependencies": {
19+
"python": ["pyjwt"],
20+
},
21+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright 2025 Kencove (https://www.kencove.com/)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
from . import ir_http
5+
from . import res_config_settings
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Copyright 2025 Kencove (https://www.kencove.com/)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
import logging
5+
from functools import lru_cache
6+
7+
import jwt
8+
import requests
9+
from jwt import PyJWKClient
10+
from jwt.exceptions import InvalidTokenError, PyJWTError
11+
12+
from odoo import models
13+
from odoo.http import request
14+
from odoo.service import security
15+
16+
_logger = logging.getLogger(__name__)
17+
18+
19+
@lru_cache(maxsize=16)
20+
def _get_jwk_client(jwks_url: str) -> PyJWKClient:
21+
"""
22+
Cache a PyJWKClient per JWKS URL (per worker).
23+
PyJWKClient itself caches fetched JWKS keys.
24+
"""
25+
return PyJWKClient(jwks_url)
26+
27+
28+
class IrHttp(models.AbstractModel):
29+
_inherit = "ir.http"
30+
31+
@classmethod
32+
def _authenticate(cls, endpoint):
33+
# If already authenticated, keep default flow
34+
if getattr(request, "session", None) and request.session.uid:
35+
return super()._authenticate(endpoint)
36+
37+
result = cls._try_autologin_from_jwt_cookie()
38+
39+
if not result:
40+
return super()._authenticate(endpoint)
41+
42+
@classmethod
43+
def _try_autologin_from_jwt_cookie(cls):
44+
settings = cls._get_autologin_settings()
45+
if not settings:
46+
_logger.debug("JWT autologin disabled: missing config parameters")
47+
return False
48+
49+
token = cls._get_cookie_token(settings["cookie_name"])
50+
if not token:
51+
_logger.debug(
52+
"JWT autologin skipped: cookie '%s' not found",
53+
settings["cookie_name"],
54+
)
55+
return False
56+
57+
claims = cls._verify_jwt_with_pyjwt(token, settings["jwks_url"])
58+
if not claims:
59+
_logger.debug("JWT autologin failed: token verification returned no claims")
60+
return False
61+
62+
# Optional hardening: accept only access tokens when claim exists
63+
token_use = claims.get("token_use")
64+
if token_use and token_use != "access":
65+
_logger.debug("Skipping autologin: token_use=%s", token_use)
66+
return False
67+
68+
email = cls._get_email_from_userinfo(settings["userinfo_url"], token)
69+
if not email:
70+
_logger.debug("JWT autologin failed: email not found in userinfo response")
71+
return False
72+
73+
user = cls._find_user_by_email(email)
74+
if not user:
75+
_logger.debug(
76+
"JWT autologin failed: no active user found for email=%s",
77+
email,
78+
)
79+
return False
80+
81+
cls._force_login(user)
82+
83+
return True
84+
85+
@classmethod
86+
def _get_autologin_settings(cls):
87+
icp = request.env["ir.config_parameter"].sudo()
88+
cookie_name = (
89+
icp.get_param("auth_autologin_via_jwt_cookie.jwt_cookie_name") or ""
90+
).strip()
91+
jwks_url = (
92+
icp.get_param("auth_autologin_via_jwt_cookie.jwks_url") or ""
93+
).strip()
94+
userinfo_url = (
95+
icp.get_param("auth_autologin_via_jwt_cookie.userinfo_url") or ""
96+
).strip()
97+
98+
if not (cookie_name and jwks_url and userinfo_url):
99+
_logger.debug(
100+
"JWT autologin config incomplete: cookie_name=%s, jwks_url=%s, userinfo_url=%s",
101+
bool(cookie_name),
102+
bool(jwks_url),
103+
bool(userinfo_url),
104+
)
105+
return None
106+
return {
107+
"cookie_name": cookie_name,
108+
"jwks_url": jwks_url,
109+
"userinfo_url": userinfo_url,
110+
}
111+
112+
@classmethod
113+
def _get_cookie_token(cls, cookie_name: str):
114+
return request.httprequest.cookies.get(cookie_name)
115+
116+
@classmethod
117+
def _verify_jwt_with_pyjwt(cls, token: str, jwks_url: str):
118+
"""
119+
Verify RS256 token using JWKS URL via PyJWKClient (cached).
120+
Returns claims dict if valid, otherwise None.
121+
"""
122+
try:
123+
header = jwt.get_unverified_header(token)
124+
except PyJWTError as e:
125+
_logger.info("Invalid JWT header: %s", e)
126+
return None
127+
128+
if header.get("alg") != "RS256":
129+
_logger.info("Skipping autologin: unexpected alg=%s", header.get("alg"))
130+
return None
131+
132+
if not header.get("kid"):
133+
_logger.info("Skipping autologin: missing kid")
134+
return None
135+
136+
try:
137+
jwk_client = _get_jwk_client(jwks_url)
138+
signing_key = jwk_client.get_signing_key_from_jwt(token).key
139+
except (requests.RequestException, PyJWTError) as e:
140+
_logger.warning("Unable to fetch/resolve JWKS signing key: %s", e)
141+
return None
142+
143+
try:
144+
claims = jwt.decode(
145+
token,
146+
signing_key,
147+
algorithms=["RS256"],
148+
options={
149+
"verify_aud": False,
150+
},
151+
)
152+
return claims
153+
except InvalidTokenError as e:
154+
_logger.info("JWT verification failed: %s", e)
155+
return None
156+
157+
@classmethod
158+
def _get_email_from_userinfo(cls, userinfo_url: str, token: str):
159+
try:
160+
res = requests.get(
161+
userinfo_url,
162+
headers={"Authorization": f"Bearer {token}"},
163+
timeout=5,
164+
)
165+
res.raise_for_status()
166+
except requests.RequestException as e:
167+
_logger.warning("Userinfo request failed: %s", e)
168+
return None
169+
170+
try:
171+
data = res.json()
172+
except ValueError:
173+
_logger.info("Userinfo response is not JSON")
174+
return None
175+
176+
email = (data.get("email") or "").strip()
177+
return email or None
178+
179+
@classmethod
180+
def _find_user_by_email(cls, email: str):
181+
user = (
182+
request.env["res.users"]
183+
.sudo()
184+
.search(
185+
["|", ("login", "=ilike", email), ("email", "=ilike", email)],
186+
limit=1,
187+
)
188+
)
189+
return user if user and user.active else None
190+
191+
@classmethod
192+
def _force_login(cls, user):
193+
request.update_env(user=user.id)
194+
request.session.uid = user.id
195+
request.session.session_token = security.compute_session_token(
196+
request.session, request.env
197+
)
198+
199+
_logger.info("Auto-authenticated user %s via JWT cookie", user.login)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2025 Kencove (https://www.kencove.com/)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
from odoo import fields, models
5+
6+
7+
class ResConfigSettings(models.TransientModel):
8+
_inherit = "res.config.settings"
9+
10+
auth_autologin_jwt_cookie_name = fields.Char(
11+
string="JWT Cookie Name",
12+
config_parameter="auth_autologin_via_jwt_cookie.jwt_cookie_name",
13+
help="Name of the shared cookie containing the JWT.",
14+
)
15+
auth_autologin_jwks_url = fields.Char(
16+
string="JWKS URL",
17+
config_parameter="auth_autologin_via_jwt_cookie.jwks_url",
18+
help="JWKS endpoint used to verify JWT signatures.",
19+
)
20+
auth_autologin_userinfo_url = fields.Char(
21+
string="Userinfo URL",
22+
config_parameter="auth_autologin_via_jwt_cookie.userinfo_url",
23+
help="Endpoint called with the JWT to retrieve the user email.",
24+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
This module automatically authenticates Odoo users using a valid JWT found in a shared browser cookie.
2+
If no Odoo session exists, the JWT is verified via a JWKS endpoint, user information is retrieved from a userinfo endpoint, and the matching user is logged in transparently based on email.

0 commit comments

Comments
 (0)