Skip to content

Commit 2e25683

Browse files
committed
[ADD] auth_autologin_via_jwt_cookie
1 parent 6286d73 commit 2e25683

File tree

11 files changed

+789
-0
lines changed

11 files changed

+789
-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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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+
return False
47+
48+
token = cls._get_cookie_token(settings["cookie_name"])
49+
if not token:
50+
return False
51+
52+
claims = cls._verify_jwt_with_pyjwt(token, settings["jwks_url"])
53+
if not claims:
54+
return False
55+
56+
# Optional hardening: accept only access tokens when claim exists
57+
token_use = claims.get("token_use")
58+
if token_use and token_use != "access":
59+
_logger.info("Skipping autologin: token_use=%s", token_use)
60+
return False
61+
62+
email = cls._get_email_from_userinfo(settings["userinfo_url"], token)
63+
if not email:
64+
return False
65+
66+
user = cls._find_user_by_email(email)
67+
if not user:
68+
return False
69+
70+
cls._force_login(user)
71+
72+
return True
73+
74+
@classmethod
75+
def _get_autologin_settings(cls):
76+
icp = request.env["ir.config_parameter"].sudo()
77+
cookie_name = (
78+
icp.get_param("auth_autologin_via_jwt_cookie.jwt_cookie_name") or ""
79+
).strip()
80+
jwks_url = (
81+
icp.get_param("auth_autologin_via_jwt_cookie.jwks_url") or ""
82+
).strip()
83+
userinfo_url = (
84+
icp.get_param("auth_autologin_via_jwt_cookie.userinfo_url") or ""
85+
).strip()
86+
87+
if not (cookie_name and jwks_url and userinfo_url):
88+
return None
89+
return {
90+
"cookie_name": cookie_name,
91+
"jwks_url": jwks_url,
92+
"userinfo_url": userinfo_url,
93+
}
94+
95+
@classmethod
96+
def _get_cookie_token(cls, cookie_name: str):
97+
return request.httprequest.cookies.get(cookie_name)
98+
99+
@classmethod
100+
def _verify_jwt_with_pyjwt(cls, token: str, jwks_url: str):
101+
"""
102+
Verify RS256 token using JWKS URL via PyJWKClient (cached).
103+
Returns claims dict if valid, otherwise None.
104+
"""
105+
try:
106+
header = jwt.get_unverified_header(token)
107+
except PyJWTError as e:
108+
_logger.info("Invalid JWT header: %s", e)
109+
return None
110+
111+
if header.get("alg") != "RS256":
112+
_logger.info("Skipping autologin: unexpected alg=%s", header.get("alg"))
113+
return None
114+
115+
if not header.get("kid"):
116+
_logger.info("Skipping autologin: missing kid")
117+
return None
118+
119+
try:
120+
jwk_client = _get_jwk_client(jwks_url)
121+
signing_key = jwk_client.get_signing_key_from_jwt(token).key
122+
except (requests.RequestException, PyJWTError) as e:
123+
_logger.warning("Unable to fetch/resolve JWKS signing key: %s", e)
124+
return None
125+
126+
try:
127+
claims = jwt.decode(
128+
token,
129+
signing_key,
130+
algorithms=["RS256"],
131+
options={
132+
"verify_aud": False,
133+
},
134+
)
135+
return claims
136+
except InvalidTokenError as e:
137+
_logger.info("JWT verification failed: %s", e)
138+
return None
139+
140+
@classmethod
141+
def _get_email_from_userinfo(cls, userinfo_url: str, token: str):
142+
try:
143+
res = requests.get(
144+
userinfo_url,
145+
headers={"Authorization": f"Bearer {token}"},
146+
timeout=5,
147+
)
148+
res.raise_for_status()
149+
except requests.RequestException as e:
150+
_logger.warning("Userinfo request failed: %s", e)
151+
return None
152+
153+
try:
154+
data = res.json()
155+
except ValueError:
156+
_logger.info("Userinfo response is not JSON")
157+
return None
158+
159+
email = (data.get("email") or "").strip()
160+
return email or None
161+
162+
@classmethod
163+
def _find_user_by_email(cls, email: str):
164+
user = (
165+
request.env["res.users"]
166+
.sudo()
167+
.search(
168+
["|", ("login", "=", email), ("email", "=", email)],
169+
limit=1,
170+
)
171+
)
172+
return user if user and user.active else None
173+
174+
@classmethod
175+
def _force_login(cls, user):
176+
request.update_env(user=user.id)
177+
request.session.uid = user.id
178+
request.session.session_token = security.compute_session_token(
179+
request.session, request.env
180+
)
181+
182+
_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)