11# Copyright (C) 2020 Glodo UK <https://www.glodo.uk/>
2- # Copyright (C) 2010-2016, 2022 XCG Consulting <https://xcg-consulting.fr />
2+ # Copyright (C) 2010-2016, 2022, 2025-2026 XCG SAS <https://orbeet.io />
33# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
44
55import base64
@@ -81,6 +81,7 @@ class AuthSamlProvider(models.Model):
8181 "auth.saml.attribute.mapping" ,
8282 "provider_id" ,
8383 string = "Attribute Mapping" ,
84+ copy = True ,
8485 )
8586 active = fields .Boolean (default = True )
8687 sequence = fields .Integer (index = True )
@@ -136,6 +137,28 @@ class AuthSamlProvider(models.Model):
136137 default = True ,
137138 help = "Whether metadata should be signed or not" ,
138139 )
140+ # User creation fields
141+ create_user = fields .Boolean (
142+ default = False ,
143+ help = "Create user if not found. The login and name will defaults to the SAML "
144+ "user matching attribute. Use the mapping attributes to change the value "
145+ "used. If a deactivated user has a matching saml uid, activate it rather than"
146+ "create a new one." ,
147+ )
148+ create_user_template_id = fields .Many2one (
149+ comodel_name = "res.users" ,
150+ # Template users, like base.default_user, are disabled by default so allow them
151+ domain = "[('active', 'in', (True, False))]" ,
152+ default = lambda self : self .env .ref ("base.default_user" ),
153+ help = "When creating user, this user is used as a template" ,
154+ )
155+ create_user_reactivate = fields .Boolean (
156+ "Reactivate when Creating Users" ,
157+ default = False ,
158+ help = "If a deactivated user has a matching SAML uid when trying to create the "
159+ "user, and this is checked, then the user is reactivated. Otherwise,"
160+ "access is denied." ,
161+ )
139162
140163 @api .model
141164 def _sig_alg_selection (self ):
@@ -256,9 +279,7 @@ def _get_auth_request(self, extra_state=None, url_root=None):
256279 }
257280 state .update (extra_state )
258281
259- sig_alg = ds .SIG_RSA_SHA1
260- if self .sig_alg :
261- sig_alg = getattr (ds , self .sig_alg )
282+ sig_alg = getattr (ds , self .sig_alg )
262283
263284 saml_client = self ._get_client_for_provider (url_root )
264285 reqid , info = saml_client .prepare_for_authenticate (
@@ -272,6 +293,7 @@ def _get_auth_request(self, extra_state=None, url_root=None):
272293 for key , value in info ["headers" ]:
273294 if key == "Location" :
274295 redirect_url = value
296+ break
275297
276298 self ._store_outstanding_request (reqid )
277299
@@ -287,27 +309,15 @@ def _validate_auth_response(self, token: str, base_url: str = None):
287309 saml2 .entity .BINDING_HTTP_POST ,
288310 self ._get_outstanding_requests_dict (),
289311 )
290- matching_value = None
291-
292- if self .matching_attribute == "subject.nameId" :
293- matching_value = response .name_id .text
294- else :
295- attrs = response .get_identity ()
296-
297- for k , v in attrs .items ():
298- if k == self .matching_attribute :
299- matching_value = v
300- break
301-
302- if not matching_value :
303- raise Exception (
304- f"Matching attribute { self .matching_attribute } not found "
305- f"in user attrs: { attrs } "
306- )
307-
308- if matching_value and isinstance (matching_value , list ):
309- matching_value = next (iter (matching_value ), None )
310-
312+ try :
313+ matching_value = self ._get_attribute_value (
314+ response , self .matching_attribute
315+ )
316+ except KeyError :
317+ raise KeyError (
318+ f"Matching attribute { self .matching_attribute } not found "
319+ f"in user attrs: { response .get_identity ()} "
320+ ) from None
311321 if isinstance (matching_value , str ) and self .matching_attribute_to_lower :
312322 matching_value = matching_value .lower ()
313323
@@ -349,24 +359,59 @@ def _metadata_string(self, valid=None, base_url: str = None):
349359 sign = self .sign_metadata ,
350360 )
351361
362+ @staticmethod
363+ def _get_attribute_value (response , attribute_name : str ):
364+ """
365+
366+ :raise: KeyError if attribute is not in the response
367+ :param response:
368+ :param attribute_name:
369+ :return: value of the attribute. if the value is an empty list, return None
370+ otherwise return the first element of the list
371+ """
372+ if attribute_name == "subject.nameId" :
373+ return response .name_id .text
374+ attrs = response .get_identity ()
375+ attribute_value = attrs [attribute_name ]
376+ if isinstance (attribute_value , list ):
377+ attribute_value = next (iter (attribute_value ), None )
378+ return attribute_value
379+
352380 def _hook_validate_auth_response (self , response , matching_value ):
353381 self .ensure_one ()
354382 vals = {}
355- attrs = response .get_identity ()
356383
357384 for attribute in self .attribute_mapping_ids :
358- if attribute .attribute_name not in attrs :
359- _logger .debug (
385+ try :
386+ vals [attribute .field_name ] = self ._get_attribute_value (
387+ response , attribute .attribute_name
388+ )
389+ except KeyError :
390+ _logger .warning (
360391 "SAML attribute '%s' not found in response %s" ,
361392 attribute .attribute_name ,
362- attrs ,
393+ response . get_identity () ,
363394 )
364- continue
365395
366- attribute_value = attrs [attribute .attribute_name ]
367- if isinstance (attribute_value , list ):
368- attribute_value = attribute_value [0 ]
396+ return {"mapped_attrs" : vals }
369397
370- vals [attribute .field_name ] = attribute_value
398+ def _user_copy_defaults (self , validation ):
399+ """
400+ Returns defaults when copying the template user.
371401
372- return {"mapped_attrs" : vals }
402+ Can be overridden with extra information.
403+ :param validation: validation result
404+ :return: a dictionary for copying template user, empty to avoid copying
405+ """
406+ self .ensure_one ()
407+ if not self .create_user :
408+ return {}
409+ saml_uid = validation ["user_id" ]
410+ return {
411+ "name" : saml_uid ,
412+ "login" : saml_uid ,
413+ "active" : True ,
414+ # if signature is not provided by mapped_attrs, it will be computed
415+ # due to call to compute method in calling method.
416+ "signature" : None ,
417+ } | validation .get ("mapped_attrs" , {})
0 commit comments