diff --git a/auth_oidc/__manifest__.py b/auth_oidc/__manifest__.py
index e9186bd6d8..605590cd52 100644
--- a/auth_oidc/__manifest__.py
+++ b/auth_oidc/__manifest__.py
@@ -16,6 +16,10 @@
"summary": "Allow users to login through OpenID Connect Provider",
"external_dependencies": {"python": ["python-jose"]},
"depends": ["auth_oauth"],
- "data": ["views/auth_oauth_provider.xml", "data/auth_oauth_data.xml"],
+ "data": [
+ "security/ir.model.access.csv",
+ "views/auth_oauth_provider.xml",
+ "data/auth_oauth_data.xml",
+ ],
"demo": ["demo/local_keycloak.xml"],
}
diff --git a/auth_oidc/demo/local_keycloak.xml b/auth_oidc/demo/local_keycloak.xml
index 0362764014..641d2bd27a 100644
--- a/auth_oidc/demo/local_keycloak.xml
+++ b/auth_oidc/demo/local_keycloak.xml
@@ -37,4 +37,20 @@
Log in with Microsoft
{'prompt':'select_account'}
+
+
+
+ token['name'] == 'test'
+
+
+
+
+ 'erp_manager' in token['groups']
+
diff --git a/auth_oidc/models/auth_oauth_provider.py b/auth_oidc/models/auth_oauth_provider.py
index d05197da72..898e4d0f16 100644
--- a/auth_oidc/models/auth_oauth_provider.py
+++ b/auth_oidc/models/auth_oauth_provider.py
@@ -2,12 +2,13 @@
# Copyright 2021 ACSONE SA/NV
# License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+import collections
import logging
import secrets
import requests
-from odoo import fields, models, tools
+from odoo import api, exceptions, fields, models, tools
try:
from jose import jwt
@@ -50,6 +51,11 @@ class AuthOauthProvider(models.Model):
help="Additional parameters for the auth link. "
"For example: {'prompt':'select_account'}"
)
+ group_line_ids = fields.One2many(
+ "auth.oauth.provider.group_line",
+ "provider_id",
+ string="Group mappings",
+ )
@tools.ormcache("self.jwks_uri", "kid")
def _get_keys(self, kid):
@@ -108,3 +114,39 @@ def _decode_id_token(self, access_token, id_token, kid):
if error:
raise error
return {}
+
+
+class AuthOauthProviderGroupLine(models.Model):
+ _name = "auth.oauth.provider.group_line"
+ _description = "OAuth mapping between an Odoo group and an expression"
+
+ provider_id = fields.Many2one("auth.oauth.provider", required=True)
+ group_id = fields.Many2one("res.groups", required=True)
+ expression = fields.Char(required=True, help="Variables: user, token")
+
+ @api.constrains("expression")
+ def _check_expression(self):
+ for this in self:
+ try:
+ this._eval_expression(self.env.user, {})
+ except (AttributeError, KeyError, NameError, ValueError) as e:
+ # AttributeError: user object can be accessed via attributes: user.email
+ # KeyError: token is a dict of dicts
+ # NameError: only user and token can be used
+ # ValueError: for inexistant variables or attributes
+ raise exceptions.ValidationError(e) from e
+
+ def _eval_expression(self, user, token):
+ self.ensure_one()
+
+ class Defaultdict2(collections.defaultdict):
+ def __init__(self, *args, **kwargs):
+ super().__init__(Defaultdict2, *args, **kwargs)
+
+ return tools.safe_eval.safe_eval(
+ self.expression,
+ {
+ "user": user,
+ "token": Defaultdict2(token),
+ },
+ )
diff --git a/auth_oidc/models/res_users.py b/auth_oidc/models/res_users.py
index 1684480fa4..3489770cae 100644
--- a/auth_oidc/models/res_users.py
+++ b/auth_oidc/models/res_users.py
@@ -8,6 +8,7 @@
from odoo import api, models
from odoo.exceptions import AccessDenied
+from odoo.fields import Command
from odoo.http import request
_logger = logging.getLogger(__name__)
@@ -64,6 +65,13 @@ def auth_oauth(self, provider, params):
_logger.error("No id_token in response.")
raise AccessDenied()
validation = oauth_provider._parse_id_token(id_token, access_token)
+ if oauth_provider.data_endpoint:
+ data = requests.get(
+ oauth_provider.data_endpoint,
+ headers={"Authorization": "Bearer %s" % access_token},
+ timeout=10,
+ ).json()
+ validation.update(data)
# required check
if "sub" in validation and "user_id" not in validation:
# set user_id for auth_oauth, user_id is not an OpenID Connect standard
@@ -80,3 +88,22 @@ def auth_oauth(self, provider, params):
raise AccessDenied()
# return user credentials
return (self.env.cr.dbname, login, access_token)
+
+ @api.model
+ def _auth_oauth_signin(self, provider, validation, params):
+ login = super()._auth_oauth_signin(provider, validation, params)
+ user = self.search([("login", "=", login)])
+ if user:
+ group_updates = []
+ for group_line in (
+ self.env["auth.oauth.provider"].browse(provider).group_line_ids
+ ):
+ if group_line._eval_expression(user, validation):
+ if group_line.group_id not in user.groups_id:
+ group_updates.append(Command.link(group_line.group_id.id))
+ else:
+ if group_line.group_id in user.groups_id:
+ group_updates.append(Command.unlink(group_line.group_id.id))
+ if group_updates:
+ user.write({"groups_id": group_updates})
+ return login
diff --git a/auth_oidc/security/ir.model.access.csv b/auth_oidc/security/ir.model.access.csv
new file mode 100644
index 0000000000..503e4c7529
--- /dev/null
+++ b/auth_oidc/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_auth_oauth_provider_group_line,auth_oauth_provider,model_auth_oauth_provider_group_line,base.group_system,1,1,1,1
diff --git a/auth_oidc/tests/test_auth_oidc_auth_code.py b/auth_oidc/tests/test_auth_oidc_auth_code.py
index 43a1ec6fff..2fc88f9cdd 100644
--- a/auth_oidc/tests/test_auth_oidc_auth_code.py
+++ b/auth_oidc/tests/test_auth_oidc_auth_code.py
@@ -13,7 +13,8 @@
from jose.utils import long_to_base64
import odoo
-from odoo.exceptions import AccessDenied
+from odoo.exceptions import AccessDenied, ValidationError
+from odoo.fields import Command
from odoo.tests import common
from odoo.addons.website.tools import MockRequest as _MockRequest
@@ -21,6 +22,7 @@
from ..controllers.main import OpenIDLogin
BASE_URL = "http://localhost:%s" % odoo.tools.config["http_port"]
+KEYCLOAK_URL = "http://localhost:8080"
@contextlib.contextmanager
@@ -69,7 +71,7 @@ def _generate_key():
def setUp(self):
super().setUp()
- # search our test provider and bind the demo user to it
+ # search our only test provider
self.provider_rec = self.env["auth.oauth.provider"].search(
[("name", "=", "keycloak:8080 on localhost")]
)
@@ -106,6 +108,7 @@ def test_auth_link(self):
self.assertEqual(params["prompt"], ["select_account"])
def _prepare_login_test_user(self):
+ # bind the demo user to our test provider it
user = self.env.ref("base.user_demo")
user.write({"oauth_provider_id": self.provider_rec.id, "oauth_uid": user.login})
return user
@@ -119,7 +122,7 @@ def _prepare_login_test_responses(
id_token_headers = {"kid": "the_key_id"}
responses.add(
responses.POST,
- "http://localhost:8080/auth/realms/master/protocol/openid-connect/token",
+ KEYCLOAK_URL + "/auth/realms/master/protocol/openid-connect/token",
json={
"access_token": access_token,
"id_token": jwt.encode(
@@ -137,7 +140,7 @@ def _prepare_login_test_responses(
keys = [{"keys": [self.rsa_key_public_pem]}]
responses.add(
responses.GET,
- "http://localhost:8080/auth/realms/master/protocol/openid-connect/certs",
+ KEYCLOAK_URL + "/auth/realms/master/protocol/openid-connect/certs",
json={"keys": keys},
)
@@ -156,6 +159,44 @@ def test_login(self):
self.assertEqual(token, "42")
self.assertEqual(login, user.login)
+ @responses.activate
+ def test_manager_login(self):
+ """Test that login works and assigns the user to a manager group"""
+ user = self._prepare_login_test_user()
+ self._prepare_login_test_responses(
+ id_token_body={"user_id": user.login, "groups": ["erp_manager"]}
+ )
+
+ params = {"state": json.dumps({})}
+ with MockRequest(self.env):
+ db, login, token = self.env["res.users"].auth_oauth(
+ self.provider_rec.id,
+ params,
+ )
+ self.assertTrue(user.has_group("base.group_erp_manager"))
+
+ @responses.activate
+ def test_ex_manager_login(self):
+ """Test that login works and de-assigns the user from a manager group"""
+ user = self._prepare_login_test_user()
+ # Make them a manager
+ user.write(
+ {"groups_id": [Command.link(self.env.ref("base.group_erp_manager").id)]}
+ )
+ self.assertTrue(user.has_group("base.group_erp_manager"))
+
+ self._prepare_login_test_responses(
+ id_token_body={"user_id": user.login, "groups": ["not_erp_manager"]}
+ )
+
+ params = {"state": json.dumps({})}
+ with MockRequest(self.env):
+ db, login, token = self.env["res.users"].auth_oauth(
+ self.provider_rec.id,
+ params,
+ )
+ self.assertFalse(user.has_group("base.group_erp_manager"))
+
@responses.activate
def test_login_without_kid(self):
"""Test that login works when ID Token has no kid in header"""
@@ -317,3 +358,47 @@ def test_login_with_jwk_format(self):
)
self.assertEqual(token, "122/3")
self.assertEqual(login, user.login)
+
+ def test_group_expression_empty_token(self):
+ """Test that group expression with an empty token evaluate correctly"""
+ group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1]
+ group_line.expression = 'token["test"]["test"] == 1'
+ self.assertFalse(group_line._eval_expression(self.env.user, {}))
+
+ def test_group_expressions_with_token(self):
+ """Test that group expression with token with groups evaluate correctly"""
+ group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1]
+
+ group_line.expression = "'group-a' in token['groups']"
+ self.assertFalse(group_line._eval_expression(self.env.user, {}))
+ self.assertTrue(
+ group_line._eval_expression(
+ self.env.user, {"groups": ["group-a", "group-b"]}
+ )
+ )
+ self.assertFalse(
+ group_line._eval_expression(self.env.user, {"groups": ["group-c"]})
+ )
+
+ def test_group_expression_with_inexistant_variable(self):
+ """Test that group expression with inexistant variable fails"""
+ group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1]
+
+ with self.assertRaises(ValidationError):
+ group_line.expression = "inexistant_variable"
+
+ def test_group_expression_with_inexistant_attribute(self):
+ """Test that group expression with inexistant attribute (on user) fails"""
+ group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1]
+
+ with self.assertRaises(ValidationError):
+ group_line.expression = "user.not_an_attribute"
+
+ def test_realistic_group_expression(self):
+ """Test that group expression with inexistant attribute (on user) fails"""
+ group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1]
+
+ group_line.expression = "user.email == token['mail']"
+ self.assertTrue(
+ group_line._eval_expression(self.env.user, {"mail": self.env.user.email})
+ )
diff --git a/auth_oidc/views/auth_oauth_provider.xml b/auth_oidc/views/auth_oauth_provider.xml
index c63f2cef60..b53ba80e6f 100644
--- a/auth_oidc/views/auth_oauth_provider.xml
+++ b/auth_oidc/views/auth_oauth_provider.xml
@@ -22,6 +22,16 @@
+
+
+
+
+
+
+
+
+
+