Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[17.0][IMP] auth_oidc: add groups' handling #682

Open
wants to merge 10 commits into
base: 17.0
Choose a base branch
from
6 changes: 5 additions & 1 deletion auth_oidc/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}
16 changes: 16 additions & 0 deletions auth_oidc/demo/local_keycloak.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,20 @@
name="jwks_uri"
>http://localhost:8080/auth/realms/master/protocol/openid-connect/certs</field>
</record>
<record
id="local_keycloak_group_line_name_is_test"
model="auth.oauth.provider.group_line"
>
<field name="provider_id" ref="local_keycloak" />
<field name="group_id" ref="base.group_no_one" />
<field name="expression">token['name'] == 'test'</field>
</record>
<record
id="local_keycloak_group_line_erp_manager_in_groups"
model="auth.oauth.provider.group_line"
>
<field name="provider_id" ref="local_keycloak" />
<field name="group_id" ref="base.group_erp_manager" />
<field name="expression">'erp_manager' in token['groups']</field>
</record>
</odoo>
44 changes: 43 additions & 1 deletion auth_oidc/models/auth_oauth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
# Copyright 2021 ACSONE SA/NV <https://acsone.eu>
# 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
Expand Down Expand Up @@ -46,6 +47,11 @@ class AuthOauthProvider(models.Model):
string="Token URL", help="Required for OpenID Connect authorization code flow."
)
jwks_uri = fields.Char(string="JWKS URL", help="Required for OpenID Connect.")
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):
Expand Down Expand Up @@ -104,3 +110,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),
},
)
27 changes: 27 additions & 0 deletions auth_oidc/models/res_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -64,6 +65,13 @@
_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(

Check warning on line 69 in auth_oidc/models/res_users.py

View check run for this annotation

Codecov / codecov/patch

auth_oidc/models/res_users.py#L69

Added line #L69 was not covered by tests
oauth_provider.data_endpoint,
headers={"Authorization": "Bearer %s" % access_token},
timeout=10,
).json()
validation.update(data)

Check warning on line 74 in auth_oidc/models/res_users.py

View check run for this annotation

Codecov / codecov/patch

auth_oidc/models/res_users.py#L74

Added line #L74 was not covered by tests
# 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
Expand All @@ -80,3 +88,22 @@
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
2 changes: 2 additions & 0 deletions auth_oidc/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -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
93 changes: 89 additions & 4 deletions auth_oidc/tests/test_auth_oidc_auth_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
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

from ..controllers.main import OpenIDLogin

BASE_URL = "http://localhost:%s" % odoo.tools.config["http_port"]
KEYCLOAK_URL = "http://localhost:8080"


@contextlib.contextmanager
Expand Down Expand Up @@ -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(
[("client_id", "=", "auth_oidc-test")]
)
Expand Down Expand Up @@ -97,6 +99,7 @@ def test_auth_link(self):
self.assertEqual(params["redirect_uri"], [BASE_URL + "/auth_oauth/signin"])

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
Expand All @@ -110,7 +113,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(
Expand All @@ -128,7 +131,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},
)

Expand All @@ -147,6 +150,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"""
Expand Down Expand Up @@ -308,3 +349,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})
)
10 changes: 10 additions & 0 deletions auth_oidc/views/auth_oauth_provider.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
<field name="token_endpoint" />
<field name="jwks_uri" />
</field>
<xpath expr="//sheet/group[last()]" position="after">
<group name="mappings">
<field name="group_line_ids">
<tree editable="bottom">
<field name="group_id" />
<field name="expression" />
</tree>
</field>
</group>
</xpath>
</field>
</record>
</odoo>
Loading