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 @@
+
+
+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.
+ 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: 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:
+ 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:Galicea OpenID Connect Provider
+
+ OpenID Connect Provider for Odoo & OAuth2 resource server
+
+Prerequisites
+
+pip install -r galicea_openid_connect/requirements.txt
+
+ Client configuration
+
+{
+ "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
+
+
Creating JSON APIs with OAuth2 authorization
+
+# -*- 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
+
+
+
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 @@ +