Skip to content

Commit b979548

Browse files
committed
Merge PR #602 into 15.0
Signed-off-by gurneyalex
2 parents 7299e4d + ad253fb commit b979548

File tree

8 files changed

+217
-15
lines changed

8 files changed

+217
-15
lines changed

auth_saml/README.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ SAML2 Authentication
77
!! This file is generated by oca-gen-addon-readme !!
88
!! changes will be overwritten. !!
99
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10-
!! source digest: sha256:139e220611ae66b5caca3b9586fb543ebc5de97db011e239ec48e389950beba5
10+
!! source digest: sha256:56a6042e204ca8c553db8eb36de4b1ad7ae8e1e9d5abe598a8398f5e17da7e7f
1111
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1212
1313
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
@@ -81,6 +81,10 @@ by using the query parameter ``disable_autoredirect``, as in
8181
``https://example.com/web/login?disable_autoredirect=`` The login is also displayed if
8282
there is an error with SAML login, in order to display any error message.
8383

84+
If you are using Office365 as identity provider, set up the federation metadata document
85+
rather than the document itself. This will allow the module to refresh the document when
86+
needed.
87+
8488
Usage
8589
=====
8690

auth_saml/models/auth_saml_provider.py

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@
99
import tempfile
1010
import urllib.parse
1111

12+
import requests
13+
1214
# dependency name is pysaml2 # pylint: disable=W7936
1315
import saml2
1416
import saml2.xmldsig as ds
1517
from saml2.client import Saml2Client
1618
from saml2.config import Config as Saml2Config
19+
from saml2.sigver import SignatureError
1720

1821
from odoo import api, fields, models
22+
from odoo.exceptions import UserError
1923

2024
_logger = logging.getLogger(__name__)
2125

