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
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"],
}
36 changes: 36 additions & 0 deletions auth_oidc/demo/local_keycloak.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,40 @@
name="jwks_uri"
>http://localhost:8080/auth/realms/master/protocol/openid-connect/certs</field>
</record>
<record id="provider_azuread_multi" model="auth.oauth.provider">
<field name="name">Azure AD Multitenant</field>
<field name="flow">id_token_code</field>
<field name="client_id">auth_oidc-test</field>
<field name="enabled">True</field>
<field name="token_map">upn:user_id upn:email</field>
<field
name="auth_endpoint"
>https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize</field>
<field name="scope">profile openid</field>
<field
name="token_endpoint"
>https://login.microsoftonline.com/organizations/oauth2/v2.0/token</field>
<field
name="jwks_uri"
>https://login.microsoftonline.com/organizations/discovery/v2.0/keys</field>
<field name="css_class">fa fa-fw fa-windows</field>
<field name="body">Log in with Microsoft</field>
<field name="auth_link_params">{'prompt':'select_account'}</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>
50 changes: 49 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 @@ -47,6 +48,15 @@ class AuthOauthProvider(models.Model):
)
jwks_uri = fields.Char(string="JWKS URL", help="Required for OpenID Connect.")
end_session_endpoint = fields.Char(string="End Session URL")
auth_link_params = fields.Char(
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):
Expand Down Expand Up @@ -105,3 +115,41 @@ 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):
"""Class keeping yielding defaultdicts"""

def __init__(self, *args, **kwargs):
super().__init__(Defaultdict2, *args, **kwargs)

return tools.safe_eval.safe_eval(
self.expression,
{
"user": user,
"token": Defaultdict2(token),
},
)
43 changes: 43 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 @@ -63,7 +64,30 @@ def auth_oauth(self, provider, params):
if not id_token:
_logger.error("No id_token in response.")
raise AccessDenied()

# Parse the ID token
validation = oauth_provider._parse_id_token(id_token, access_token)

# Use the access_token to fetch the OIDC validation_endpoint
if oauth_provider.validation_endpoint:
response = requests.get(
oauth_provider.validation_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
if response.ok: # nb: could be a successful failure
validation.update(response.json())

# Use the access_token to fetch the OAuth2 data_endpoint
if oauth_provider.data_endpoint:
response = requests.get(
oauth_provider.data_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
if response.ok: # nb: could be a successful failure
validation.update(response.json())

# 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 +104,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
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
Loading