Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions auth_saml/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: [email protected]

# 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
)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions auth_saml/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
auth_saml_provider,
auth_saml_request,
ir_config_parameter,
res_company,
res_config_settings,
res_users,
res_users_saml,
Expand Down
14 changes: 13 additions & 1 deletion auth_saml/models/auth_saml_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Comment on lines +140 to +145
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make this a company dependent value and not a provider dependant one?

If it is company dependent, it should only be in the configuration panel.

@api.model
def _sig_alg_selection(self):
return [(sig[0], sig[0]) for sig in ds.SIG_ALLOWED_ALG]
Expand Down Expand Up @@ -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,
Comment on lines -222 to +228
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not confident the company is correct at this place, I believe it might be using the superuser company instead of any company.

"authn_requests_signed": self.authn_requests_signed,
"logout_requests_signed": self.logout_requests_signed,
"want_assertions_signed": self.want_assertions_signed,
Expand Down Expand Up @@ -370,3 +376,9 @@ 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
)
13 changes: 13 additions & 0 deletions auth_saml/models/res_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (C) 2010-2016, 2022 XCG Consulting <http://odoo.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",
)
7 changes: 7 additions & 0 deletions auth_saml/models/res_config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ 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 ",
)
8 changes: 8 additions & 0 deletions auth_saml/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
82 changes: 82 additions & 0 deletions auth_saml/tests/test_unsolicited_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Copyright (C) 2010-2016, 2022 XCG Consulting <http://odoo.consulting>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Copyright (C) 2010-2016, 2022 XCG Consulting <http://odoo.consulting>
# Copyright (C) 2010-2016, 2022 XCG SAS <https://orbeet.io/>

No need to keep the copyright unless you copied something from another test.
You should add your name or your company’s.

# 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"))
7 changes: 7 additions & 0 deletions auth_saml/views/res_config_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
id="module_auth_saml"
>
<field name="allow_saml_uid_and_internal_password" />
<!-- Allow IdP-initiated authentication without prior AuthnRequest from SP.
Security Note: Only enable if your IdP requires unsolicited requests
and you trust the IdP to properly validate user sessions. -->
Comment on lines +16 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be a comment, it should be displayed in the configuration page.

<field
name="allow_saml_unsolicited_req"
string="Allow SAML Unsolicited Requests"
/>
</setting>
</xpath>
</field>
Expand Down