@@ -42,6 +46,14 @@ class AuthSamlProvider(models.Model):
4246
),
4347
required=True,
4448
)
49+
idp_metadata_url = fields.Char(
50+
string="Identity Provider Metadata URL",
51+
help="Some SAML providers, notably Office365 can have a metadata "
52+
"document which changes over time, and they provide a URL to the "
53+
"document instead. When this field is set, the metadata can be "
54+
"fetched from the provided URL.",
55+
)
56+
4557
sp_baseurl = fields.Text(
4658
string="Override Base URL",
4759
help="""Base URL sent to Odoo with this, rather than automatically
@@ -232,10 +244,19 @@ def _get_config_for_provider(self, base_url: str = None) -> Saml2Config:
232244
"cert_file": self._get_cert_key_path("sp_pem_public"),
233245
"key_file": self._get_cert_key_path("sp_pem_private"),
234246
}
235-
sp_config = Saml2Config()
236-
sp_config.load(settings)
237-
sp_config.allow_unknown_attributes = True
238-
return sp_config
247+
try:
248+
sp_config = Saml2Config()
249+
sp_config.load(settings)
250+
sp_config.allow_unknown_attributes = True
251+
return sp_config
252+
except saml2.SAMLError:
253+
if self.env.context.get("saml2_retry_after_refresh_metadata", False):
254+
raise
255+
# Retry after refresh metadata
256+
self.action_refresh_metadata_from_url()
257+
return self.with_context(
258+
saml2_retry_after_refresh_metatata=1
259+
)._get_config_for_provider(base_url)
239260

240261
def _get_client_for_provider(self, base_url: str = None) -> Saml2Client:
241262
sp_config = self._get_config_for_provider(base_url)
@@ -280,13 +301,26 @@ def _get_auth_request(self, extra_state=None, url_root=None):
280301
def _validate_auth_response(self, token: str, base_url: str = None):
281302
"""return the validation data corresponding to the access token"""
282303
self.ensure_one()
283-
284-
client = self._get_client_for_provider(base_url)
285-
response = client.parse_authn_request_response(
286-
token,
287-
saml2.entity.BINDING_HTTP_POST,
288-
self._get_outstanding_requests_dict(),
289-
)
304+
try:
305+
client = self._get_client_for_provider(base_url)
306+
response = client.parse_authn_request_response(
307+
token,
308+
saml2.entity.BINDING_HTTP_POST,
309+
self._get_outstanding_requests_dict(),
310+
)
311+
except SignatureError:
312+
# we have a metadata url: try to refresh the metadata document
313+
if self.idp_metadata_url:
314+
self.action_refresh_metadata_from_url()
315+
# retry: if it fails again, we let the exception flow
316+
client = self._get_client_for_provider(base_url)
317+
response = client.parse_authn_request_response(
318+
token,
319+
saml2.entity.BINDING_HTTP_POST,
320+
self._get_outstanding_requests_dict(),
321+
)
322+
else:
323+
raise
290324
matching_value = None
291325

292326
if self.matching_attribute == "subject.nameId":
@@ -370,3 +404,38 @@ def _hook_validate_auth_response(self, response, matching_value):
370404
vals[attribute.field_name] = attribute_value
371405

372406
return {"mapped_attrs": vals}
407+
408+
def action_refresh_metadata_from_url(self):
409+
providers = self.search(
410+
[("idp_metadata_url", "ilike", "http%"), ("id", "in", self.ids)]
411+
)
412+
if not providers:
413+
return False
414+
415+
providers_to_update = {}
416+
for provider in providers:
417+
document = requests.get(provider.idp_metadata_url, timeout=5)
418+
if document.status_code != 200:
419+
raise UserError(
420+
f"Unable to download the metadata for {provider.name}: {document.reason}"
421+
)
422+
if document.text != provider.idp_metadata:
423+
providers_to_update[provider.id] = document.text
424+
425+
# lock the records we might update, so that multiple simultaneous login
426+
# attempts will not cause concurrent updates
427+
self.env.cr.execute(
428+
"SELECT id FROM auth_saml_provider WHERE id in %s FOR UPDATE",
429+
(tuple(providers_to_update.keys()),),
430+
)
431+
updated = False
432+
for provider in providers:
433+
if provider.id in providers_to_update:
434+
provider.idp_metadata = providers_to_update[provider.id]
435+
_logger.info(
436+
"Updated metadata for provider %s from %s",
437+
provider.name,
438+
)
439+
updated = True
440+
441+
return updated

auth_saml/readme/CONFIGURE.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ with the highest priority. It is still possible to access the login without redi
1515
by using the query parameter ``disable_autoredirect``, as in
1616
``https://example.com/web/login?disable_autoredirect=`` The login is also displayed if
1717
there is an error with SAML login, in order to display any error message.
18+
19+
If you are using Office365 as identity provider, set up the federation metadata document
20+
rather than the document itself. This will allow the module to refresh the document when
21+
needed.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIID7TCCAtWgAwIBAgIUDBX/LJ1BPZOhb2vrDnwIasyEi+AwDQYJKoZIhvcNAQEL
3+
BQAwgYUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMQ4wDAYDVQQH
4+
DAVQYXJpczEMMAoGA1UECgwDT0NBMQwwCgYDVQQLDANPQ0ExFDASBgNVBAMMC2V4
5+
YW1wbGUuY29tMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4YW1wbGUuY29tMB4XDTIz
6+
MDEwMTExMDAyN1oXDTIzMDEzMTExMDAyN1owgYUxCzAJBgNVBAYTAkFVMRMwEQYD
7+
VQQIDApTb21lLVN0YXRlMQ4wDAYDVQQHDAVQYXJpczEMMAoGA1UECgwDT0NBMQww
8+
CgYDVQQLDANPQ0ExFDASBgNVBAMMC2V4YW1wbGUuY29tMR8wHQYJKoZIhvcNAQkB
9+
FhB0ZXN0QGV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
10+
AQEAvgeLRr1Q9aS/t8ZuC7/pZRHTB6sqamVwXyR7zh0v51yH7xBy9xs4zJWKneRn
11+
OJw46IogYhY+dyNWElbY+Ckcc6z1eJONiHNtOKAy07VtfhisGviRv1kLE56SHGgW
12+
fIXrOuFqj6F1yTfKyLtq2RZBzmbMTNG7z89rO2hqdTWqhyof9OGWtecrM7Ei9PnL
13+
tqULhQyh6n47KnIXfBMLIeQG7d/WyGU5CnO7yhHkS/51E9gI6g5G0VoueBVFErCl
14+
rjo0clMJrFVpanOG2USGgLfPkomSIv9ZL4SreFN27sbhTbkVWxbk7AOCFCQcaBIv
15+
RThpRrA9YRv2dB/X4yIi7UrrPwIDAQABo1MwUTAdBgNVHQ4EFgQU4WFoM/SL6qvT
16+
jV4YUwH3rggBqyIwHwYDVR0jBBgwFoAU4WFoM/SL6qvTjV4YUwH3rggBqyIwDwYD
17+
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYG+CTENdcmiKmyYg/n6H
18+
59RRgaLHSszjsKYdM5Uy/PmwfvPTRuxP38UhF0gWJTsLxWRjI0ejzdy5vzvxpoYl
19+
3pEyYxPzl+jfGA6AUSOfzTT/ksi7SZWak3cqJDFCdnfaCSjYyi+nKz5y6Bm1/fMy
20+
/3zJX92ELIWc8cTUItUIWjlITmxgLhIGr1wYaCinxkaEopGqkX85RYFaWKyYa5ok
21+
8MnoYbPrh/i9EekHUMBMPKWN3tWMMEROTtX9hmxSSTtgdQCahBaOCCU+m8PSNKEc
22+
UA8nSStaolv8t6aOyEb/Kzs7WSbd7v1ovZsy2FYmIRn0eHz8fpMAw2qk7mol6iao
23+
GQ==
24+
-----END CERTIFICATE-----
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+B4tGvVD1pL+3
3+
xm4Lv+llEdMHqypqZXBfJHvOHS/nXIfvEHL3GzjMlYqd5Gc4nDjoiiBiFj53I1YS
4+
Vtj4KRxzrPV4k42Ic204oDLTtW1+GKwa+JG/WQsTnpIcaBZ8hes64WqPoXXJN8rI
5+
u2rZFkHOZsxM0bvPz2s7aGp1NaqHKh/04Za15yszsSL0+cu2pQuFDKHqfjsqchd8
6+
Ewsh5Abt39bIZTkKc7vKEeRL/nUT2AjqDkbRWi54FUUSsKWuOjRyUwmsVWlqc4bZ
7+
RIaAt8+SiZIi/1kvhKt4U3buxuFNuRVbFuTsA4IUJBxoEi9FOGlGsD1hG/Z0H9fj
8+
IiLtSus/AgMBAAECggEBAKuXUFJeHL7TNzMRAMmnT28uOypPiwtr8Z5X6Vtiy6DU
9+
0wIyDj3H3PAPkI2mcvaRSmngYAFyKJGX3N7OgTkElmZ1pWptgn3WDKf3MC4vQ2F7
10+
kd0A20q3cuMSaskvzC5BFvmiFoD/wMYjlP7RDVhdWqqv9IbhVAAAQcnxLUANZ6CH
11+
/xrieGuYavs62pSu5fnke7zRozdD1Mb7/oolAnycaLuoi1eZBh8wW8EJyFSxcZ5A
12+
pYF5kNqbwAdOZ22Tygxwu7lnh8PUOKxf9pTmO6uUYAJcn/Z3ZHtnBYsjU/LkfNPV
13+
hYLu1bKftm6UEZYwCXE3/ygop1q648NvCvtJB+Gbj9ECgYEA8nB+hS+7MLgi/dv8
14+
FCMJ9HBN76/nlwjOCTZIyIhCs5Jc6zJQGiDNLUFM/1mpBKUWWAss3g0dmJq32ish
15+
apsCUxabzWuKi44fDMEterJrGDWquyJK+jNPqfqOORLdMf0edNfZbjUxev7D52Ak
16+
4Ej3Ggy/fENd8QWLK6PZHV5X1MUCgYEAyKiWlawh7l8eBrba8UFQ4n1HiK/2uEud
17+
yQOLceSRmW/xC6ZCiR0ILinrtZWRxqQg+ZSS24hjnHhcdnRw8TRXx22TkTwGfAXW
18+
wKesPrtGJrn0ADuZwPkGewyeHPsisXNSiuGLPcLiOCoNNYgbIWJ2RknM1Xw+2p8C
19+
qYU8Si6l6DMCgYEA20v4ld7sExCszjZ72XcsXQhs5v+Vm9/iByEsSwA+XZJqLHFx
20+
VYEQNvxXeq8OnN37zR4msqDogY6J+XWEH5shSiksO28ofj3LRk1DJzZWeyqoSeem
21+
LJXXXKkAlw3COaJ9NzG8Qt0o6dmjORqVoK8/nTekyfFh+0+JaKsoDFG3XwUCgYBN
22+
tq2Ljj0d+wzAAPXO1kMjVO3tjGj7e53CinLpS2LwkCBFKMFAJVRTvLyjeSgaTNrQ
23+
jrBKAgrCQQNehT5wzJrqjA/JAfxo8EH6H3ZgXVuQCBjuNicYS9ossfhStRj8rPNd
24+
AnlRFDdVFUREZVBMn7u7AT4puJMHTOpVCVsOR/7NbQKBgApyR1WfsxZYi8vzVosQ
25+
jnMIW18lnZN3s6auyEvmpVowx0U0yd9QU3HHX1j8Kfq7D9uERCwBtIfn9fzZrZnu
26+
Xgbi9LMUT1z1jFXxJNgzaNmm0JHW9cD24BWNeQ60uxaRiGGmCyfmgqrGOXSn2R8w
27+
KoWEnnunZ9nehcD9dkWcH5zG
28+
-----END PRIVATE KEY-----

auth_saml/tests/fake_idp.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ def _unpack(self, ver="SAMLResponse"):
105105

106106

107107
class FakeIDP(Server):
108-
def __init__(self, metadatas=None):
109-
settings = CONFIG
108+
def __init__(self, metadatas=None, settings=None):
109+
if settings is None:
110+
settings = CONFIG
110111
if metadatas:
111112
settings.update({"metadata": {"inline": metadatas}})
112113

auth_saml/tests/test_pysaml.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
import base64
33
import html
44
import os
5+
import os.path as osp
6+
from copy import deepcopy
57
from unittest.mock import patch
68

9+
import responses
10+
711
from odoo.exceptions import AccessDenied, UserError, ValidationError
812
from odoo.tests import HttpCase, tagged
913

10-
from .fake_idp import FakeIDP
14+
from .fake_idp import CONFIG, FakeIDP
1115

1216

1317
@tagged("saml", "post_install", "-at_install")
@@ -359,3 +363,56 @@ def test_disallow_user_password_when_changing_settings(self):
359363
self.authenticate(
360364
user="[email protected]", password="NesTNSte9340D720te>/-A"
361365
)
366+
367+
@responses.activate
368+
def test_download_metadata(self):
369+
expected_metadata = self.idp.get_metadata()
370+
responses.add(
371+
responses.GET,
372+
"http://localhost:8000/metadata",
373+
status=200,
374+
content_type="text/xml",
375+
body=expected_metadata,
376+
)
377+
self.saml_provider.idp_metadata_url = "http://localhost:8000/metadata"
378+
self.saml_provider.idp_metadata = ""
379+
self.saml_provider.action_refresh_metadata_from_url()
380+
self.assertEqual(self.saml_provider.idp_metadata, expected_metadata)
381+
382+
@responses.activate
383+
def test_login_with_saml_metadata_empty(self):
384+
self.saml_provider.idp_metadata_url = "http://localhost:8000/metadata"
385+
self.saml_provider.idp_metadata = ""
386+
expected_metadata = self.idp.get_metadata()
387+
responses.add(
388+
responses.GET,
389+
"http://localhost:8000/metadata",
390+
status=200,
391+
content_type="text/xml",
392+
body=expected_metadata,
393+
)
394+
self.test_login_with_saml()
395+
self.assertEqual(self.saml_provider.idp_metadata, expected_metadata)
396+
397+
@responses.activate
398+
def test_login_with_saml_metadata_key_changed(self):
399+
settings = deepcopy(CONFIG)
400+
settings["key_file"] = osp.join(
401+
osp.dirname(__file__), "data", "key_idp_expired.pem"
402+
)
403+
settings["cert"] = osp.join(
404+
osp.dirname(__file__), "data", "key_idp_expired.pem"
405+
)
406+
expired_idp = FakeIDP(settings=settings)
407+
self.saml_provider.idp_metadata = expired_idp.get_metadata()
408+
self.saml_provider.idp_metadata_url = "http://localhost:8000/metadata"
409+
up_to_date_metadata = self.idp.get_metadata()
410+
self.assertNotEqual(self.saml_provider.idp_metadata, up_to_date_metadata)
411+
responses.add(
412+
responses.GET,
413+
"http://localhost:8000/metadata",
414+
status=200,
415+
content_type="text/xml",
416+
body=up_to_date_metadata,
417+
)
418+
self.test_login_with_saml()

auth_saml/views/auth_saml.xml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@
7676
</div>
7777
<group name="idp_settings">
7878
<group string="Identity Provider Settings">
79+
<span>
80+
<label for="idp_metadata_url" />
81+
<div>
82+
<field name="idp_metadata_url" />
83+
<p
84+
class="help small"
85+
>If you provider gives you a URL, use this field preferably</p>
86+
</div>
87+
<button
88+
type="object"
89+
string="Refresh"
90+
name="action_refresh_metadata_from_url"
91+
attrs="{'invisible': [('idp_metadata_url', '=', False)]}"
92+
/>
93+
</span>
7994
<label for="idp_metadata" />
8095
<div>
8196
<field

0 commit comments

Comments
 (0)