diff --git a/auth_oauth_link_by_email/README.rst b/auth_oauth_link_by_email/README.rst new file mode 100644 index 0000000000..8d01888741 --- /dev/null +++ b/auth_oauth_link_by_email/README.rst @@ -0,0 +1,124 @@ +==================================== +OAuth - Link existing users by email +==================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7b1f947b629d3f0ea59aca3c89d6656b6a1e8ae5c4378ec0650e9ca03cc54e9f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/17.0/auth_oauth_link_by_email + :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-17-0/server-auth-17-0-auth_oauth_link_by_email + :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=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +When installed, this module automatically links existing Odoo users to +an OAuth provider on their first login, by matching the email address +from the OAuth token with the user's login (which is their email in +Odoo). + +This is useful when users already exist in Odoo (created manually or +imported) and you want them to authenticate via an OAuth provider +without having to recreate their accounts. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +No additional installation steps are required beyond installing the +module itself. It depends only on the standard ``auth_oauth`` module. + +Configuration +============= + +No configuration is required. The auto-link feature is active as soon as +the module is installed. + +How it works +------------ + +When a user attempts to log in through an OAuth provider and no Odoo +user is found with a matching ``oauth_uid`` + ``oauth_provider_id``, +this module will: + +1. Extract the ``email`` claim from the OAuth token validation response. +2. Search for an active Odoo user whose ``login`` matches that email. +3. If found, write the ``oauth_provider_id``, ``oauth_uid``, and + ``oauth_access_token`` onto that user record and return their login. +4. Subsequent logins will resolve directly via ``oauth_uid`` as usual. + +If no matching user is found, or the email claim is absent, the standard +``auth_oauth`` flow continues (raising ``AccessDenied`` for unknown +accounts). + +Changelog +========= + +17.0.1.0.0 (2026) +----------------- + +- Initial release. Auto-links existing Odoo users to OAuth providers by + email on first login. The feature is active as soon as the module is + installed. + +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 +------- + +* Heligrafics Fotogrametria S.L. + +Contributors +------------ + +- `Heligrafics `__ + + - Jose Zambudio Bernabeu + +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_oauth_link_by_email/__init__.py b/auth_oauth_link_by_email/__init__.py new file mode 100644 index 0000000000..93ff268207 --- /dev/null +++ b/auth_oauth_link_by_email/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Heligrafics +# License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import models diff --git a/auth_oauth_link_by_email/__manifest__.py b/auth_oauth_link_by_email/__manifest__.py new file mode 100644 index 0000000000..32ae82eab1 --- /dev/null +++ b/auth_oauth_link_by_email/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2026 Heligrafics +# License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "OAuth - Link existing users by email", + "version": "17.0.1.0.0", + "license": "AGPL-3", + "author": "Heligrafics Fotogrametria S.L., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-auth", + "summary": ( + "Automatically link existing Odoo users to an OAuth provider " + "on first login by matching their email address." + ), + "depends": ["auth_oauth"], + "installable": True, +} diff --git a/auth_oauth_link_by_email/i18n/auth_oauth_link_by_email.pot b/auth_oauth_link_by_email/i18n/auth_oauth_link_by_email.pot new file mode 100644 index 0000000000..d59b93d4c4 --- /dev/null +++ b/auth_oauth_link_by_email/i18n/auth_oauth_link_by_email.pot @@ -0,0 +1,14 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * auth_oauth_link_by_email +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/auth_oauth_link_by_email/models/__init__.py b/auth_oauth_link_by_email/models/__init__.py new file mode 100644 index 0000000000..858adbc1aa --- /dev/null +++ b/auth_oauth_link_by_email/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Heligrafics +# License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import res_users diff --git a/auth_oauth_link_by_email/models/res_users.py b/auth_oauth_link_by_email/models/res_users.py new file mode 100644 index 0000000000..6680773d61 --- /dev/null +++ b/auth_oauth_link_by_email/models/res_users.py @@ -0,0 +1,56 @@ +# Copyright 2026 Heligrafics +# License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import api, models + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = "res.users" + + @api.model + def _oauth_link_user_by_email(self, provider, oauth_uid, email, access_token): + user = self.search([("login", "=", email)], limit=1) + if not user: + _logger.warning( + "OAuth link by email: no user found with login=%s, skipping.", + email, + ) + return None + user.write( + { + "oauth_provider_id": provider, + "oauth_uid": oauth_uid, + "oauth_access_token": access_token, + } + ) + _logger.info( + "OAuth link by email: user '%s' linked to provider %s with oauth_uid=%s.", + user.login, + provider, + oauth_uid, + ) + return user + + @api.model + def _auth_oauth_signin(self, provider, validation, params): + oauth_uid = validation["user_id"] + already_linked = self.search( + [ + ("oauth_uid", "=", oauth_uid), + ("oauth_provider_id", "=", provider), + ], + limit=1, + ) + if not already_linked: + email = validation.get("email") + if email: + linked_user = self._oauth_link_user_by_email( + provider, oauth_uid, email, params["access_token"] + ) + if linked_user: + return linked_user.login + return super()._auth_oauth_signin(provider, validation, params) diff --git a/auth_oauth_link_by_email/pyproject.toml b/auth_oauth_link_by_email/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/auth_oauth_link_by_email/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auth_oauth_link_by_email/readme/CONFIGURE.md b/auth_oauth_link_by_email/readme/CONFIGURE.md new file mode 100644 index 0000000000..5812ac843c --- /dev/null +++ b/auth_oauth_link_by_email/readme/CONFIGURE.md @@ -0,0 +1,16 @@ +No configuration is required. The auto-link feature is active as soon as the +module is installed. + +## How it works + +When a user attempts to log in through an OAuth provider and no Odoo user is +found with a matching ``oauth_uid`` + ``oauth_provider_id``, this module will: + +1. Extract the ``email`` claim from the OAuth token validation response. +2. Search for an active Odoo user whose ``login`` matches that email. +3. If found, write the ``oauth_provider_id``, ``oauth_uid``, and + ``oauth_access_token`` onto that user record and return their login. +4. Subsequent logins will resolve directly via ``oauth_uid`` as usual. + +If no matching user is found, or the email claim is absent, the standard +``auth_oauth`` flow continues (raising ``AccessDenied`` for unknown accounts). diff --git a/auth_oauth_link_by_email/readme/CONTRIBUTORS.md b/auth_oauth_link_by_email/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..fbad4f5128 --- /dev/null +++ b/auth_oauth_link_by_email/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* [Heligrafics](https://www.heligrafics.net) + - Jose Zambudio Bernabeu \<\> diff --git a/auth_oauth_link_by_email/readme/DESCRIPTION.md b/auth_oauth_link_by_email/readme/DESCRIPTION.md new file mode 100644 index 0000000000..074cc879b9 --- /dev/null +++ b/auth_oauth_link_by_email/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +When installed, this module automatically links existing Odoo users to an +OAuth provider on their first login, by matching the email address from the +OAuth token with the user's login (which is their email in Odoo). + +This is useful when users already exist in Odoo (created manually or imported) +and you want them to authenticate via an OAuth provider without having to +recreate their accounts. diff --git a/auth_oauth_link_by_email/readme/HISTORY.md b/auth_oauth_link_by_email/readme/HISTORY.md new file mode 100644 index 0000000000..8becd2613c --- /dev/null +++ b/auth_oauth_link_by_email/readme/HISTORY.md @@ -0,0 +1,4 @@ +## 17.0.1.0.0 (2026) + +* Initial release. Auto-links existing Odoo users to OAuth providers by email + on first login. The feature is active as soon as the module is installed. diff --git a/auth_oauth_link_by_email/readme/INSTALL.md b/auth_oauth_link_by_email/readme/INSTALL.md new file mode 100644 index 0000000000..7348c5e7f2 --- /dev/null +++ b/auth_oauth_link_by_email/readme/INSTALL.md @@ -0,0 +1,2 @@ +No additional installation steps are required beyond installing the module +itself. It depends only on the standard ``auth_oauth`` module. diff --git a/auth_oauth_link_by_email/static/description/icon.png b/auth_oauth_link_by_email/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/auth_oauth_link_by_email/static/description/icon.png differ diff --git a/auth_oauth_link_by_email/static/description/index.html b/auth_oauth_link_by_email/static/description/index.html new file mode 100644 index 0000000000..c7cdae006a --- /dev/null +++ b/auth_oauth_link_by_email/static/description/index.html @@ -0,0 +1,478 @@ + + + + + +OAuth - Link existing users by email + + + + + + diff --git a/auth_oauth_link_by_email/tests/__init__.py b/auth_oauth_link_by_email/tests/__init__.py new file mode 100644 index 0000000000..2780ba47a3 --- /dev/null +++ b/auth_oauth_link_by_email/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Heligrafics +# License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import test_auth_oauth_link_by_email diff --git a/auth_oauth_link_by_email/tests/test_auth_oauth_link_by_email.py b/auth_oauth_link_by_email/tests/test_auth_oauth_link_by_email.py new file mode 100644 index 0000000000..0706088ad1 --- /dev/null +++ b/auth_oauth_link_by_email/tests/test_auth_oauth_link_by_email.py @@ -0,0 +1,136 @@ +# Copyright 2026 Heligrafics +# License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.exceptions import AccessDenied +from odoo.tests import common +from odoo.tools import mute_logger + + +class TestOAuthLinkByEmail(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._provider_id = ( + cls.env["auth.oauth.provider"] + .create( + { + "name": "Test OAuth Provider", + "client_id": "test-client", + "body": "Log in with Test OAuth", + "auth_endpoint": "http://example.com/auth", + "validation_endpoint": "http://example.com/userinfo", + "scope": "openid email", + "enabled": True, + } + ) + .id + ) + cls._test_user_id = ( + cls.env["res.users"] + .create( + { + "name": "OAuth Test User", + "login": "oauth.test@example.com", + "email": "oauth.test@example.com", + "groups_id": [(6, 0, [cls.env.ref("base.group_user").id])], + } + ) + .id + ) + + def setUp(self): + super().setUp() + self.provider = self.env["auth.oauth.provider"].browse( + self.__class__._provider_id + ) + self.test_user = self.env["res.users"].browse(self.__class__._test_user_id) + + def _make_validation(self, user_id="sub-uuid-1234", email="oauth.test@example.com"): + return {"user_id": user_id, "email": email} + + def _make_params(self, access_token="test_token"): + return {"access_token": access_token, "state": "{}"} + + def test_link_user_by_email_links_and_returns_user(self): + """Links the user found by email and returns the recordset.""" + oauth_uid = "sub-uuid-9999" + result = self.env["res.users"]._oauth_link_user_by_email( + self.provider.id, oauth_uid, "oauth.test@example.com", "token-abc" + ) + self.assertEqual(result, self.test_user) + self.assertEqual(self.test_user.oauth_uid, oauth_uid) + self.assertEqual(self.test_user.oauth_provider_id, self.provider) + self.assertEqual(self.test_user.oauth_access_token, "token-abc") + + def test_link_user_by_email_no_user_returns_none(self): + """Returns None without raising when no user matches the email.""" + result = self.env["res.users"]._oauth_link_user_by_email( + self.provider.id, "sub-x", "unknown@example.com", "token" + ) + self.assertIsNone(result) + + def test_link_user_by_email_inactive_user_returns_none(self): + """Inactive users are excluded by the default active_test context.""" + self.test_user.active = False + result = self.env["res.users"]._oauth_link_user_by_email( + self.provider.id, "sub-x", "oauth.test@example.com", "token" + ) + self.assertIsNone(result) + + def test_signin_links_and_returns_login_when_enabled(self): + """The user is linked on first login.""" + login = self.env["res.users"]._auth_oauth_signin( + self.provider.id, + self._make_validation(user_id="sub-first-login"), + self._make_params(), + ) + self.assertEqual(login, self.test_user.login) + self.assertEqual(self.test_user.oauth_uid, "sub-first-login") + + def test_signin_subsequent_login_uses_oauth_uid(self): + """After the first link, subsequent logins resolve via oauth_uid directly.""" + self.test_user.write( + { + "oauth_provider_id": self.provider.id, + "oauth_uid": "sub-already-set", + } + ) + login = self.env["res.users"]._auth_oauth_signin( + self.provider.id, + self._make_validation(user_id="sub-already-set"), + self._make_params(), + ) + self.assertEqual(login, self.test_user.login) + + def test_signin_no_email_claim_falls_through(self): + """Without an email claim in the token, auto-link is skipped.""" + ResUsers = self.env["res.users"].with_context(no_user_creation=True) + result = ResUsers._auth_oauth_signin( + self.provider.id, + {"user_id": "sub-no-email"}, + self._make_params(), + ) + self.assertIsNone(result) + self.assertFalse(self.test_user.oauth_uid) + + def test_signin_unknown_email_falls_through(self): + """With an email not present in Odoo, auto-link is skipped.""" + ResUsers = self.env["res.users"].with_context(no_user_creation=True) + result = ResUsers._auth_oauth_signin( + self.provider.id, + self._make_validation(email="nobody@example.com"), + self._make_params(), + ) + self.assertIsNone(result) + self.assertFalse(self.test_user.oauth_uid) + + @mute_logger("odoo.sql_db") + def test_signin_inactive_user_not_linked(self): + """An inactive user is not linked → AccessDenied.""" + self.test_user.active = False + with self.assertRaises(AccessDenied): + self.env["res.users"]._auth_oauth_signin( + self.provider.id, + self._make_validation(), + self._make_params(), + )