From ec0c8a7703f2aaead4e0aa82c77206f38e755284 Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Aug 08 2016 11:40:13 +0000 Subject: Split OpenID Connect code This splits the various parts of the OpenID Connect protocol into files with specific implementation paths. Signed-off-by: Patrick Uiterwijk Reviewed-by: Pierre-Yves Chibon --- diff --git a/ipsilon/providers/openidc/api.py b/ipsilon/providers/openidc/api.py new file mode 100644 index 0000000..3f0b279 --- /dev/null +++ b/ipsilon/providers/openidc/api.py @@ -0,0 +1,345 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from ipsilon.util.log import Log +from ipsilon.providers.common import ProviderPageBase +from ipsilon.util.security import constant_time_string_comparison + +from jwcrypto.jwt import JWT +import base64 +import cherrypy +import time +import json + + +class APIError(cherrypy.HTTPError, Log): + + def __init__(self, code, error, description=None): + super(APIError, self).__init__(code, error) + self.debug('OpenIDC API error: %s, desc: %s' + % (error, description)) + response = {'error': error} + if description: + response['error_description'] = description + self._error_response = json.dumps(response) + cherrypy.response.headers.update({ + 'Content-Type': 'application/json' + }) + + def get_error_page(self, *args, **kwargs): + return self._error_response + + +class APIRequest(ProviderPageBase): + # Bearer token (RFC 6750) and Client Auth + authenticate_client = False + authenticate_token = False + requires_client_auth = False + requires_valid_token = False + required_scope = None + + def __init__(self, site, provider, *args, **kwargs): + super(APIRequest, self).__init__(site, provider) + self.api_client_id = None + self.api_valid_token = False + self.api_username = None + self.api_scopes = [] + self.api_client_authenticated = False + self.api_client_id = None + self.api_token = None + self.api_client = None + + def pre_GET(self, *args, **kwargs): + # Note that we explicitly do NOT support URI Query parameter posting + # of bearer tokens (RFC6750, section 2.3 marks this as MAY) + self._preop() + + def pre_POST(self, *args, **kwargs): + self._preop(*args, **kwargs) + + def _preop(self, *args, **kwargs): + cherrypy.response.headers.update({ + 'Content-Type': 'application/json' + }) + + self.api_client_id = None + self.api_valid_token = False + self.api_username = None + self.api_scopes = [] + self.api_client_authenticated = False + self.api_client_id = None + self.api_token = None + self.api_client = None + + if self.authenticate_client: + self._authenticate_client(kwargs) + if self.requires_client_auth and not self.api_client_id: + raise APIError(400, 'invalid_client', + 'client authentication required') + + if self.authenticate_token: + self._authenticate_token(kwargs) + + def _respond(self, response): + return json.dumps(response) + + def _respond_error(self, error, message): + return self._respond({'error': error, + 'error_description': message}) + + def _handle_client_authentication(self, auth_method, client_id, + client_secret): + self.debug('Trying client auth for %s with method %s' + % (client_id, auth_method)) + if not client_id: + self.error('Client authentication without client_id') + raise APIError(400, 'invalid_client', + 'client authentication error') + + client = self.cfg.datastore.getClient(client_id) + if not client: + self.error('Client authentication with invalid client ID') + raise APIError(400, 'invalid_client', + 'client authentication error') + + if client['client_secret_expires_at'] != 0 and \ + client['client_secret_expires_at'] <= int(time.time()): + self.error('Client authentication with expired secret') + raise APIError(400, 'invalid_client', + 'client authentication error') + + if client['token_endpoint_auth_method'] != auth_method: + self.error('Client authentication with invalid auth method: %s' + % auth_method) + raise APIError(400, 'invalid_client', + 'client authentication error') + + if not constant_time_string_comparison(client['client_secret'], + client_secret): + self.error('Client authentication with invalid secret: %s' + % client_secret) + raise APIError(400, 'invalid_client', + 'client authentication error') + + self.api_client_authenticated = True + self.api_client_id = client_id + self.api_client = client + + def _authenticate_client(self, post_args): + request = cherrypy.serving.request + self.debug('Trying to authenticate client') + if 'authorization' in request.headers: + self.debug('Authorization header found') + hdr = request.headers['authorization'] + if hdr.startswith('Basic '): + self.debug('Authorization header is basic') + hdr = hdr[len('Basic '):] + try: + client_id, client_secret = \ + base64.b64decode(hdr).split(':', 1) + except Exception as e: # pylint: disable=broad-except + self.error('Invalid request received: %s' % repr(e)) + self._respond_error('invalid_request', + 'invalid auth header') + self.debug('Client ID: %s' % client_id) + self._handle_client_authentication('client_secret_basic', + client_id, + client_secret) + else: + self.error('Invalid authorization header presented') + response = cherrypy.serving.response + response.headers['WWW-Authenticate'] = 'Bearer realm="Ipsilon"' + raise cherrypy.HTTPError(401, "Unauthorized") + elif 'client_id' in post_args: + self.debug('Client id found in post args: %s' + % post_args['client_id']) + self._handle_client_authentication('client_secret_post', + post_args['client_id'], + post_args.get('client_secret', + '')) + else: + self.error('No authorization presented') + response = cherrypy.serving.response + response.headers['WWW-Authenticate'] = 'Bearer realm="Ipsilon"' + raise cherrypy.HTTPError(401, "Unauthorized") + # FIXME: Perhaps add client_secret_jwt and private_key_jwt as per + # OpenID Connect Core section 9 + + def _handle_token_authentication(self, token): + token = self.cfg.datastore.lookupToken(token, 'Bearer') + if not token: + self.error('Unknown token provided') + raise APIError(400, 'invalid_token') + + if self.api_client_id: + if token['client_id'] != self.api_client_id: + self.error('Authenticated client is not token owner: %s != %s' + % (token['client_id'], self.api_client_id)) + raise APIError(400, 'invalid_request') + else: + self.api_client = self.cfg.datastore.getClient(token['client_id']) + if not self.api_client: + self.error('Token authentication with invalid client ID') + raise APIError(400, 'invalid_client', + 'client authentication error') + + if (self.required_scope is not None and + self.required_scope not in token['scope']): + self.error('Required %s not in token scopes %s' + % (self.required_scope, token['scope'])) + raise APIError(403, 'insufficient_scope') + + self.api_client_id = token['client_id'] + self.api_valid_token = True + self.api_username = token['username'] + self.api_scopes = token['scope'] + self.api_token = token + + def _authenticate_token(self, post_args): + request = cherrypy.serving.request + if 'authorization' in request.headers: + hdr = request.headers['authorization'] + if hdr.startswith('Bearer '): + token = hdr[len('Bearer '):] + self._handle_token_authentication(token) + else: + raise APIError(400, 'invalid_request') + elif 'access_token' in post_args: + # Bearer token + token = post_args['access_token'] + self._handle_token_authentication(token) + + def require_scope(self, scope): + if scope not in self.api_scopes: + raise APIError(403, 'insufficient_scope') + + +class Token(APIRequest): + authenticate_client = True + + def POST(self, *args, **kwargs): + grant_type = kwargs.get('grant_type', None) + + if grant_type == 'authorization_code': + # Handle authz code + code = kwargs.get('code', None) + redirect_uri = kwargs.get('redirect_uri', None) + + token = self.cfg.datastore.lookupToken(code, 'Authorization') + if not token: + self.error('Unknown authz token provided') + return self._respond_error('invalid_request', + 'invalid token') + + if token['client_id'] != self.api_client_id: + self.error('Authz code owner does not match authenticated ' + + 'client: %s != %s' % (token['client_id'], + self.api_client_id)) + return self._respond_error('invalid_request', + 'invalid token for client ID') + + if token['redirect_uri'] != redirect_uri: + self.error('Token redirect URI does not match request: ' + + '%s != %s' % (token['redirect_uri'], redirect_uri)) + return self._respond_error('invalid_request', + 'redirect_uri does not match') + + new_token = self.cfg.datastore.exchangeAuthorizationCode(code) + if not new_token: + self.error('Was unable to exchange for token') + return self._respond_error('invalid_grant', + 'Could not refresh') + new_token['token_type'] = 'Bearer' + + return self._respond(new_token) + + elif grant_type == 'refresh_token': + # Handle token refresh + refresh_token = kwargs.get('refresh_token', None) + + refresh_result = self.cfg.datastore.refreshToken( + refresh_token, + self.api_client_id) + + if not refresh_result: + return self._respond_error('invalid_grant', + 'Something went wrong refreshing') + + return self._respond({ + 'access_token': refresh_result['access_token'], + 'token_type': 'Bearer', + 'refresh_token': refresh_result['refresh_token'], + 'expires_in': refresh_result['expires_in']}) + + else: + return self._respond_error('unsupported_grant_type', + 'unknown grant_type') + + +class TokenInfo(APIRequest): + # RFC7662 (Token introspection) + authenticate_client = True + requires_client_auth = True + + def POST(self, *args, **kwargs): + token = kwargs.get('token', None) + + token = self.cfg.datastore.lookupToken(token, None, True) + if not token: + # Per spec, if this token is not valid, but the request itself is, + # we return with an "empty" response + return self._respond({ + 'active': False + }) + + # FIXME: At this moment, we only have Bearer tokens + token_type = 'Bearer' + + return self._respond({ + 'active': int(time.time()) <= int(token['expires_at']), + 'scope': ' '.join(token['scope']), + 'client_id': token['client_id'], + 'username': token['username'], + 'token_type': token_type, + 'exp': token['expires_at'], + 'iat': token['issued_at'], + 'sub': token['username'], + 'aud': token['client_id'], + 'iss': self.cfg.endpoint_url, + }) + + +class UserInfo(APIRequest): + authenticate_token = True + requires_valid_token = True + required_scope = 'openid' + + def _get_userinfo(self, *args, **kwargs): + info = self.cfg.datastore.getUserInfo(self.api_token['userinfocode']) + if not info: + return self._respond_error('invalid_request', + 'No userinfo for token') + + if 'userinfo_signed_response_alg' in self.api_client: + cherrypy.response.headers.update({ + 'Content-Type': 'application/jwt' + }) + + sig = JWT(header={'alg': 'RS256', + 'kid': self.cfg.idp_sig_key_id}, + claims=info) + # FIXME: Maybe add other algorithms in the future + sig.make_signed_token(self.cfg.keyset.get_key( + self.cfg.idp_sig_key_id)) + # FIXME: Maybe encrypt in the future + info = sig.serialize(compact=True) + + if isinstance(info, dict): + info = json.dumps(info) + + return info + + def GET(self, *args, **kwargs): + return self._get_userinfo(*kwargs, **kwargs) + + def POST(self, *args, **kwargs): + return self._get_userinfo(*kwargs, **kwargs) diff --git a/ipsilon/providers/openidc/auth.py b/ipsilon/providers/openidc/auth.py index 80de200..c48d9ef 100644 --- a/ipsilon/providers/openidc/auth.py +++ b/ipsilon/providers/openidc/auth.py @@ -1,12 +1,14 @@ # Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING -from ipsilon.util.log import Log from ipsilon.providers.common import ProviderPageBase from ipsilon.providers.common import InvalidRequest from ipsilon.util.policy import Policy from ipsilon.util.trans import Transaction -from ipsilon.util.security import (generate_random_secure_string, - constant_time_string_comparison) +from ipsilon.providers.openidc.api import (Token, + TokenInfo, + UserInfo) +from ipsilon.providers.openidc.provider import (get_url_hostpart, + Registration) from ipsilon.util.user import UserSession from jwcrypto.jwt import JWT @@ -20,19 +22,10 @@ import requests import time import json import urllib -from urlparse import urlparse URLROOT = 'openidc' -def get_url_hostpart(url): - try: - o = urlparse(url) - return o.hostname - except: # pylint: disable=bare-except - return url - - class AuthenticateRequest(ProviderPageBase): def __init__(self, site, provider, *args, **kwargs): @@ -146,208 +139,6 @@ class AuthenticateRequest(ProviderPageBase): return None -class APIError(cherrypy.HTTPError, Log): - - def __init__(self, code, error, description=None): - super(APIError, self).__init__(code, error) - self.debug('OpenIDC API error: %s, desc: %s' - % (error, description)) - response = {'error': error} - if description: - response['error_description'] = description - self._error_response = json.dumps(response) - cherrypy.response.headers.update({ - 'Content-Type': 'application/json' - }) - - def get_error_page(self, *args, **kwargs): - return self._error_response - - -class APIRequest(ProviderPageBase): - # Bearer token (RFC 6750) and Client Auth - authenticate_client = False - authenticate_token = False - requires_client_auth = False - requires_valid_token = False - required_scope = None - - def __init__(self, site, provider, *args, **kwargs): - super(APIRequest, self).__init__(site, provider) - self.api_client_id = None - self.api_valid_token = False - self.api_username = None - self.api_scopes = [] - self.api_client_authenticated = False - self.api_client_id = None - self.api_token = None - self.api_client = None - - def pre_GET(self, *args, **kwargs): - # Note that we explicitly do NOT support URI Query parameter posting - # of bearer tokens (RFC6750, section 2.3 marks this as MAY) - self._preop() - - def pre_POST(self, *args, **kwargs): - self._preop(*args, **kwargs) - - def _preop(self, *args, **kwargs): - cherrypy.response.headers.update({ - 'Content-Type': 'application/json' - }) - - self.api_client_id = None - self.api_valid_token = False - self.api_username = None - self.api_scopes = [] - self.api_client_authenticated = False - self.api_client_id = None - self.api_token = None - self.api_client = None - - if self.authenticate_client: - self._authenticate_client(kwargs) - if self.requires_client_auth and not self.api_client_id: - raise APIError(400, 'invalid_client', - 'client authentication required') - - if self.authenticate_token: - self._authenticate_token(kwargs) - - def _respond(self, response): - return json.dumps(response) - - def _respond_error(self, error, message): - return self._respond({'error': error, - 'error_description': message}) - - def _handle_client_authentication(self, auth_method, client_id, - client_secret): - self.debug('Trying client auth for %s with method %s' - % (client_id, auth_method)) - if not client_id: - self.error('Client authentication without client_id') - raise APIError(400, 'invalid_client', - 'client authentication error') - - client = self.cfg.datastore.getClient(client_id) - if not client: - self.error('Client authentication with invalid client ID') - raise APIError(400, 'invalid_client', - 'client authentication error') - - if client['client_secret_expires_at'] != 0 and \ - client['client_secret_expires_at'] <= int(time.time()): - self.error('Client authentication with expired secret') - raise APIError(400, 'invalid_client', - 'client authentication error') - - if client['token_endpoint_auth_method'] != auth_method: - self.error('Client authentication with invalid auth method: %s' - % auth_method) - raise APIError(400, 'invalid_client', - 'client authentication error') - - if not constant_time_string_comparison(client['client_secret'], - client_secret): - self.error('Client authentication with invalid secret: %s' - % client_secret) - raise APIError(400, 'invalid_client', - 'client authentication error') - - self.api_client_authenticated = True - self.api_client_id = client_id - self.api_client = client - - def _authenticate_client(self, post_args): - request = cherrypy.serving.request - self.debug('Trying to authenticate client') - if 'authorization' in request.headers: - self.debug('Authorization header found') - hdr = request.headers['authorization'] - if hdr.startswith('Basic '): - self.debug('Authorization header is basic') - hdr = hdr[len('Basic '):] - try: - client_id, client_secret = \ - base64.b64decode(hdr).split(':', 1) - except Exception as e: # pylint: disable=broad-except - self.error('Invalid request received: %s' % repr(e)) - self._respond_error('invalid_request', - 'invalid auth header') - self.debug('Client ID: %s' % client_id) - self._handle_client_authentication('client_secret_basic', - client_id, - client_secret) - else: - self.error('Invalid authorization header presented') - response = cherrypy.serving.response - response.headers['WWW-Authenticate'] = 'Bearer realm="Ipsilon"' - raise cherrypy.HTTPError(401, "Unauthorized") - elif 'client_id' in post_args: - self.debug('Client id found in post args: %s' - % post_args['client_id']) - self._handle_client_authentication('client_secret_post', - post_args['client_id'], - post_args.get('client_secret', - '')) - else: - self.error('No authorization presented') - response = cherrypy.serving.response - response.headers['WWW-Authenticate'] = 'Bearer realm="Ipsilon"' - raise cherrypy.HTTPError(401, "Unauthorized") - # FIXME: Perhaps add client_secret_jwt and private_key_jwt as per - # OpenID Connect Core section 9 - - def _handle_token_authentication(self, token): - token = self.cfg.datastore.lookupToken(token, 'Bearer') - if not token: - self.error('Unknown token provided') - raise APIError(400, 'invalid_token') - - if self.api_client_id: - if token['client_id'] != self.api_client_id: - self.error('Authenticated client is not token owner: %s != %s' - % (token['client_id'], self.api_client_id)) - raise APIError(400, 'invalid_request') - else: - self.api_client = self.cfg.datastore.getClient(token['client_id']) - if not self.api_client: - self.error('Token authentication with invalid client ID') - raise APIError(400, 'invalid_client', - 'client authentication error') - - if (self.required_scope is not None and - self.required_scope not in token['scope']): - self.error('Required %s not in token scopes %s' - % (self.required_scope, token['scope'])) - raise APIError(403, 'insufficient_scope') - - self.api_client_id = token['client_id'] - self.api_valid_token = True - self.api_username = token['username'] - self.api_scopes = token['scope'] - self.api_token = token - - def _authenticate_token(self, post_args): - request = cherrypy.serving.request - if 'authorization' in request.headers: - hdr = request.headers['authorization'] - if hdr.startswith('Bearer '): - token = hdr[len('Bearer '):] - self._handle_token_authentication(token) - else: - raise APIError(400, 'invalid_request') - elif 'access_token' in post_args: - # Bearer token - token = post_args['access_token'] - self._handle_token_authentication(token) - - def require_scope(self, scope): - if scope not in self.api_scopes: - raise APIError(403, 'insufficient_scope') - - class Authorization(AuthenticateRequest): def start_authz(self, arguments): @@ -867,282 +658,6 @@ class Continue(AuthenticateRequest): return self._perform_continue(*args, form_filled=True, **kwargs) -class Registration(APIRequest): - - def POST(self, *args, **kwargs): - if not self.cfg.allow_dynamic_client_registration: - raise APIError(400, 'invalid_request', - 'dynamic client registration has been disabled') - - try: - client_metadata = json.loads(cherrypy.request.rfile.read()) - except: - raise APIError(400, 'invalid_client_metadata', - 'unable to parse metadata') - self.debug('Received client registration request: %s' - % client_metadata) - - # Fill in defaults for optional arguments - client_metadata['response_types'] = client_metadata.get( - 'response_types', ['code']) - client_metadata['grant_types'] = client_metadata.get( - 'grant_types', ['authorization_code']) - client_metadata['application_type'] = client_metadata.get( - 'application_type', 'web') - client_metadata['contacts'] = client_metadata.get('contacts', []) - client_metadata['subject_type'] = client_metadata.get('subject_type', - 'pairwise') - client_metadata['id_token_signed_response_alg'] = client_metadata.get( - 'id_token_signed_response_alg', 'RS256') - client_metadata['token_endpoint_auth_method'] = client_metadata.get( - 'token_endpoint_auth_method', 'client_secret_basic') - client_metadata['require_auth_time'] = client_metadata.get( - 'require_auth_time', False) - - # Check the client metadata received - if 'redirect_uris' not in client_metadata: - raise APIError(400, 'invalid_client_metadata', - 'missing redirect_uris') - - if client_metadata['application_type'] not in ['web', 'native']: - raise APIError(400, 'invalid_client_metadata', - 'application_type invalid') - - for redirect_uri in client_metadata['redirect_uris']: - if '#' in redirect_uri: - raise APIError(400, 'invalid_redirect_uri', - 'redirect_uri contains fragment') - - if client_metadata['application_type'] == 'web': - # In this case, it must be https:// and not https://localhost - if (not redirect_uri.startswith('https://') or - redirect_uri.startswith('https://localhost')): - raise APIError(400, 'invalid_redirect_uri', - 'redirect_uri %s not valid' % redirect_uri) - - elif client_metadata['application_type'] == 'native': - # In this case, it must be http://localhost, or something - # that is not http:// or https:// - if (redirect_uri.startswith('https://') or - (redirect_uri.startswith('http://') and - not redirect_uri.startswith('http://localhost'))): - raise APIError(400, 'invalid_redirect_uri', - 'redirect_uri %s not valid' % redirect_uri) - - if 'initiate_login_uri' in client_metadata: - if not client_metadata['initiate_login_uri'].startswith( - 'https://'): - raise APIError(400, 'invalid_client_metadata', - 'initiate_login_uri must be https') - - if 'sector_identifier_uri' not in client_metadata: - hostname = None - for redir_uri in client_metadata['redirect_uris']: - cur_host = get_url_hostpart(redir_uri) - if not cur_host: - raise APIError(400, 'invalid_client_metadata', - 'Unable to parse hostname from ' + - 'redirect_uri %s' % redir_uri) - if hostname is not None and cur_host != hostname: - raise APIError(400, 'invalid_client_metadata', - 'Multiple redirect_uri hostnames without ' + - 'sector_identifier_uri') - hostname = cur_host - else: - if not client_metadata['sector_identifier_uri'].startswith( - 'https://'): - raise APIError(400, 'invalid_client_metadata', - 'sector_identifier_uri must be https') - - try: - resp = requests.get(client_metadata['sector_identifier_uri']) - resp = resp.json() - for redirect_uri in client_metadata['redirect_uris']: - if redirect_uri not in resp: - raise APIError(400, 'invalid_client_metadata', - 'redirect_uri %s not in ' + - 'sector_identifier_uri document' % - redirect_uri) - except Exception as ex: - self.debug('Unable to process sector_identifier_uri: %s' % - ex) - raise APIError(400, 'invalid_client_metadata', - 'unable to process sector_identifier_uri') - - if 'code' in client_metadata['response_types']: - if 'authorization_code' not in client_metadata['grant_types']: - raise APIError(400, 'invalid_client_metadata', - 'authorization_code missing with code') - - if ('token' in client_metadata['response_types'] or - 'id_token' in client_metadata['response_types']): - if 'implicit' not in client_metadata['grant_types']: - raise APIError(400, 'invalid_client_metadata', - 'implicit missing with token or id_token') - - if 'jwks' in client_metadata and 'jwks_uri' in client_metadata: - raise APIError(400, 'invalid_client_metadata', - 'both jwks and jwks_uri provided') - - # If all checks pass, generate client ID and secret - client_metadata['client_secret'] = \ - generate_random_secure_string() - client_metadata['client_secret_expires_at'] = 0 # FIXME: Expire? - client_metadata['client_id_issued_at'] = int(time.time()) - - # Store some internal data - client_metadata['ipsilon_internal'] = { - 'trusted': False - } - - # Store and add reg uri - client_id = self.cfg.datastore.registerDynamicClient(client_metadata) - client_metadata['client_id'] = client_id - - # Clear internal data from returned values - del client_metadata['ipsilon_internal'] - - # FIXME: Offer this once we add a ClientConfiguration endpoint - # client_metadata['registration_access_token'] = \ - # generate_random_secure_string() - # client_metadata['registration_client_uri'] = '%s%s' % ( - # self.cfg.endpoint_url, 'ClientConfiguration') - - return self._respond(client_metadata) - - -class Token(APIRequest): - authenticate_client = True - - def POST(self, *args, **kwargs): - grant_type = kwargs.get('grant_type', None) - - if grant_type == 'authorization_code': - # Handle authz code - code = kwargs.get('code', None) - redirect_uri = kwargs.get('redirect_uri', None) - - token = self.cfg.datastore.lookupToken(code, 'Authorization') - if not token: - self.error('Unknown authz token provided') - return self._respond_error('invalid_request', - 'invalid token') - - if token['client_id'] != self.api_client_id: - self.error('Authz code owner does not match authenticated ' + - 'client: %s != %s' % (token['client_id'], - self.api_client_id)) - return self._respond_error('invalid_request', - 'invalid token for client ID') - - if token['redirect_uri'] != redirect_uri: - self.error('Token redirect URI does not match request: ' + - '%s != %s' % (token['redirect_uri'], redirect_uri)) - return self._respond_error('invalid_request', - 'redirect_uri does not match') - - new_token = self.cfg.datastore.exchangeAuthorizationCode(code) - if not new_token: - self.error('Was unable to exchange for token') - return self._respond_error('invalid_grant', - 'Could not refresh') - new_token['token_type'] = 'Bearer' - - return self._respond(new_token) - - elif grant_type == 'refresh_token': - # Handle token refresh - refresh_token = kwargs.get('refresh_token', None) - - refresh_result = self.cfg.datastore.refreshToken( - refresh_token, - self.api_client_id) - - if not refresh_result: - return self._respond_error('invalid_grant', - 'Something went wrong refreshing') - - return self._respond({ - 'access_token': refresh_result['access_token'], - 'token_type': 'Bearer', - 'refresh_token': refresh_result['refresh_token'], - 'expires_in': refresh_result['expires_in']}) - - else: - return self._respond_error('unsupported_grant_type', - 'unknown grant_type') - - -class TokenInfo(APIRequest): - # RFC7662 (Token introspection) - authenticate_client = True - requires_client_auth = True - - def POST(self, *args, **kwargs): - token = kwargs.get('token', None) - - token = self.cfg.datastore.lookupToken(token, None, True) - if not token: - # Per spec, if this token is not valid, but the request itself is, - # we return with an "empty" response - return self._respond({ - 'active': False - }) - - # FIXME: At this moment, we only have Bearer tokens - token_type = 'Bearer' - - return self._respond({ - 'active': int(time.time()) <= int(token['expires_at']), - 'scope': ' '.join(token['scope']), - 'client_id': token['client_id'], - 'username': token['username'], - 'token_type': token_type, - 'exp': token['expires_at'], - 'iat': token['issued_at'], - 'sub': token['username'], - 'aud': token['client_id'], - 'iss': self.cfg.endpoint_url, - }) - - -class UserInfo(APIRequest): - authenticate_token = True - requires_valid_token = True - required_scope = 'openid' - - def _get_userinfo(self, *args, **kwargs): - info = self.cfg.datastore.getUserInfo(self.api_token['userinfocode']) - if not info: - return self._respond_error('invalid_request', - 'No userinfo for token') - - if 'userinfo_signed_response_alg' in self.api_client: - cherrypy.response.headers.update({ - 'Content-Type': 'application/jwt' - }) - - sig = JWT(header={'alg': 'RS256', - 'kid': self.cfg.idp_sig_key_id}, - claims=info) - # FIXME: Maybe add other algorithms in the future - sig.make_signed_token(self.cfg.keyset.get_key( - self.cfg.idp_sig_key_id)) - # FIXME: Maybe encrypt in the future - info = sig.serialize(compact=True) - - if isinstance(info, dict): - info = json.dumps(info) - - return info - - def GET(self, *args, **kwargs): - return self._get_userinfo(*kwargs, **kwargs) - - def POST(self, *args, **kwargs): - return self._get_userinfo(*kwargs, **kwargs) - - class OpenIDC(ProviderPageBase): def __init__(self, *args, **kwargs): diff --git a/ipsilon/providers/openidc/provider.py b/ipsilon/providers/openidc/provider.py new file mode 100644 index 0000000..3305f28 --- /dev/null +++ b/ipsilon/providers/openidc/provider.py @@ -0,0 +1,164 @@ +# Copyright (C) 2016 Ipsilon project Contributors, for license see COPYING + +from ipsilon.providers.openidc.api import APIError, APIRequest +from ipsilon.util.security import generate_random_secure_string + + +import cherrypy +import json +import time +import requests +from urlparse import urlparse + + +def get_url_hostpart(url): + try: + o = urlparse(url) + return o.hostname + except: # pylint: disable=bare-except + return url + + +def validate_client_metadata(client_metadata): + # Fill in defaults for optional arguments + client_metadata['response_types'] = client_metadata.get( + 'response_types', ['code']) + client_metadata['grant_types'] = client_metadata.get( + 'grant_types', ['authorization_code']) + client_metadata['application_type'] = client_metadata.get( + 'application_type', 'web') + client_metadata['contacts'] = client_metadata.get('contacts', []) + client_metadata['subject_type'] = client_metadata.get('subject_type', + 'pairwise') + client_metadata['id_token_signed_response_alg'] = client_metadata.get( + 'id_token_signed_response_alg', 'RS256') + client_metadata['token_endpoint_auth_method'] = client_metadata.get( + 'token_endpoint_auth_method', 'client_secret_basic') + client_metadata['require_auth_time'] = client_metadata.get( + 'require_auth_time', False) + + # Check the client metadata received + if 'redirect_uris' not in client_metadata: + raise APIError(400, 'invalid_client_metadata', + 'missing redirect_uris') + + if client_metadata['application_type'] not in ['web', 'native']: + raise APIError(400, 'invalid_client_metadata', + 'application_type invalid') + + for redirect_uri in client_metadata['redirect_uris']: + if '#' in redirect_uri: + raise APIError(400, 'invalid_redirect_uri', + 'redirect_uri contains fragment') + + if client_metadata['application_type'] == 'web': + # In this case, it must be https:// and not https://localhost + if (not redirect_uri.startswith('https://') or + redirect_uri.startswith('https://localhost')): + raise APIError(400, 'invalid_redirect_uri', + 'redirect_uri %s not valid' % redirect_uri) + + elif client_metadata['application_type'] == 'native': + # In this case, it must be http://localhost, or something + # that is not http:// or https:// + if (redirect_uri.startswith('https://') or + (redirect_uri.startswith('http://') and + not redirect_uri.startswith('http://localhost'))): + raise APIError(400, 'invalid_redirect_uri', + 'redirect_uri %s not valid' % redirect_uri) + + if 'initiate_login_uri' in client_metadata: + if not client_metadata['initiate_login_uri'].startswith( + 'https://'): + raise APIError(400, 'invalid_client_metadata', + 'initiate_login_uri must be https') + + if 'sector_identifier_uri' not in client_metadata: + hostname = None + for redir_uri in client_metadata['redirect_uris']: + cur_host = get_url_hostpart(redir_uri) + if not cur_host: + raise APIError(400, 'invalid_client_metadata', + 'Unable to parse hostname from ' + + 'redirect_uri %s' % redir_uri) + if hostname is not None and cur_host != hostname: + raise APIError(400, 'invalid_client_metadata', + 'Multiple redirect_uri hostnames without ' + + 'sector_identifier_uri') + hostname = cur_host + else: + if not client_metadata['sector_identifier_uri'].startswith( + 'https://'): + raise APIError(400, 'invalid_client_metadata', + 'sector_identifier_uri must be https') + + try: + resp = requests.get(client_metadata['sector_identifier_uri']) + resp = resp.json() + for redirect_uri in client_metadata['redirect_uris']: + if redirect_uri not in resp: + raise APIError(400, 'invalid_client_metadata', + 'redirect_uri %s not in ' + + 'sector_identifier_uri document' % + redirect_uri) + except Exception as ex: + raise APIError(400, 'invalid_client_metadata', + 'unable to process sector_identifier_uri: %s' % ex) + + if 'code' in client_metadata['response_types']: + if 'authorization_code' not in client_metadata['grant_types']: + raise APIError(400, 'invalid_client_metadata', + 'authorization_code missing with code') + + if ('token' in client_metadata['response_types'] or + 'id_token' in client_metadata['response_types']): + if 'implicit' not in client_metadata['grant_types']: + raise APIError(400, 'invalid_client_metadata', + 'implicit missing with token or id_token') + + if 'jwks' in client_metadata and 'jwks_uri' in client_metadata: + raise APIError(400, 'invalid_client_metadata', + 'both jwks and jwks_uri provided') + + +class Registration(APIRequest): + + def POST(self, *args, **kwargs): + if not self.cfg.allow_dynamic_client_registration: + raise APIError(400, 'invalid_request', + 'dynamic client registration has been disabled') + + try: + client_metadata = json.loads(cherrypy.request.rfile.read()) + except: + raise APIError(400, 'invalid_client_metadata', + 'unable to parse metadata') + self.debug('Received client registration request: %s' + % client_metadata) + + validate_client_metadata(client_metadata) + + client_metadata['client_secret'] = \ + generate_random_secure_string() + client_metadata['client_secret_expires_at'] = 0 # FIXME: Expire? + client_metadata['client_id_issued_at'] = int(time.time()) + + # Store some internal data + client_metadata['ipsilon_internal'] = { + 'trusted': False + } + + # Store and add reg uri + client_id = self.cfg.datastore.registerDynamicClient(client_metadata) + client_metadata['client_id'] = client_id + + # Clear internal data from returned values + del client_metadata['ipsilon_internal'] + + # FIXME: Offer this once we add a ClientConfiguration endpoint + # client_metadata['registration_access_token'] = \ + # generate_random_secure_string() + # client_metadata['registration_client_uri'] = '%s%s' % ( + # self.cfg.endpoint_url, 'ClientConfiguration') + + return self._respond(client_metadata)