diff --git a/galicea_openid_connect/__init__.py b/galicea_openid_connect/__init__.py new file mode 100644 index 0000000..26e263a --- /dev/null +++ b/galicea_openid_connect/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import controllers +from . import models +from . import system_checks diff --git a/galicea_openid_connect/__manifest__.py b/galicea_openid_connect/__manifest__.py new file mode 100644 index 0000000..9b4976d --- /dev/null +++ b/galicea_openid_connect/__manifest__.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Galicea OpenID Connect Provider", + + 'summary': """OpenID Connect Provider and OAuth2 resource server""", + + 'author': "Maciej Wawro", + 'maintainer': "Galicea", + 'website': "http://galicea.pl", + + 'category': 'Technical Settings', + 'version': '10.0.1.0', + + 'depends': ['web', 'galicea_environment_checkup'], + + 'external_dependencies': { + 'python': ['jwcrypto', 'cryptography'] + }, + + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'security/init.yml', + + 'views/views.xml', + 'views/templates.xml' + ], + + 'environment_checkup': { + 'dependencies': { + 'python': [ + { + 'name': 'jwcrypto', + 'install': "pip install 'jwcrypto==0.5.0'" + }, + { + 'name': 'cryptography', + 'version': '>=2.3', + 'install': "pip install 'cryptography>=2.3'" + } + ] + } + }, + + 'images': [ + 'static/description/images/custom_screenshot.png', + 'static/description/images/dependencies_screenshot.png' + ] +} diff --git a/galicea_openid_connect/api.py b/galicea_openid_connect/api.py new file mode 100644 index 0000000..36b7743 --- /dev/null +++ b/galicea_openid_connect/api.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- + +import json +import logging +from functools import wraps + +from odoo import http +import werkzeug + +_logger = logging.getLogger(__name__) + +class ApiException(Exception): + INVALID_REQUEST = 'invalid_request' + + def __init__(self, message, code=None): + super(Exception, self).__init__(message) + self.code = code if code else self.INVALID_REQUEST + +def resource(path, method, auth='user'): + assert auth in ['user', 'client'] + + def endpoint_decorator(func): + @http.route(path, auth='public', type='http', methods=[method], csrf=False) + @wraps(func) + def func_wrapper(self, req, **query): + try: + access_token = None + if 'Authorization' in req.httprequest.headers: + authorization_header = req.httprequest.headers['Authorization'] + if authorization_header[:7] == 'Bearer ': + access_token = authorization_header.split(' ', 1)[1] + if access_token is None: + access_token = query.get('access_token') + if not access_token: + raise ApiException( + 'access_token param is missing', + 'invalid_request', + ) + if auth == 'user': + token = req.env['galicea_openid_connect.access_token'].sudo().search( + [('token', '=', access_token)] + ) + if not token: + raise ApiException( + 'access_token is invalid', + 'invalid_request', + ) + req.uid = token.user_id.id + elif auth == 'client': + token = req.env['galicea_openid_connect.client_access_token'].sudo().search( + [('token', '=', access_token)] + ) + if not token: + raise ApiException( + 'access_token is invalid', + 'invalid_request', + ) + req.uid = token.client_id.system_user_id.id + + ctx = req.context.copy() + ctx.update({'client_id': token.client_id.id}) + req.context = ctx + + response = func(self, req, **query) + return json.dumps(response) + except ApiException as e: + return werkzeug.Response( + response=json.dumps({'error': e.code, 'error_message': e.message}), + status=400, + ) + except: + _logger.exception('Unexpected exception while processing API request') + return werkzeug.Response( + response=json.dumps({ + 'error': 'server_error', + 'error_message': 'Unexpected server error', + }), + status=500, + ) + + return func_wrapper + return endpoint_decorator diff --git a/galicea_openid_connect/controllers/__init__.py b/galicea_openid_connect/controllers/__init__.py new file mode 100644 index 0000000..00fd0df --- /dev/null +++ b/galicea_openid_connect/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import ext_web_login +from . import main diff --git a/galicea_openid_connect/controllers/ext_web_login.py b/galicea_openid_connect/controllers/ext_web_login.py new file mode 100644 index 0000000..60bcde9 --- /dev/null +++ b/galicea_openid_connect/controllers/ext_web_login.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +import time + +from odoo import http +from odoo.addons import web + +class Home(web.controllers.main.Home): + + @http.route('/web/login', type='http', auth="none") + def web_login(self, redirect=None, **kw): + result = super(Home, self).web_login(redirect, **kw) + if result.is_qweb and 'force_auth_and_redirect' in kw: + result.qcontext['redirect'] = kw['force_auth_and_redirect'] + if http.request.params.get('login_success'): + http.request.session['auth_time'] = int(time.time()) + return result diff --git a/galicea_openid_connect/controllers/main.py b/galicea_openid_connect/controllers/main.py new file mode 100644 index 0000000..a7ec142 --- /dev/null +++ b/galicea_openid_connect/controllers/main.py @@ -0,0 +1,355 @@ +# -*- coding: utf-8 -*- + +import json +import time +import os +import base64 + +from odoo import http +import werkzeug + +from .. api import resource + +try: + from jwcrypto import jwk, jwt + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes +except ImportError: + pass + +def jwk_from_json(json_key): + key = jwk.JWK() + key.import_key(**json.loads(json_key)) + return key + +def jwt_encode(claims, key): + token = jwt.JWT( + header={'alg': key._params['alg'], 'kid': key._params['kid']}, + claims=claims + ) + token.make_signed_token(key) + return token.serialize() + +def jwt_decode(serialized, key): + token = jwt.JWT(jwt=serialized, key=key) + return json.loads(token.claims) + +RESPONSE_TYPES_SUPPORTED = [ + 'code', + 'token', + 'id_token token', + 'id_token' +] + +class OAuthException(Exception): + INVALID_REQUEST = 'invalid_request' + INVALID_CLIENT = 'invalid_client' + UNSUPPORTED_RESPONSE_TYPE = 'unsupported_response_type' + INVALID_GRANT = 'invalid_grant' + UNSUPPORTED_GRANT_TYPE = 'unsupported_grant_type' + + def __init__(self, message, type): + super(Exception, self).__init__(message) + self.type = type + +class Main(http.Controller): + def __get_authorization_code_jwk(self, req): + return jwk_from_json(req.env['ir.config_parameter'].sudo().get_param( + 'galicea_openid_connect.authorization_code_jwk' + )) + + def __get_id_token_jwk(self, req): + return jwk_from_json(req.env['ir.config_parameter'].sudo().get_param( + 'galicea_openid_connect.id_token_jwk' + )) + + def __validate_client(self, req, **query): + if 'client_id' not in query: + raise OAuthException( + 'client_id param is missing', + OAuthException.INVALID_CLIENT, + ) + client_id = query['client_id'] + client = req.env['galicea_openid_connect.client'].sudo().search( + [('client_id', '=', client_id)] + ) + if not client: + raise OAuthException( + 'client_id param is invalid', + OAuthException.INVALID_CLIENT, + ) + return client + + def __validate_redirect_uri(self, client, req, **query): + if 'redirect_uri' not in query: + raise OAuthException( + 'redirect_uri param is missing', + OAuthException.INVALID_GRANT, + ) + + redirect_uri = query['redirect_uri'] + if client.auth_redirect_uri != redirect_uri: + raise OAuthException( + 'redirect_uri param doesn\'t match the pre-configured redirect URI', + OAuthException.INVALID_GRANT, + ) + + return redirect_uri + + def __validate_client_secret(self, client, req, **query): + if 'client_secret' not in query or query['client_secret'] != client.secret: + raise OAuthException( + 'client_secret param is not valid', + OAuthException.INVALID_CLIENT, + ) + + @http.route('/.well-known/openid-configuration', auth='public', type='http') + def metadata(self, req, **query): + base_url = http.request.httprequest.host_url + data = { + 'issuer': base_url, + 'authorization_endpoint': base_url + 'oauth/authorize', + 'token_endpoint': base_url + 'oauth/token', + 'userinfo_endpoint': base_url + 'oauth/userinfo', + 'jwks_uri': base_url + 'oauth/jwks', + 'scopes_supported': ['openid'], + 'response_types_supported': RESPONSE_TYPES_SUPPORTED, + 'grant_types_supported': ['authorization_code', 'implicit'], + 'subject_types_supported': ['public'], + 'id_token_signing_alg_values_supported': ['RS256'], + 'token_endpoint_auth_methods_supported': ['client_secret_post'] + } + return json.dumps(data) + + @http.route('/oauth/jwks', auth='public', type='http') + def jwks(self, req, **query): + keyset = jwk.JWKSet() + keyset.add(self.__get_id_token_jwk(req)) + return keyset.export(private_keys=False) + + @resource('/oauth/userinfo', method='GET') + def userinfo(self, req, **query): + user = req.env.user + values = { + 'sub': str(user.id), + # Needed in case the client is another Odoo instance + 'user_id': str(user.id), + 'name': user.name, + } + if user.email: + values['email'] = user.email + return values + + @resource('/oauth/clientinfo', method='GET', auth='client') + def clientinfo(self, req, **query): + client = req.env['galicea_openid_connect.client'].browse(req.context['client_id']) + return { + 'name': client.name + } + + @http.route('/oauth/authorize', auth='public', type='http', csrf=False) + def authorize(self, req, **query): + # First, validate client_id and redirect_uri params. + try: + client = self.__validate_client(req, **query) + redirect_uri = self.__validate_redirect_uri(client, req, **query) + except OAuthException as e: + # If those are not valid, we must not redirect back to the client + # - instead, we display a message to the user + return req.render('galicea_openid_connect.error', {'exception': e}) + + scopes = query['scope'].split(' ') if query.get('scope') else [] + is_openid_request = 'openid' in scopes + + # state, if present, is just mirrored back to the client + response_params = {} + if 'state' in query: + response_params['state'] = query['state'] + + response_mode = query.get('response_mode') + try: + if response_mode and response_mode not in ['query', 'fragment']: + response_mode = None + raise OAuthException( + 'The only supported response_modes are \'query\' and \'fragment\'', + OAuthException.INVALID_REQUEST + ) + + if 'response_type' not in query: + raise OAuthException( + 'response_type param is missing', + OAuthException.INVALID_REQUEST, + ) + response_type = query['response_type'] + if response_type not in RESPONSE_TYPES_SUPPORTED: + raise OAuthException( + 'The only supported response_types are: {}'.format(', '.join(RESPONSE_TYPES_SUPPORTED)), + OAuthException.UNSUPPORTED_RESPONSE_TYPE, + ) + except OAuthException as e: + response_params['error'] = e.type + response_params['error_description'] = e.message + return self.__redirect(redirect_uri, response_params, response_mode or 'query') + + if not response_mode: + response_mode = 'query' if response_type == 'code' else 'fragment' + + user = req.env.user + # In case user is not logged in, we redirect to the login page and come back + needs_login = user.login == 'public' + # Also if they didn't authenticate recently enough + if 'max_age' in query and http.request.session.get('auth_time', 0) + int(query['max_age']) < time.time(): + needs_login = True + if needs_login: + params = { + 'force_auth_and_redirect': '/oauth/authorize?{}'.format(werkzeug.url_encode(query)) + } + return self.__redirect('/web/login', params, 'query') + + response_types = response_type.split() + + extra_claims = { + 'sid': http.request.httprequest.session.sid, + } + if 'nonce' in query: + extra_claims['nonce'] = query['nonce'] + + if 'code' in response_types: + # Generate code that can be used by the client server to retrieve + # the token. It's set to be valid for 60 seconds only. + # TODO: The spec says the code should be single-use. We're not enforcing + # that here. + payload = { + 'redirect_uri': redirect_uri, + 'client_id': client.client_id, + 'user_id': user.id, + 'scopes': scopes, + 'exp': int(time.time()) + 60 + } + payload.update(extra_claims) + key = self.__get_authorization_code_jwk(req) + response_params['code'] = jwt_encode(payload, key) + if 'token' in response_types: + access_token = req.env['galicea_openid_connect.access_token'].sudo().retrieve_or_create( + user.id, + client.id + ).token + response_params['access_token'] = access_token + response_params['token_type'] = 'bearer' + + digest = hashes.Hash(hashes.SHA256(), backend=default_backend()) + digest.update(access_token.encode('ascii')) + at_hash = digest.finalize() + extra_claims['at_hash'] = base64.urlsafe_b64encode(at_hash[:16]).strip('=') + if 'id_token' in response_types: + response_params['id_token'] = self.__create_id_token(req, user.id, client, extra_claims) + + return self.__redirect(redirect_uri, response_params, response_mode) + + @http.route('/oauth/token', auth='public', type='http', methods=['POST'], csrf=False) + def token(self, req, **query): + try: + if 'grant_type' not in query: + raise OAuthException( + 'grant_type param is missing', + OAuthException.INVALID_REQUEST, + ) + if query['grant_type'] == 'authorization_code': + return json.dumps(self.__handle_grant_type_authorization_code(req, **query)) + elif query['grant_type'] == 'client_credentials': + return json.dumps(self.__handle_grant_type_client_credentials(req, **query)) + else: + raise OAuthException( + 'Unsupported grant_type param: \'{}\''.format(query['grant_type']), + OAuthException.UNSUPPORTED_GRANT_TYPE, + ) + except OAuthException as e: + body = json.dumps({'error': e.type, 'error_description': e.message}) + return werkzeug.Response(response=body, status=400) + + def __handle_grant_type_authorization_code(self, req, **query): + client = self.__validate_client(req, **query) + redirect_uri = self.__validate_redirect_uri(client, req, **query) + self.__validate_client_secret(client, req, **query) + + if 'code' not in query: + raise OAuthException( + 'code param is missing', + OAuthException.INVALID_GRANT, + ) + try: + payload = jwt_decode(query['code'], self.__get_authorization_code_jwk(req)) + except jwt.JWTExpired: + raise OAuthException( + 'Code expired', + OAuthException.INVALID_GRANT, + ) + except ValueError: + raise OAuthException( + 'code malformed', + OAuthException.INVALID_GRANT, + ) + if payload['client_id'] != client.client_id: + raise OAuthException( + 'client_id doesn\'t match the authorization request', + OAuthException.INVALID_GRANT, + ) + if payload['redirect_uri'] != redirect_uri: + raise OAuthException( + 'redirect_uri doesn\'t match the authorization request', + OAuthException.INVALID_GRANT, + ) + + # Retrieve/generate access token. We currently only store one per user/client + token = req.env['galicea_openid_connect.access_token'].sudo().retrieve_or_create( + payload['user_id'], + client.id + ) + response = { + 'access_token': token.token, + 'token_type': 'bearer' + } + if 'openid' in payload['scopes']: + extra_claims = { name: payload[name] for name in payload if name in ['sid', 'nonce'] } + response['id_token'] = self.__create_id_token(req, payload['user_id'], client, extra_claims) + return response + + def __handle_grant_type_client_credentials(self, req, **query): + client = self.__validate_client(req, **query) + self.__validate_client_secret(client, req, **query) + token = req.env['galicea_openid_connect.client_access_token'].sudo().retrieve_or_create(client.id) + return { + 'access_token': token.token, + 'token_type': 'bearer' + } + + def __create_id_token(self, req, user_id, client, extra_claims): + claims = { + 'iss': http.request.httprequest.host_url, + 'sub': str(user_id), + 'aud': client.client_id, + 'iat': int(time.time()), + 'exp': int(time.time()) + 15 * 60 + } + auth_time = extra_claims.get('sid') and http.root.session_store.get(extra_claims['sid']).get('auth_time') + if auth_time: + claims['auth_time'] = auth_time + if 'nonce' in extra_claims: + claims['nonce'] = extra_claims['nonce'] + if 'at_hash' in extra_claims: + claims['at_hash'] = extra_claims['at_hash'] + + key = self.__get_id_token_jwk(req) + return jwt_encode(claims, key) + + def __redirect(self, url, params, response_mode): + location = '{}{}{}'.format( + url, + '?' if response_mode == 'query' else '#', + werkzeug.url_encode(params) + ) + return werkzeug.Response( + headers={'Location': location}, + response=None, + status=302, + ) diff --git a/galicea_openid_connect/models/__init__.py b/galicea_openid_connect/models/__init__.py new file mode 100644 index 0000000..c5f0513 --- /dev/null +++ b/galicea_openid_connect/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import client +from . import access_token diff --git a/galicea_openid_connect/models/access_token.py b/galicea_openid_connect/models/access_token.py new file mode 100644 index 0000000..36869e9 --- /dev/null +++ b/galicea_openid_connect/models/access_token.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from .. import random_tokens + +class AccessTokenBase(models.AbstractModel): + _name = 'galicea_openid_connect.access_token_base' + + token = fields.Char( + readonly=True, + required=True, + default=lambda _: random_tokens.alpha_numeric(64), + index=True, + ) + client_id = fields.Many2one( + 'galicea_openid_connect.client', + readonly=True, + index=True, + required=True, + ondelete='cascade' + ) + +class AccessToken(models.Model): + _inherit = 'galicea_openid_connect.access_token_base' + _name = 'galicea_openid_connect.access_token' + _description = 'Acccess token representing user-client pair' + + user_id = fields.Many2one( + 'res.users', + required=True, + readonly=True, + index=True, + ondelete='cascade' + ) + + @api.model + def retrieve_or_create(self, user_id, client_id): + existing_tokens = self.search( + [ + ('user_id', '=', user_id), + ('client_id', '=', client_id), + ] + ) + if existing_tokens: + return existing_tokens[0] + else: + return self.create({'user_id': user_id, 'client_id': client_id}) + +class ClientAccessToken(models.Model): + _inherit = 'galicea_openid_connect.access_token_base' + _name = 'galicea_openid_connect.client_access_token' + _description = 'Access token representing client credentials' + + @api.model + def retrieve_or_create(self, client_id): + existing_tokens = self.search( + [ + ('client_id', '=', client_id), + ] + ) + if existing_tokens: + return existing_tokens[0] + else: + return self.create({'client_id': client_id}) diff --git a/galicea_openid_connect/models/client.py b/galicea_openid_connect/models/client.py new file mode 100644 index 0000000..470c8db --- /dev/null +++ b/galicea_openid_connect/models/client.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api +from .. import random_tokens + +class Client(models.Model): + _name = 'galicea_openid_connect.client' + _description = 'OpenID Connect client' + + name = fields.Char(required=True) + auth_redirect_uri = fields.Char('Redirect URI for user login') + client_id = fields.Char( + string='Client ID', + required=True, + readonly=True, + index=True, + default=lambda _: random_tokens.lower_case(16), + ) + secret = fields.Char( + string='Client secret', + required=True, + readonly=True, + default=lambda _: random_tokens.alpha_numeric(32), + groups='galicea_openid_connect.group_admin' + ) + system_user_id = fields.Many2one( + 'res.users', + 'Artificial user representing the client in client credentials requests', + readonly=True, + required=True, + ondelete='restrict' + ) + + @api.model + def __system_user_name(self, client_name): + return '{} - API system user'.format(client_name) + + @api.model + def create(self, values): + if 'name' in values: + system_user = self.env['res.users'].create({ + 'name': self.__system_user_name(values['name']), + 'login': random_tokens.lower_case(8), + 'groups_id': [(4, self.env.ref('galicea_openid_connect.group_system_user').id)] + }) + # Do not include in the "Pending invitations" list + system_user.sudo(system_user.id)._update_last_login() + values['system_user_id'] = system_user.id + return super(Client, self).create(values) + + @api.multi + def write(selfs, values): + super(Client, selfs).write(values) + if 'name' in values: + selfs.mapped(lambda client: client.system_user_id).write({ + 'name': selfs.__system_user_name(values['name']) + }) + return True + + @api.multi + def unlink(selfs): + users_to_unlink = selfs.mapped(lambda client: client.system_user_id) + ret = super(Client, selfs).unlink() + users_to_unlink.unlink() + return ret diff --git a/galicea_openid_connect/random_tokens.py b/galicea_openid_connect/random_tokens.py new file mode 100644 index 0000000..d448b42 --- /dev/null +++ b/galicea_openid_connect/random_tokens.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +from random import SystemRandom + +def random_token(length, byte_filter): + allowed_bytes = ''.join(c for c in map(chr, range(256)) if byte_filter(c)) + random = SystemRandom() + return ''.join([random.choice(allowed_bytes) for _ in range(length)]) + +def alpha_numeric(length): + return random_token(length, str.isalnum) + +def lower_case(length): + return random_token(length, str.islower) diff --git a/galicea_openid_connect/requirements.txt b/galicea_openid_connect/requirements.txt new file mode 100644 index 0000000..073e997 --- /dev/null +++ b/galicea_openid_connect/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=2.3 +jwcrypto==0.5.0 diff --git a/galicea_openid_connect/security/__init__.py b/galicea_openid_connect/security/__init__.py new file mode 100644 index 0000000..90f35cf --- /dev/null +++ b/galicea_openid_connect/security/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +from .. import random_tokens +try: + from jwcrypto import jwk +except ImportError: + pass + +def init_keys(IrConfigParameter): + keys = { + 'galicea_openid_connect.authorization_code_jwk': lambda: \ + jwk.JWK.generate(kty='oct', size=256, kid=random_tokens.alpha_numeric(16), use='sig', alg='HS256').export(), + 'galicea_openid_connect.id_token_jwk': lambda: \ + jwk.JWK.generate(kty='RSA', size=2054, kid=random_tokens.alpha_numeric(16), use='sig', alg='RS256').export() + } + + for key, gen in keys.iteritems(): + if not IrConfigParameter.search([('key', '=', key)]): + IrConfigParameter.create({ + 'key': key, + 'value': gen(), + 'group_ids': [(4, IrConfigParameter.env.ref('base.group_erp_manager').id)] + }) diff --git a/galicea_openid_connect/security/init.yml b/galicea_openid_connect/security/init.yml new file mode 100644 index 0000000..e827682 --- /dev/null +++ b/galicea_openid_connect/security/init.yml @@ -0,0 +1,4 @@ +- + !python {model: ir.config_parameter}: | + from odoo.addons.galicea_openid_connect.security import init_keys + init_keys(self) diff --git a/galicea_openid_connect/security/ir.model.access.csv b/galicea_openid_connect/security/ir.model.access.csv new file mode 100644 index 0000000..8fa3730 --- /dev/null +++ b/galicea_openid_connect/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_client,client,model_galicea_openid_connect_client,galicea_openid_connect.group_admin,1,1,1,1 +access_client_system_user,client_system_user,model_galicea_openid_connect_client,galicea_openid_connect.group_system_user,1,0,0,0 +access_access_token,access_token,model_galicea_openid_connect_access_token,galicea_openid_connect.group_admin,1,0,0,1 +access_client_access_token,client_access_token,model_galicea_openid_connect_client_access_token,galicea_openid_connect.group_admin,1,0,0,1 diff --git a/galicea_openid_connect/security/security.xml b/galicea_openid_connect/security/security.xml new file mode 100644 index 0000000..e07fa87 --- /dev/null +++ b/galicea_openid_connect/security/security.xml @@ -0,0 +1,35 @@ + + + + OpenID Connect Provider + + + + OpenID Client's system user + + + + + + OpenID Connect Provider Administrator + + + + + + + + + OpenID system users can only see corresponding clients + + + + [('system_user_id', '=', user.id)] + + + + + + + + diff --git a/galicea_openid_connect/static/description/icon.png b/galicea_openid_connect/static/description/icon.png new file mode 100755 index 0000000..88d2f51 Binary files /dev/null and b/galicea_openid_connect/static/description/icon.png differ diff --git a/galicea_openid_connect/static/description/images/client_screenshot.png b/galicea_openid_connect/static/description/images/client_screenshot.png new file mode 100644 index 0000000..d722fe2 Binary files /dev/null and b/galicea_openid_connect/static/description/images/client_screenshot.png differ diff --git a/galicea_openid_connect/static/description/images/login_screenshot.png b/galicea_openid_connect/static/description/images/login_screenshot.png new file mode 100644 index 0000000..98bc735 Binary files /dev/null and b/galicea_openid_connect/static/description/images/login_screenshot.png differ diff --git a/galicea_openid_connect/static/description/images/master_screenshot.png b/galicea_openid_connect/static/description/images/master_screenshot.png new file mode 100644 index 0000000..923c47f Binary files /dev/null and b/galicea_openid_connect/static/description/images/master_screenshot.png differ diff --git a/galicea_openid_connect/static/description/index.html b/galicea_openid_connect/static/description/index.html new file mode 100644 index 0000000..366e1e6 --- /dev/null +++ b/galicea_openid_connect/static/description/index.html @@ -0,0 +1,133 @@ +
+
+
+

Galicea OpenID Connect Provider

+

+ OpenID Connect Provider for Odoo & OAuth2 resource server +

+

+This add-on allows Odoo to become an OpenID Connect Identity Provider (or just OAuth2 authorization server). The supported use-case is to allow several company-owned applications (possibly other Odoo instances) to reuse identities provided by Odoo, by becoming its OpenID Connect Clients. There is no technical reason not to allow third-party clients, but keep in mind that as is, there is no support for custom scopes (other than openid) and no permission is required from the user to share their identity with the client.

+ +

The add-on also provides OAuth2 token validation for use in custom API endpoints. This allows the clients to securely fetch data from Odoo.

+ +

Prerequisites

+
+pip install -r galicea_openid_connect/requirements.txt
+
+

Client configuration

+

+ Simply go to OpenID Connect Provider menu to register a new client. Make sure that the Redirect URI exactly matches redirect_uri parameter your client is going to send. Copy generated Client ID and Client secret to configure your client. +

+

+ You can use OpenID Connect Discovery to set up your client. The discovery document URL will be located at <odoo-base-url>/.well-known/openid-configuration and it looks like this:

+{
+    "authorization_endpoint": "<odoo-base-url>/oauth/authorize",
+    "grant_types_supported": [
+        "authorization_code",
+        "implicit"
+    ],
+    "id_token_signing_alg_values_supported": [
+        "RS256"
+    ],
+    "issuer": "<odoo-base-url>/",
+    "jwks_uri": "<odoo-base-url>/oauth/jwks",
+    "response_types_supported": [
+        "code",
+        "token",
+        "id_token token",
+        "id_token"
+    ],
+    "scopes_supported": [
+        "openid"
+    ],
+    "subject_types_supported": [
+        "public"
+    ],
+    "token_endpoint": "<odoo-base-url>/oauth/token",
+    "token_endpoint_auth_methods_supported": [
+        "client_secret_post"
+    ],
+    "userinfo_endpoint": "<odoo-base-url>/oauth/userinfo"
+}
+
+ +

Configuring other Odoo instance/DB to be the client

+

Let's say that you want to allow users registered in your master.odoo.com Odoo instance to be able to log into client.odoo.com instance, without having to create a separate account.

+ +

To do that, simply install this module on master.odoo.com and add the client, using /auth_oauth/signin as a redirect_uri:

+ + +

Now, in client.odoo.com: +

    +
  • install the auth_oauth add-on,
  • +
  • enable developer mode,
  • +
  • make sure that Allow external users to sign up option is enabled in General settings
  • +
  • add the following OAuth Provider data in the settings:
  • + +

    +Now, the users of client.odoo.com will be able to login using new Login with Master link. + +In case they are already logged into master.odoo.com, all they need to do is to click it. Otherwise, they will be redirected to master.odoo.com to provide their credentials. + +

    Creating JSON APIs with OAuth2 authorization

    +

    Along with the ID token, it's possible for the OpenID Connect Client to request access token, that can be used to authorize access to a custom JSON API.

    +

    You can create such API in a way that is similar to creating regular Odoo controllers:

    +
    +# -*- coding: utf-8 -*-
    +
    +from odoo import http
    +from odoo.addons.galicea_openid_connect.api import resource
    +
    +class ExternalAPI(http.Controller):
    +    @resource('/oauth/userinfo', method='GET')
    +    def userinfo(self, req, **query):
    +        user = req.env.user
    +        return {
    +            'sub': str(user.id),
    +            'name': user.name,
    +            'email': user.email
    +        }
    +
    +(note that this particular endpoint is bundled into galicea_openid_connect add-on). The client can then call this endpoint with either a header that looks like Authorization: Bearer <token> or &access_token=<token> query parameter. +
    +$ curl --header 'Authorization: Bearer 9Dkv2W...gzpz' '<odoo-base-url>/oauth/userinfo'
    +
    +{"email": false, "sub": "1", "name": "Administrator"}
    +
    + +

    API authorized with client credentials tokens

    +It's also possible to create APIs for server-to-server requests from the Client. +
    +# -*- coding: utf-8 -*-
    +
    +from odoo import http
    +from odoo.addons.galicea_openid_connect.api import resource
    +
    +class ExternalAPI(http.Controller):
    +    @resource('/oauth/clientinfo', method='GET', auth='client')
    +    def clientinfo(self, req, **query):
    +        client = req.env['galicea_openid_connect.client'].browse(req.context['client_id'])
    +        return {
    +            'name': client.name
    +        }
    +
    +(note that this particular endpoint is bundled into galicea_openid_connect add-on as well). In order to receive the access token, the client needs to call the /oauth/token endpoint with &grant_type=client_credentials parameter: +
    +$ curl -X POST '<odoo-base-url>/oauth/token?grant_type=client_credentials&client_id=dr...ds&client_secret=DL...gO'
    +
    +{"access_token": "WWy74uJIIRA4bonJHdVUeY3N8Jn2vuMecIfQntLf5FvCj3C3nNJY9tRER0qcoHRw", "token_type": "bearer"}
    +
    +Such token can then be used to access the resource: +
    +$ curl --header 'Authorization: Bearer WWy...coHRw' '<odoo-base-url>/oauth/clientinfo'
    +
    +{"name": "Test Client"}
    +
    +

    Additional notes

    +
      +
    • In order to support OpenID Connect features related to authentication time, this also adds time of the user log-in to Odoo session.
    • +
    • For each client, a special kind of public user ("system user") is created to be impersonated during the server-server API requests.
    • +
    +
+
+
diff --git a/galicea_openid_connect/system_checks.py b/galicea_openid_connect/system_checks.py new file mode 100644 index 0000000..40fdf61 --- /dev/null +++ b/galicea_openid_connect/system_checks.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +from odoo.addons.galicea_environment_checkup import \ + custom_check, CheckWarning, CheckSuccess, CheckFail + +from odoo import http + +@custom_check +def check_single_db(env): + if not http.request: + raise CheckWarning('Could not detect DB settings.') + + dbs = http.db_list(True, http.request.httprequest) + if len(dbs) == 1: + return CheckSuccess('Odoo runs in a single-DB mode.') + + details = ( + '

Odoo runs in a multi-DB mode, which will cause API request routing to fail.

' + '

Run Odoo with --dbfilter or --database flag.

' + ) + return CheckFail( + 'Odoo runs in a multi-DB mode.', + details=details + ) diff --git a/galicea_openid_connect/views/templates.xml b/galicea_openid_connect/views/templates.xml new file mode 100644 index 0000000..facb29a --- /dev/null +++ b/galicea_openid_connect/views/templates.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/galicea_openid_connect/views/views.xml b/galicea_openid_connect/views/views.xml new file mode 100644 index 0000000..519c15c --- /dev/null +++ b/galicea_openid_connect/views/views.xml @@ -0,0 +1,65 @@ + + + + galicea_openid_connect.client + 10 + +
+ + + + + +
+ + + galicea_openid_connect.client + + + + + + + + + + + + + +
+