From 6b9541efbfbfd7d5e08aea7f3ce12a49803b683f Mon Sep 17 00:00:00 2001 From: chaule97 Date: Mon, 22 Dec 2025 11:29:44 +0700 Subject: [PATCH] [ADD] auth_autologin_via_jwt_cookie --- auth_autologin_via_jwt_cookie/README.rst | 72 +++ auth_autologin_via_jwt_cookie/__init__.py | 1 + auth_autologin_via_jwt_cookie/__manifest__.py | 21 + .../models/__init__.py | 5 + .../models/ir_http.py | 182 ++++++++ .../models/res_config_settings.py | 24 + .../readme/DESCRIPTION.rst | 2 + .../static/description/index.html | 417 ++++++++++++++++++ .../views/res_config_settings_view.xml | 58 +++ .../odoo/addons/auth_autologin_via_jwt_cookie | 1 + setup/auth_autologin_via_jwt_cookie/setup.py | 6 + 11 files changed, 789 insertions(+) create mode 100644 auth_autologin_via_jwt_cookie/README.rst create mode 100644 auth_autologin_via_jwt_cookie/__init__.py create mode 100644 auth_autologin_via_jwt_cookie/__manifest__.py create mode 100644 auth_autologin_via_jwt_cookie/models/__init__.py create mode 100644 auth_autologin_via_jwt_cookie/models/ir_http.py create mode 100644 auth_autologin_via_jwt_cookie/models/res_config_settings.py create mode 100644 auth_autologin_via_jwt_cookie/readme/DESCRIPTION.rst create mode 100644 auth_autologin_via_jwt_cookie/static/description/index.html create mode 100644 auth_autologin_via_jwt_cookie/views/res_config_settings_view.xml create mode 120000 setup/auth_autologin_via_jwt_cookie/odoo/addons/auth_autologin_via_jwt_cookie create mode 100644 setup/auth_autologin_via_jwt_cookie/setup.py diff --git a/auth_autologin_via_jwt_cookie/README.rst b/auth_autologin_via_jwt_cookie/README.rst new file mode 100644 index 0000000000..ac8b5dbb5f --- /dev/null +++ b/auth_autologin_via_jwt_cookie/README.rst @@ -0,0 +1,72 @@ +============================= +Auth Autologin via JWT Cookie +============================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:235b6c7853637cc201cf62eb0b9a6cfa257c77d6199a697f0a09c7be9c3afa8b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/16.0/auth_autologin_via_jwt_cookie + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-auth_autologin_via_jwt_cookie + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module automatically authenticates Odoo users using a valid JWT found in a shared browser cookie. +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. + +**Table of contents** + +.. contents:: + :local: + +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 +~~~~~~~ + +* Kencove + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auth_autologin_via_jwt_cookie/__init__.py b/auth_autologin_via_jwt_cookie/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/auth_autologin_via_jwt_cookie/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/auth_autologin_via_jwt_cookie/__manifest__.py b/auth_autologin_via_jwt_cookie/__manifest__.py new file mode 100644 index 0000000000..6f0a4f368f --- /dev/null +++ b/auth_autologin_via_jwt_cookie/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Auth Autologin via JWT Cookie", + "summary": "Auto-authenticate users using a shared JWT cookie", + "version": "16.0.1.0.0", + "category": "Authentication", + "author": "Kencove,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-auth", + "license": "AGPL-3", + "depends": ["base_setup"], + "data": [ + "views/res_config_settings_view.xml", + ], + "installable": True, + "application": False, + "external_dependencies": { + "python": ["pyjwt"], + }, +} diff --git a/auth_autologin_via_jwt_cookie/models/__init__.py b/auth_autologin_via_jwt_cookie/models/__init__.py new file mode 100644 index 0000000000..8828c2e1e1 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import ir_http +from . import res_config_settings diff --git a/auth_autologin_via_jwt_cookie/models/ir_http.py b/auth_autologin_via_jwt_cookie/models/ir_http.py new file mode 100644 index 0000000000..b2aab69e29 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/models/ir_http.py @@ -0,0 +1,182 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +from functools import lru_cache + +import jwt +import requests +from jwt import PyJWKClient +from jwt.exceptions import InvalidTokenError, PyJWTError + +from odoo import models +from odoo.http import request +from odoo.service import security + +_logger = logging.getLogger(__name__) + + +@lru_cache(maxsize=16) +def _get_jwk_client(jwks_url: str) -> PyJWKClient: + """ + Cache a PyJWKClient per JWKS URL (per worker). + PyJWKClient itself caches fetched JWKS keys. + """ + return PyJWKClient(jwks_url) + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + @classmethod + def _authenticate(cls, endpoint): + # If already authenticated, keep default flow + if getattr(request, "session", None) and request.session.uid: + return super()._authenticate(endpoint) + + result = cls._try_autologin_from_jwt_cookie() + + if not result: + return super()._authenticate(endpoint) + + @classmethod + def _try_autologin_from_jwt_cookie(cls): + settings = cls._get_autologin_settings() + if not settings: + return False + + token = cls._get_cookie_token(settings["cookie_name"]) + if not token: + return False + + claims = cls._verify_jwt_with_pyjwt(token, settings["jwks_url"]) + if not claims: + return False + + # Optional hardening: accept only access tokens when claim exists + token_use = claims.get("token_use") + if token_use and token_use != "access": + _logger.info("Skipping autologin: token_use=%s", token_use) + return False + + email = cls._get_email_from_userinfo(settings["userinfo_url"], token) + if not email: + return False + + user = cls._find_user_by_email(email) + if not user: + return False + + cls._force_login(user) + + return True + + @classmethod + def _get_autologin_settings(cls): + icp = request.env["ir.config_parameter"].sudo() + cookie_name = ( + icp.get_param("auth_autologin_via_jwt_cookie.jwt_cookie_name") or "" + ).strip() + jwks_url = ( + icp.get_param("auth_autologin_via_jwt_cookie.jwks_url") or "" + ).strip() + userinfo_url = ( + icp.get_param("auth_autologin_via_jwt_cookie.userinfo_url") or "" + ).strip() + + if not (cookie_name and jwks_url and userinfo_url): + return None + return { + "cookie_name": cookie_name, + "jwks_url": jwks_url, + "userinfo_url": userinfo_url, + } + + @classmethod + def _get_cookie_token(cls, cookie_name: str): + return request.httprequest.cookies.get(cookie_name) + + @classmethod + def _verify_jwt_with_pyjwt(cls, token: str, jwks_url: str): + """ + Verify RS256 token using JWKS URL via PyJWKClient (cached). + Returns claims dict if valid, otherwise None. + """ + try: + header = jwt.get_unverified_header(token) + except PyJWTError as e: + _logger.info("Invalid JWT header: %s", e) + return None + + if header.get("alg") != "RS256": + _logger.info("Skipping autologin: unexpected alg=%s", header.get("alg")) + return None + + if not header.get("kid"): + _logger.info("Skipping autologin: missing kid") + return None + + try: + jwk_client = _get_jwk_client(jwks_url) + signing_key = jwk_client.get_signing_key_from_jwt(token).key + except (requests.RequestException, PyJWTError) as e: + _logger.warning("Unable to fetch/resolve JWKS signing key: %s", e) + return None + + try: + claims = jwt.decode( + token, + signing_key, + algorithms=["RS256"], + options={ + "verify_aud": False, + }, + ) + return claims + except InvalidTokenError as e: + _logger.info("JWT verification failed: %s", e) + return None + + @classmethod + def _get_email_from_userinfo(cls, userinfo_url: str, token: str): + try: + res = requests.get( + userinfo_url, + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + res.raise_for_status() + except requests.RequestException as e: + _logger.warning("Userinfo request failed: %s", e) + return None + + try: + data = res.json() + except ValueError: + _logger.info("Userinfo response is not JSON") + return None + + email = (data.get("email") or "").strip() + return email or None + + @classmethod + def _find_user_by_email(cls, email: str): + user = ( + request.env["res.users"] + .sudo() + .search( + ["|", ("login", "=", email), ("email", "=", email)], + limit=1, + ) + ) + return user if user and user.active else None + + @classmethod + def _force_login(cls, user): + request.update_env(user=user.id) + request.session.uid = user.id + request.session.session_token = security.compute_session_token( + request.session, request.env + ) + + _logger.info("Auto-authenticated user %s via JWT cookie", user.login) diff --git a/auth_autologin_via_jwt_cookie/models/res_config_settings.py b/auth_autologin_via_jwt_cookie/models/res_config_settings.py new file mode 100644 index 0000000000..86a8c86308 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/models/res_config_settings.py @@ -0,0 +1,24 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + auth_autologin_jwt_cookie_name = fields.Char( + string="JWT Cookie Name", + config_parameter="auth_autologin_via_jwt_cookie.jwt_cookie_name", + help="Name of the shared cookie containing the JWT.", + ) + auth_autologin_jwks_url = fields.Char( + string="JWKS URL", + config_parameter="auth_autologin_via_jwt_cookie.jwks_url", + help="JWKS endpoint used to verify JWT signatures.", + ) + auth_autologin_userinfo_url = fields.Char( + string="Userinfo URL", + config_parameter="auth_autologin_via_jwt_cookie.userinfo_url", + help="Endpoint called with the JWT to retrieve the user email.", + ) diff --git a/auth_autologin_via_jwt_cookie/readme/DESCRIPTION.rst b/auth_autologin_via_jwt_cookie/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..8e0f0cebba --- /dev/null +++ b/auth_autologin_via_jwt_cookie/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module automatically authenticates Odoo users using a valid JWT found in a shared browser cookie. +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. diff --git a/auth_autologin_via_jwt_cookie/static/description/index.html b/auth_autologin_via_jwt_cookie/static/description/index.html new file mode 100644 index 0000000000..60bc690564 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/static/description/index.html @@ -0,0 +1,417 @@ + + + + + +Auth Autologin via JWT Cookie + + + + + + diff --git a/auth_autologin_via_jwt_cookie/views/res_config_settings_view.xml b/auth_autologin_via_jwt_cookie/views/res_config_settings_view.xml new file mode 100644 index 0000000000..9fc4fbdd26 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/views/res_config_settings_view.xml @@ -0,0 +1,58 @@ + + + + res.config.settings.view.form.auth.autologin.jwt.cookie + res.config.settings + + +
+

JWT Cookie Autologin

+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ + + diff --git a/setup/auth_autologin_via_jwt_cookie/odoo/addons/auth_autologin_via_jwt_cookie b/setup/auth_autologin_via_jwt_cookie/odoo/addons/auth_autologin_via_jwt_cookie new file mode 120000 index 0000000000..1c4f88a98f --- /dev/null +++ b/setup/auth_autologin_via_jwt_cookie/odoo/addons/auth_autologin_via_jwt_cookie @@ -0,0 +1 @@ +../../../../auth_autologin_via_jwt_cookie \ No newline at end of file diff --git a/setup/auth_autologin_via_jwt_cookie/setup.py b/setup/auth_autologin_via_jwt_cookie/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/auth_autologin_via_jwt_cookie/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)