From 294bb9ffbf8d4bd07b9ad4b4aa9a8a652c8aa7ac Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:25:25 +0200 Subject: [PATCH 1/9] [FEAT] Added possibility to accept unsolicited SAML requests. Very useful for allowing IdP to initiate --- auth_saml/models/auth_saml_provider.py | 13 +++- auth_saml/models/res_company.py | 13 ++++ auth_saml/models/res_config_settings.py | 9 +++ auth_saml/readme/CONFIGURE.md | 8 ++ auth_saml/tests/test_unsolicited_requests.py | 82 ++++++++++++++++++++ auth_saml/views/res_config_settings.xml | 1 + 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 auth_saml/models/res_company.py create mode 100644 auth_saml/tests/test_unsolicited_requests.py diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index 9b8c2d7294..733ef0f8b1 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -137,6 +137,12 @@ class AuthSamlProvider(models.Model): help="Whether metadata should be signed or not", ) + allow_saml_unsolicited_req = fields.Boolean( + compute="_compute_allow_saml_unsolicited", + string="Allow Unsolicited Requests", + help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP" + ) + @api.model def _sig_alg_selection(self): return [(sig[0], sig[0]) for sig in ds.SIG_ALLOWED_ALG] @@ -219,7 +225,7 @@ def _get_config_for_provider(self, base_url: str = None) -> Saml2Config: (acs_url, saml2.BINDING_HTTP_POST), ], }, - "allow_unsolicited": False, + "allow_unsolicited": self.allow_saml_unsolicited_req, "authn_requests_signed": self.authn_requests_signed, "logout_requests_signed": self.logout_requests_signed, "want_assertions_signed": self.want_assertions_signed, @@ -370,3 +376,8 @@ def _hook_validate_auth_response(self, response, matching_value): vals[attribute.field_name] = attribute_value return {"mapped_attrs": vals} + + def _compute_allow_saml_unsolicited(self): + for record in self: + record.allow_saml_unsolicited_req = self.env.company.allow_saml_unsolicited_req + diff --git a/auth_saml/models/res_company.py b/auth_saml/models/res_company.py new file mode 100644 index 0000000000..faaadfc1c9 --- /dev/null +++ b/auth_saml/models/res_company.py @@ -0,0 +1,13 @@ +# Copyright (C) 2010-2016, 2022 XCG Consulting +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + allow_saml_unsolicited_req = fields.Boolean( + string="Allow SAML Unsolicited Requests", + help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP" + ) diff --git a/auth_saml/models/res_config_settings.py b/auth_saml/models/res_config_settings.py index 36b78d959e..095986d29c 100644 --- a/auth_saml/models/res_config_settings.py +++ b/auth_saml/models/res_config_settings.py @@ -13,3 +13,12 @@ class ResConfigSettings(models.TransientModel): "Allow SAML users to possess an Odoo password (warning: decreases security)", config_parameter=ALLOW_SAML_UID_AND_PASSWORD, ) + + + allow_saml_unsolicited_req = fields.Boolean( + related='company_id.allow_saml_unsolicited_req', + readonly=False, + string="Allow SAML Unsolicited Requests", + help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP" + ) + diff --git a/auth_saml/readme/CONFIGURE.md b/auth_saml/readme/CONFIGURE.md index 68072d142c..54557afa61 100644 --- a/auth_saml/readme/CONFIGURE.md +++ b/auth_saml/readme/CONFIGURE.md @@ -18,3 +18,11 @@ query parameter `disable_autoredirect`, as in `https://example.com/web/login?disable_autoredirect=` The login is also displayed if there is an error with SAML login, in order to display any error message. + +**SAML Unsolicited Requests** + +By default, unsolicited SAML requests (IdP-initiated authentication) are +disabled for security reasons. You can enable them in Settings > Users & +Companies > Settings under the SAML section. When enabled, the Identity +Provider can initiate authentication requests without a prior AuthnRequest +from the Service Provider (Odoo). diff --git a/auth_saml/tests/test_unsolicited_requests.py b/auth_saml/tests/test_unsolicited_requests.py new file mode 100644 index 0000000000..00d0be2f1e --- /dev/null +++ b/auth_saml/tests/test_unsolicited_requests.py @@ -0,0 +1,82 @@ +# Copyright (C) 2010-2016, 2022 XCG Consulting +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import os + +from odoo.tests import TransactionCase + +from .fake_idp import FakeIDP + + +class TestUnsolicitedRequests(TransactionCase): + def setUp(self): + super().setUp() + + with open( + os.path.join(os.path.dirname(__file__), "data", "sp.pem"), + encoding="UTF-8", + ) as file: + sp_pem_public = file.read() + + with open( + os.path.join(os.path.dirname(__file__), "data", "sp.key"), + encoding="UTF-8", + ) as file: + sp_pem_private = file.read() + + self.saml_provider = self.env["auth.saml.provider"].create( + { + "name": "SAML Provider Demo", + "idp_metadata": FakeIDP().get_metadata(), + "sp_pem_public": base64.b64encode(sp_pem_public.encode()), + "sp_pem_private": base64.b64encode(sp_pem_private.encode()), + "body": "Login with Provider", + "active": True, + "sig_alg": "SIG_RSA_SHA1", + "matching_attribute": "mail", + } + ) + + def test_unsolicited_request_setting_default_false(self): + """Test that unsolicited requests are disabled by default""" + # Default company setting should be False + self.assertFalse(self.env.company.allow_saml_unsolicited_req) + + # Provider computed field should reflect company setting + self.assertFalse(self.saml_provider.allow_saml_unsolicited_req) + + def test_unsolicited_request_setting_enabled(self): + """Test enabling unsolicited requests""" + # Enable unsolicited requests for company + self.env.company.allow_saml_unsolicited_req = True + + # Provider computed field should reflect the change + self.saml_provider._compute_allow_saml_unsolicited() + self.assertTrue(self.saml_provider.allow_saml_unsolicited_req) + + def test_saml_config_with_unsolicited_enabled(self): + """Test that SAML configuration includes unsolicited setting""" + # Enable unsolicited requests + self.env.company.allow_saml_unsolicited_req = True + self.saml_provider._compute_allow_saml_unsolicited() + + # Get SAML config + config = self.saml_provider._get_config_for_provider() + + # Check that the config includes the allow_unsolicited setting + sp_config = config.getattr("service", "sp") + self.assertTrue(sp_config.get("allow_unsolicited")) + + def test_saml_config_with_unsolicited_disabled(self): + """Test that SAML configuration respects disabled unsolicited setting""" + # Ensure unsolicited requests are disabled + self.env.company.allow_saml_unsolicited_req = False + self.saml_provider._compute_allow_saml_unsolicited() + + # Get SAML config + config = self.saml_provider._get_config_for_provider() + + # Check that the config does not allow unsolicited requests + sp_config = config.getattr("service", "sp") + self.assertFalse(sp_config.get("allow_unsolicited")) diff --git a/auth_saml/views/res_config_settings.xml b/auth_saml/views/res_config_settings.xml index 7e27733c27..77aa58e7b8 100644 --- a/auth_saml/views/res_config_settings.xml +++ b/auth_saml/views/res_config_settings.xml @@ -13,6 +13,7 @@ id="module_auth_saml" > + From f8b7d509fe050b35fe190b439679b87f62f13b35 Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:36:02 +0200 Subject: [PATCH 2/9] fix: Resolve module loading order and XML formatting issues - Move res_company import before res_config_settings to fix related field dependency - Apply OCA XML formatting standards to res_config_settings.xml --- auth_saml/models/__init__.py | 1 + auth_saml/views/res_config_settings.xml | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/auth_saml/models/__init__.py b/auth_saml/models/__init__.py index b6825a4d6e..7dfa946b0a 100644 --- a/auth_saml/models/__init__.py +++ b/auth_saml/models/__init__.py @@ -5,6 +5,7 @@ auth_saml_provider, auth_saml_request, ir_config_parameter, + res_company, res_config_settings, res_users, res_users_saml, diff --git a/auth_saml/views/res_config_settings.xml b/auth_saml/views/res_config_settings.xml index 77aa58e7b8..a3cb859f30 100644 --- a/auth_saml/views/res_config_settings.xml +++ b/auth_saml/views/res_config_settings.xml @@ -13,7 +13,13 @@ id="module_auth_saml" > - + + From ce7c82ef64a2a0b9942b3973f74aa7a199fbdbbb Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:46:24 +0200 Subject: [PATCH 3/9] Style: Satisfy the linter --- auth_saml/models/auth_saml_provider.py | 8 +++++--- auth_saml/models/res_company.py | 2 +- auth_saml/models/res_config_settings.py | 5 ++--- auth_saml/tests/test_unsolicited_requests.py | 14 +++++++------- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index 733ef0f8b1..4a00dc3cca 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -140,7 +140,7 @@ class AuthSamlProvider(models.Model): allow_saml_unsolicited_req = fields.Boolean( compute="_compute_allow_saml_unsolicited", string="Allow Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP" + help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP", ) @api.model @@ -376,8 +376,10 @@ def _hook_validate_auth_response(self, response, matching_value): vals[attribute.field_name] = attribute_value return {"mapped_attrs": vals} - + def _compute_allow_saml_unsolicited(self): for record in self: - record.allow_saml_unsolicited_req = self.env.company.allow_saml_unsolicited_req + record.allow_saml_unsolicited_req = ( + self.env.company.allow_saml_unsolicited_req + ) diff --git a/auth_saml/models/res_company.py b/auth_saml/models/res_company.py index faaadfc1c9..e02b66b2af 100644 --- a/auth_saml/models/res_company.py +++ b/auth_saml/models/res_company.py @@ -9,5 +9,5 @@ class ResCompany(models.Model): allow_saml_unsolicited_req = fields.Boolean( string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP" + help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP", ) diff --git a/auth_saml/models/res_config_settings.py b/auth_saml/models/res_config_settings.py index 095986d29c..472f59a573 100644 --- a/auth_saml/models/res_config_settings.py +++ b/auth_saml/models/res_config_settings.py @@ -14,11 +14,10 @@ class ResConfigSettings(models.TransientModel): config_parameter=ALLOW_SAML_UID_AND_PASSWORD, ) - allow_saml_unsolicited_req = fields.Boolean( - related='company_id.allow_saml_unsolicited_req', + related="company_id.allow_saml_unsolicited_req", readonly=False, string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP" + help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP", ) diff --git a/auth_saml/tests/test_unsolicited_requests.py b/auth_saml/tests/test_unsolicited_requests.py index 00d0be2f1e..6c15a85ccf 100644 --- a/auth_saml/tests/test_unsolicited_requests.py +++ b/auth_saml/tests/test_unsolicited_requests.py @@ -12,7 +12,7 @@ class TestUnsolicitedRequests(TransactionCase): def setUp(self): super().setUp() - + with open( os.path.join(os.path.dirname(__file__), "data", "sp.pem"), encoding="UTF-8", @@ -42,7 +42,7 @@ def test_unsolicited_request_setting_default_false(self): """Test that unsolicited requests are disabled by default""" # Default company setting should be False self.assertFalse(self.env.company.allow_saml_unsolicited_req) - + # Provider computed field should reflect company setting self.assertFalse(self.saml_provider.allow_saml_unsolicited_req) @@ -50,7 +50,7 @@ def test_unsolicited_request_setting_enabled(self): """Test enabling unsolicited requests""" # Enable unsolicited requests for company self.env.company.allow_saml_unsolicited_req = True - + # Provider computed field should reflect the change self.saml_provider._compute_allow_saml_unsolicited() self.assertTrue(self.saml_provider.allow_saml_unsolicited_req) @@ -60,10 +60,10 @@ def test_saml_config_with_unsolicited_enabled(self): # Enable unsolicited requests self.env.company.allow_saml_unsolicited_req = True self.saml_provider._compute_allow_saml_unsolicited() - + # Get SAML config config = self.saml_provider._get_config_for_provider() - + # Check that the config includes the allow_unsolicited setting sp_config = config.getattr("service", "sp") self.assertTrue(sp_config.get("allow_unsolicited")) @@ -73,10 +73,10 @@ def test_saml_config_with_unsolicited_disabled(self): # Ensure unsolicited requests are disabled self.env.company.allow_saml_unsolicited_req = False self.saml_provider._compute_allow_saml_unsolicited() - + # Get SAML config config = self.saml_provider._get_config_for_provider() - + # Check that the config does not allow unsolicited requests sp_config = config.getattr("service", "sp") self.assertFalse(sp_config.get("allow_unsolicited")) From dabefe5b6e60636e1e4f1d9351f8ad752f21a1df Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:48:46 +0200 Subject: [PATCH 4/9] Style: Satisfy the linter --- auth_saml/models/auth_saml_provider.py | 3 ++- auth_saml/models/res_company.py | 3 ++- auth_saml/models/res_config_settings.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index 4a00dc3cca..5c89ecd37d 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -140,7 +140,8 @@ class AuthSamlProvider(models.Model): allow_saml_unsolicited_req = fields.Boolean( compute="_compute_allow_saml_unsolicited", string="Allow Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP", + help="Allow IdP-initiated authentication requests without prior " + "AuthnRequest from SP", ) @api.model diff --git a/auth_saml/models/res_company.py b/auth_saml/models/res_company.py index e02b66b2af..5209567e5a 100644 --- a/auth_saml/models/res_company.py +++ b/auth_saml/models/res_company.py @@ -9,5 +9,6 @@ class ResCompany(models.Model): allow_saml_unsolicited_req = fields.Boolean( string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP", + help="Allow IdP-initiated authentication requests without prior " + "AuthnRequest from SP", ) diff --git a/auth_saml/models/res_config_settings.py b/auth_saml/models/res_config_settings.py index 472f59a573..ddc76b3608 100644 --- a/auth_saml/models/res_config_settings.py +++ b/auth_saml/models/res_config_settings.py @@ -18,6 +18,7 @@ class ResConfigSettings(models.TransientModel): related="company_id.allow_saml_unsolicited_req", readonly=False, string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior AuthnRequest from SP", + help="Allow IdP-initiated authentication requests without prior " + "AuthnRequest from SP", ) From 6524c63d26073575c3a5918ed167fb8b6b09c890 Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:52:03 +0200 Subject: [PATCH 5/9] style: Too long textss --- auth_saml/models/auth_saml_provider.py | 3 +-- auth_saml/models/res_company.py | 3 +-- auth_saml/models/res_config_settings.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index 5c89ecd37d..4a0db5e9a7 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -140,8 +140,7 @@ class AuthSamlProvider(models.Model): allow_saml_unsolicited_req = fields.Boolean( compute="_compute_allow_saml_unsolicited", string="Allow Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior " - "AuthnRequest from SP", + help="Allow IdP-initiated authentication requests" ) @api.model diff --git a/auth_saml/models/res_company.py b/auth_saml/models/res_company.py index 5209567e5a..6470fbc603 100644 --- a/auth_saml/models/res_company.py +++ b/auth_saml/models/res_company.py @@ -9,6 +9,5 @@ class ResCompany(models.Model): allow_saml_unsolicited_req = fields.Boolean( string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior " - "AuthnRequest from SP", + help="Allow IdP-initiated authentication requests without" ) diff --git a/auth_saml/models/res_config_settings.py b/auth_saml/models/res_config_settings.py index ddc76b3608..6ee5b3ac00 100644 --- a/auth_saml/models/res_config_settings.py +++ b/auth_saml/models/res_config_settings.py @@ -18,7 +18,6 @@ class ResConfigSettings(models.TransientModel): related="company_id.allow_saml_unsolicited_req", readonly=False, string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without prior " - "AuthnRequest from SP", + help="Allow IdP-initiated authentication requests " ) From 68901f5bca1a9ad8186bd7e96f21222848f773b5 Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:55:37 +0200 Subject: [PATCH 6/9] style: remove whitespaces --- auth_saml/models/auth_saml_provider.py | 2 +- auth_saml/models/res_company.py | 2 +- auth_saml/models/res_config_settings.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index 4a0db5e9a7..5c00df40e4 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -140,7 +140,7 @@ class AuthSamlProvider(models.Model): allow_saml_unsolicited_req = fields.Boolean( compute="_compute_allow_saml_unsolicited", string="Allow Unsolicited Requests", - help="Allow IdP-initiated authentication requests" + help="Allow IdP-initiated authentication requests" ) @api.model diff --git a/auth_saml/models/res_company.py b/auth_saml/models/res_company.py index 6470fbc603..d3c67a004e 100644 --- a/auth_saml/models/res_company.py +++ b/auth_saml/models/res_company.py @@ -9,5 +9,5 @@ class ResCompany(models.Model): allow_saml_unsolicited_req = fields.Boolean( string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without" + help="Allow IdP-initiated authentication requests without" ) diff --git a/auth_saml/models/res_config_settings.py b/auth_saml/models/res_config_settings.py index 6ee5b3ac00..139628f789 100644 --- a/auth_saml/models/res_config_settings.py +++ b/auth_saml/models/res_config_settings.py @@ -18,6 +18,6 @@ class ResConfigSettings(models.TransientModel): related="company_id.allow_saml_unsolicited_req", readonly=False, string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests " + help="Allow IdP-initiated authentication requests " ) From a625ed8815f875a4da17e4100321b443e2ee80b0 Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:57:47 +0200 Subject: [PATCH 7/9] style: remove more whitespaces --- auth_saml/models/auth_saml_provider.py | 2 +- auth_saml/models/res_company.py | 2 +- auth_saml/models/res_config_settings.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index 5c00df40e4..b4bf977991 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -140,7 +140,7 @@ class AuthSamlProvider(models.Model): allow_saml_unsolicited_req = fields.Boolean( compute="_compute_allow_saml_unsolicited", string="Allow Unsolicited Requests", - help="Allow IdP-initiated authentication requests" + help="Allow IdP-initiated authentication requests", ) @api.model diff --git a/auth_saml/models/res_company.py b/auth_saml/models/res_company.py index d3c67a004e..2859a4dea4 100644 --- a/auth_saml/models/res_company.py +++ b/auth_saml/models/res_company.py @@ -9,5 +9,5 @@ class ResCompany(models.Model): allow_saml_unsolicited_req = fields.Boolean( string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests without" + help="Allow IdP-initiated authentication requests without", ) diff --git a/auth_saml/models/res_config_settings.py b/auth_saml/models/res_config_settings.py index 139628f789..63dd8e9738 100644 --- a/auth_saml/models/res_config_settings.py +++ b/auth_saml/models/res_config_settings.py @@ -18,6 +18,6 @@ class ResConfigSettings(models.TransientModel): related="company_id.allow_saml_unsolicited_req", readonly=False, string="Allow SAML Unsolicited Requests", - help="Allow IdP-initiated authentication requests " + help="Allow IdP-initiated authentication requests ", ) From 1918d269fcb7e9078f29d9a7e44b3b6b646a6535 Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Mon, 1 Sep 2025 21:59:57 +0200 Subject: [PATCH 8/9] style: remove more whitespaces --- auth_saml/models/auth_saml_provider.py | 1 - auth_saml/models/res_config_settings.py | 1 - 2 files changed, 2 deletions(-) diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index b4bf977991..6f32e46571 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -382,4 +382,3 @@ def _compute_allow_saml_unsolicited(self): record.allow_saml_unsolicited_req = ( self.env.company.allow_saml_unsolicited_req ) - diff --git a/auth_saml/models/res_config_settings.py b/auth_saml/models/res_config_settings.py index 63dd8e9738..145ff56bc3 100644 --- a/auth_saml/models/res_config_settings.py +++ b/auth_saml/models/res_config_settings.py @@ -20,4 +20,3 @@ class ResConfigSettings(models.TransientModel): string="Allow SAML Unsolicited Requests", help="Allow IdP-initiated authentication requests ", ) - From 225c43a8e141b4dfb844223c0a56e2efd8607591 Mon Sep 17 00:00:00 2001 From: Torvald Baade Bringsvor Date: Tue, 7 Oct 2025 18:38:40 +0200 Subject: [PATCH 9/9] Latest greatest --- auth_saml/controllers/main.py | 161 ++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/auth_saml/controllers/main.py b/auth_saml/controllers/main.py index b34f9996e4..13a8caa575 100644 --- a/auth_saml/controllers/main.py +++ b/auth_saml/controllers/main.py @@ -188,6 +188,45 @@ def get_auth_request(self, pid): redirect.autocorrect_location_header = True return redirect + def _extract_user_info_from_saml_response(self, provider_id, saml_response, base_url): + """Extract user information from SAML response for user creation""" + try: + # Simple approach: just extract the NameID which we can see in the logs + # From the logs we can see: Subject NameID: bringsvor@bringsvor.com + + # For now, let's use a simple regex to extract the email from the SAML response + import re + import base64 + + # Decode the SAML response to look for the NameID + try: + decoded_response = base64.b64decode(saml_response).decode('utf-8') + + # Look for NameID pattern + nameid_pattern = r'<[^>]*NameID[^>]*>([^<]+)]*NameID>' + nameid_match = re.search(nameid_pattern, decoded_response) + + if nameid_match: + nameid_value = nameid_match.group(1).strip() + user_info = { + 'login': nameid_value, + 'email': nameid_value if '@' in nameid_value else '', + 'name': nameid_value.split('@')[0] if '@' in nameid_value else nameid_value + } + _logger.info("SAML2: Extracted user info from NameID: %s", user_info) + return user_info + + except Exception as decode_error: + _logger.warning("SAML2: Could not decode SAML response: %s", str(decode_error)) + + # Fallback: return empty info + _logger.warning("SAML2: Could not extract user info from SAML response") + return {} + + except Exception as e: + _logger.exception("Failed to extract user info from SAML response: %s", str(e)) + return {} + @http.route( "/auth_saml/signin", type="http", auth="none", csrf=False, readonly=False ) @@ -253,6 +292,128 @@ def signin(self, **kw): except exceptions.AccessDenied: # saml credentials not valid, user could be on a temporary session + # Try to create user if it doesn't exist + try: + # First, let's see what the validation actually returns + provider_obj = request.env["auth.saml.provider"].sudo().browse(provider) + validation = provider_obj._validate_auth_response(saml_response, request.httprequest.url_root.rstrip("/")) + _logger.info("SAML2: Validation result: %s", validation) + if validation.get("user_id"): + _logger.info("SAML2: Expected SAML UID from validation: %s", validation["user_id"]) + + user_info = self._extract_user_info_from_saml_response( + provider, saml_response, request.httprequest.url_root.rstrip("/") + ) + + if user_info and user_info.get('login'): + # Check if user already exists + existing_user = request.env['res.users'].sudo().search([ + ('login', '=', user_info['login']) + ], limit=1) + + if not existing_user: + # Create new user in activated state (no email verification needed) + company = request.env['res.company'].sudo().search([], limit=1) + if not company: + raise Exception("No company found in database") + + # Create user with context that bypasses signup workflow + new_user = request.env['res.users'].with_user(1).sudo().with_context( + no_reset_password=True, + mail_create_nosubscribe=True, + mail_create_nolog=True + ).create({ + 'name': user_info.get('name', user_info['login']), + 'login': user_info['login'], + 'email': user_info.get('email', user_info['login']), + 'company_id': company.id, + 'company_ids': [(6, 0, [company.id])], + 'groups_id': [(6, 0, [ + request.env.ref('base.group_user').id, + ])], + 'active': True, + }) + + _logger.info("SAML2: Created activated user with company: %s, allowed companies: %s", + company.name, new_user.company_ids.mapped('name')) + + # Create the SAML linking record - this is crucial! + saml_uid = validation.get("user_id", user_info['login']) # Use validation user_id or fallback to email + request.env['res.users.saml'].sudo().create({ + 'user_id': new_user.id, + 'saml_provider_id': provider, + 'saml_uid': saml_uid, + }) + + _logger.info("SAML2: Created new user %s with SAML linking record, SAML UID: %s", new_user.login, saml_uid) + + # Commit the user creation immediately so it's available for authentication + request.env.cr.commit() + _logger.info("SAML2: User creation committed to database") + + else: + # User exists, check if SAML linking record exists + saml_link = request.env['res.users.saml'].sudo().search([ + ('user_id', '=', existing_user.id), + ('saml_provider_id', '=', provider) + ], limit=1) + + # Always recreate the SAML linking record with correct saml_uid + if saml_link: + saml_link.unlink() # Delete existing wrong record + _logger.info("SAML2: Deleted existing SAML linking record for user %s", existing_user.login) + + # Create new SAML linking record with correct saml_uid + saml_uid = validation.get("user_id", user_info['login']) # Use validation user_id or fallback to email + request.env['res.users.saml'].sudo().create({ + 'user_id': existing_user.id, + 'saml_provider_id': provider, + 'saml_uid': saml_uid, + }) + _logger.info("SAML2: Created SAML linking record for existing user %s with SAML UID: %s", existing_user.login, saml_uid) + + # Try authentication again now that SAML linking record exists + try: + credentials = ( + request.env["res.users"] + .with_user(SUPERUSER_ID) + .auth_saml( + provider, + saml_response, + request.httprequest.url_root.rstrip("/"), + ) + ) + + action = state.get("a") + menu = state.get("m") + redirect = ( + werkzeug.urls.url_unquote_plus(state["r"]) if state.get("r") else False + ) + url = "/web" + if redirect: + url = redirect + elif action: + url = f"/#action={action}" + elif menu: + url = f"/#menu_id={menu}" + + credentials_dict = { + "login": credentials[1], + "token": credentials[2], + "type": "saml_token", + } + auth_info = request.session.authenticate(dbname, credentials_dict) + resp = request.redirect(_get_login_redirect_url(auth_info["uid"], url), 303) + resp.autocorrect_location_header = False + return resp + + except exceptions.AccessDenied: + _logger.info("SAML2: Authentication still failed even after creating SAML linking record") + + except Exception as create_error: + _logger.exception("SAML2: Failed to create user - %s", str(create_error)) + + # Fall back to original behavior if user creation fails _logger.info("SAML2: access denied") url = "/web/login?saml_error=expired" redirect = werkzeug.utils.redirect(url, 303)