diff --git a/README.md b/README.md index e13933450a..b76c69014c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ addon | version | maintainers | summary [auth_user_case_insensitive](auth_user_case_insensitive/) | 18.0.1.0.0 | | Makes the user login field case insensitive [base_user_empty_password](base_user_empty_password/) | 18.0.1.0.0 | grindtildeath | Allows to empty password of users [base_user_show_email](base_user_show_email/) | 18.0.1.0.0 | | Untangle user login and email -[impersonate_login](impersonate_login/) | 18.0.1.0.0 | Kev-Roche | tools +[impersonate_login](impersonate_login/) | 18.0.1.1.0 | Kev-Roche | tools [password_security](password_security/) | 18.0.1.0.0 | | Allow admin to set password security requirements. [user_log_view](user_log_view/) | 18.0.1.0.0 | trojikman | Allow to see user's actions log [users_ldap_mail](users_ldap_mail/) | 18.0.1.0.0 | joao-p-marques | LDAP mapping for user name and e-mail diff --git a/auth_autologin_via_jwt_cookie/README.rst b/auth_autologin_via_jwt_cookie/README.rst new file mode 100644 index 0000000000..3d2141393b --- /dev/null +++ b/auth_autologin_via_jwt_cookie/README.rst @@ -0,0 +1,75 @@ +============================= +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/18.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-18-0/server-auth-18-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=18.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..1d5df36cd1 --- /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": "18.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..8e9682b381 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/models/ir_http.py @@ -0,0 +1,200 @@ +# 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: + _logger.debug("JWT autologin disabled: missing config parameters") + return False + + token = cls._get_cookie_token(settings["cookie_name"]) + if not token: + _logger.debug( + "JWT autologin skipped: cookie '%s' not found", + settings["cookie_name"], + ) + return False + + claims = cls._verify_jwt_with_pyjwt(token, settings["jwks_url"]) + if not claims: + _logger.debug("JWT autologin failed: token verification returned no 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.debug("Skipping autologin: token_use=%s", token_use) + return False + + email = cls._get_email_from_userinfo(settings["userinfo_url"], token) + if not email: + _logger.debug("JWT autologin failed: email not found in userinfo response") + return False + + user = cls._find_user_by_email(email) + if not user: + _logger.debug( + "JWT autologin failed: no active user found for email=%s", + email, + ) + 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): + _logger.debug( + "JWT autologin config incomplete: cookie_name=%s, jwks_url=%s, " + "userinfo_url=%s", + bool(cookie_name), + bool(jwks_url), + bool(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", "=ilike", email), ("email", "=ilike", 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/pyproject.toml b/auth_autologin_via_jwt_cookie/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/auth_autologin_via_jwt_cookie/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auth_autologin_via_jwt_cookie/readme/DESCRIPTION.md b/auth_autologin_via_jwt_cookie/readme/DESCRIPTION.md new file mode 100644 index 0000000000..cbb8ea9dd5 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +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..cafa99ef38 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/static/description/index.html @@ -0,0 +1,420 @@ + + + + + +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..99e0c0b875 --- /dev/null +++ b/auth_autologin_via_jwt_cookie/views/res_config_settings_view.xml @@ -0,0 +1,39 @@ + + + + res.config.settings.view.form.auth.autologin.jwt.cookie + res.config.settings + + + + + + + + + + + + + + + + + + + + diff --git a/impersonate_login/README.rst b/impersonate_login/README.rst index 66afba1389..2b71b498b5 100644 --- a/impersonate_login/README.rst +++ b/impersonate_login/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ================= Impersonate Login ================= @@ -7,13 +11,13 @@ Impersonate Login !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:1c22b5e85d268d1f7b8b3d64d9d2d2acb8b05a9cb3170a33fd4dcdc64fbdbd61 + !! source digest: sha256:0f4564be316d51d716922597d0fbfc4ba6ee6b58b19243f17fc445dd6d9d3a4c !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |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 +.. |badge2| image:: https://img.shields.io/badge/license-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 diff --git a/impersonate_login/__manifest__.py b/impersonate_login/__manifest__.py index e07a936e97..e7a9ef963f 100644 --- a/impersonate_login/__manifest__.py +++ b/impersonate_login/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Impersonate Login", "summary": "tools", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "category": "Tools", "website": "https://github.com/OCA/server-auth", "author": "Akretion, Odoo Community Association (OCA)", diff --git a/impersonate_login/models/mail_message.py b/impersonate_login/models/mail_message.py index e7bf2fd4dc..6227265ed9 100644 --- a/impersonate_login/models/mail_message.py +++ b/impersonate_login/models/mail_message.py @@ -14,6 +14,7 @@ class Message(models.Model): comodel_name="res.partner", compute="_compute_impersonated_author_id", store=True, + index=True, ) body = fields.Html( diff --git a/impersonate_login/static/description/index.html b/impersonate_login/static/description/index.html index 3aa102e36d..1cf66496f8 100644 --- a/impersonate_login/static/description/index.html +++ b/impersonate_login/static/description/index.html @@ -3,7 +3,7 @@ -Impersonate Login +README.rst -
-

Impersonate Login

+
+ + +Odoo Community Association + +
+

Impersonate Login

-

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

+

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

This module allows one user (for example, a member of the support team) to log in as another user. The impersonation session can be exited by clicking on the button “Back to Original User”.

@@ -400,11 +405,11 @@

Impersonate Login

-

Configuration

+

Configuration

The impersonating user must belong to group “Impersonate Users”.

-

Usage

+

Usage

  1. In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click “Switch Login” to @@ -414,7 +419,7 @@

    Usage

-

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 @@ -422,15 +427,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Akretion
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -455,5 +460,6 @@

Maintainers

+
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, +)