From 404c5d30d681a53c82d877b90c7ea74c80c6f0ff Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 30 Aug 2016 13:04:04 -0700 Subject: [PATCH 1/8] A generic MsalError class --- msal/__init__.py | 31 +++++++++++++++++++++++++++++++ msal/exceptions.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 msal/__init__.py create mode 100644 msal/exceptions.py diff --git a/msal/__init__.py b/msal/__init__.py new file mode 100644 index 00000000..d000c027 --- /dev/null +++ b/msal/__init__.py @@ -0,0 +1,31 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) Microsoft Corporation. +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#------------------------------------------------------------------------------ + +# pylint: disable=wrong-import-position + +__version__ = '0.1.0' + diff --git a/msal/exceptions.py b/msal/exceptions.py new file mode 100644 index 00000000..c65ebf63 --- /dev/null +++ b/msal/exceptions.py @@ -0,0 +1,34 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) Microsoft Corporation. +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +#------------------------------------------------------------------------------ + +class MsalError(Exception): + msg = 'An unspecified error' + + def __init__(self, *args, **kwargs): + super(MsalError, self).__init__(self.msg.format(**kwargs), *args) + self.kwargs = kwargs + From 549ee7a3901cd14a7fd6badc9e3d927a0b2ad9e8 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 31 Aug 2016 09:49:47 -0700 Subject: [PATCH 2/8] Scaffoldwith test case --- msal/application.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 8 ++++++++ tests/test_application.py | 13 +++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 msal/application.py create mode 100644 tests/__init__.py create mode 100644 tests/test_application.py diff --git a/msal/application.py b/msal/application.py new file mode 100644 index 00000000..ddf683b8 --- /dev/null +++ b/msal/application.py @@ -0,0 +1,39 @@ + + +class ClientApplication(object): + DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/" + TOKEN_ENDPOINT_PATH = '/oauth2/v2.0/token' + + def __init__( + self, client_id, + validate_authority=True, authority=DEFAULT_AUTHORITY): + self.client_id = client_id + self.validate_authority = validate_authority + self.authority = authority +# def aquire_token_silent( +# self, scopes, user=None, authority=None, policy=None, +# force_refresh=False): +# pass + + +class PublicClientApplication(ClientApplication): + DEFAULT_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" + + def __init__(self, client_id, redirect_uri=DEFAULT_REDIRECT_URI, **kwargs): + super(PublicClientApplication, self).__init__(client_id, **kwargs) + self.redirect_uri = redirect_uri + +class ConfidentialClientApplication(ClientApplication): + def __init__(self, client_id, client_credential, user_token_cache, **kwargs): + """ + :param client_credential: It can be a string containing client secret, + or an X509 certificate object. + """ + super(ConfidentialClientApplication, self).__init__(client_id, **kwargs) + self.client_credential = client_credential + self.user_token_cache = user_token_cache + self.app_token_cache = None # TODO + + def acquire_token_for_client(self, scope, policy=None): # Can policy default to None? + pass + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..dce7a38a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,8 @@ +import sys +if sys.version_info[:2] < (2, 7): + # The unittest module got a significant overhaul in Python 2.7, + # so if we're in 2.6 we can use the backported version unittest2. + import unittest2 as unittest +else: + import unittest + diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 00000000..d145a0a9 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,13 @@ +from msal.application import ConfidentialClientApplication + +from tests import unittest + + +class TestConfidentialClientApplication(unittest.TestCase): + def test_confidential_client_using_secret(self): + app = ConfidentialClientApplication( + "client_id", "client_secret", "TBD: TokenCache()") + result = app.acquire_token_for_client( + ["r1/scope1", "r1/scope2"], "policy") + self.assertIsNone(result) + From d1be5f57ba3f21046d5013f97ef0ac32b017e61f Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Wed, 31 Aug 2016 17:01:58 -0700 Subject: [PATCH 3/8] Experimental OAuth2 client secret flow --- msal/application.py | 12 ++++++++++-- requirements.txt | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 requirements.txt diff --git a/msal/application.py b/msal/application.py index ddf683b8..41673965 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1,3 +1,4 @@ +import requests class ClientApplication(object): @@ -34,6 +35,13 @@ def __init__(self, client_id, client_credential, user_token_cache, **kwargs): self.user_token_cache = user_token_cache self.app_token_cache = None # TODO - def acquire_token_for_client(self, scope, policy=None): # Can policy default to None? - pass + def acquire_token_for_client(self, scope, policy=None): + data = { + 'grant_type': 'client_credentials', 'client_id': self.client_id, + 'scope': scope} + if True: # TODO: Need to differenciate the certificate use case + data['client_secret'] = self.client_credential + return requests.post( + self.authority + self.TOKEN_ENDPOINT_PATH, params={'p': policy}, + headers={'Accept': 'application/json'}, data=data).json() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..0e11a82f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests>=2,<3 From 7bec95ba6154d81bff325cf2385cd1a708d9c075 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 1 Sep 2016 18:33:02 -0700 Subject: [PATCH 4/8] A generic low level oauth implementation --- msal/application.py | 23 ++++---- msal/exceptions.py | 3 + msal/oauth2.py | 137 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 msal/oauth2.py diff --git a/msal/application.py b/msal/application.py index 41673965..26f4b169 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1,9 +1,10 @@ -import requests +from . import oauth2 +from .exceptions import MsalServiceError class ClientApplication(object): DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/" - TOKEN_ENDPOINT_PATH = '/oauth2/v2.0/token' + TOKEN_ENDPOINT_PATH = 'oauth2/v2.0/token' def __init__( self, client_id, @@ -35,13 +36,13 @@ def __init__(self, client_id, client_credential, user_token_cache, **kwargs): self.user_token_cache = user_token_cache self.app_token_cache = None # TODO - def acquire_token_for_client(self, scope, policy=None): - data = { - 'grant_type': 'client_credentials', 'client_id': self.client_id, - 'scope': scope} - if True: # TODO: Need to differenciate the certificate use case - data['client_secret'] = self.client_credential - return requests.post( - self.authority + self.TOKEN_ENDPOINT_PATH, params={'p': policy}, - headers={'Accept': 'application/json'}, data=data).json() + def acquire_token_for_client(self, scope, policy=''): + result = oauth2.ClientCredentialGrant( + self.client_id, + token_endpoint="%s%s?policy=%s" % ( + self.authority, self.TOKEN_ENDPOINT_PATH, policy), + ).get_token(scope=scope, client_secret=self.client_credential) + if 'error' in result: + raise MsalServiceError(**result) + return result diff --git a/msal/exceptions.py b/msal/exceptions.py index c65ebf63..21fff815 100644 --- a/msal/exceptions.py +++ b/msal/exceptions.py @@ -32,3 +32,6 @@ def __init__(self, *args, **kwargs): super(MsalError, self).__init__(self.msg.format(**kwargs), *args) self.kwargs = kwargs +class MsalServiceError(MsalError): + msg = "{error}: {error_description}" + diff --git a/msal/oauth2.py b/msal/oauth2.py new file mode 100644 index 00000000..3a395549 --- /dev/null +++ b/msal/oauth2.py @@ -0,0 +1,137 @@ +try: + from urllib.parse import urlencode, parse_qs +except ImportError: + from urlparse import parse_qs + from urllib import urlencode + +import requests + + +def validate_authorization(params, state=None): + """A thin helper to examine the authorization being redirected back""" + if not isinstance(params, dict): + params = parse_qs(params) + if params.get('state') != state: + raise ValueError('state mismatch') + return params + + +class Client(object): + """This OAuth2 client implementation aims to be spec-compliant, and generic. + + https://tools.ietf.org/html/rfc6749 + """ + def __init__( + self, client_id, + client_credential=None, # Only needed for Confidential Client + authorization_endpoint=None, token_endpoint=None): + self.client_id = client_id + self.client_credential = client_credential + self.authorization_endpoint = authorization_endpoint + self.token_endpoint = token_endpoint + + def authorization_url(self, + response_type, # MUST be set to "code" or "token" + redirect_uri=None, + scope=None, + state=None, # Recommended by the spec + **kwargs): + """To generate an authorization url, to be visited by resource owner. + + :param scope: It is a space-delimited, case-sensitive string. + Some ID provider can accept empty string to represent default scope. + """ + assert response_type and self.client_id + sep = '&' if '?' in self.authorization_endpoint else '?' + params = { + 'client_id': self.client_id, + 'response_type': response_type, + 'redirect_uri': redirect_uri, + 'scope': scope, + 'state': state, + } + params.update(kwargs) + return "%s%s%s" % (self.authorization_endpoint, sep, urlencode(params)) + + def get_token( + self, grant_type, + redirect_uri=None, + scope=None, # Not needed in Authorization Code Grant flow + **kwargs): + # Depending on your chosen grant flow, you may need 'code', + # or 'username' & 'password' pairs, or none of them in the parameters + data = { + 'client_id': self.client_id, 'grant_type': grant_type, + 'scope': scope} + data.update(kwargs) + + # Quoted from https://tools.ietf.org/html/rfc6749#section-2.3.1 + # Clients in possession of a client password MAY use the HTTP Basic + # authentication. + # Alternatively, (but NOT RECOMMENDED,) + # the authorization server MAY support including the + # client credentials in the request-body using the following + # parameters: client_id, client_secret. + auth = None + if self.client_credential and not 'client_secret' in data: + auth = (self.client_id, self.client_credential) # HTTP Basic Auth + + resp = requests.post( + self.token_endpoint, headers={'Accept': 'application/json'}, + data=data, auth=auth) + if resp.status_code>=500: + resp.raise_for_status() # TODO: Will probably try to retry here + # The spec (https://tools.ietf.org/html/rfc6749#section-5.2) says + # even an error response will be a valid json structure, + # so we simply return it here, without needing to invent an exception. + return resp.json() + + +class AuthorizationCodeGrant(Client): + + def authorization_url(self, **kwargs): + return super(AuthorizationCodeGrant, self).authorization_url( + 'code', **kwargs) + # Later when you receive the redirected feedback, + # validate_authorization() may be handy to check the returned state. + + def get_token(self, code, **kwargs): + return super(AuthorizationCodeGrantFlow, self).get_token( + 'authorization_code', code=code, **kwargs) + + +class ImplicitGrant(Client): + """This class is only for illustrative purpose. + + You probably won't implement your ImplicitGrant flow in Python. + """ + + def authorization_url(self, **kwargs): + return super(ImplicitGrant, self).authorization_url( + 'token', **kwargs) + + def get_token(self): + raise NotImplemented("Token is already issued during authorization") + + +class ResourceOwnerPasswordCredentialsGrant(Client): + + def authorization_url(self, **kwargs): + raise NotImplemented( + "You should have obtained resource owner's password, somehow.") + + def get_token(self, username, password, **kwargs): + return super(ResourceOwnerPasswordCredentialsGrant, self).get_token( + "password", username=username, password=password, **kwargs) + + +class ClientCredentialGrant(Client): + def authorization_url(self, **kwargs): + raise NotImplemented( + # Since the client authentication is used as the authorization grant + "No additional authorization request is needed") + + def get_token(self, **kwargs): + return super(ClientCredentialGrant, self).get_token( + "client_credentials", **kwargs) + From 17bba2cefd0ab79711c69be586c6947029be5c68 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 2 Sep 2016 12:44:56 -0700 Subject: [PATCH 5/8] Bugfix: clean up the None value parameters when needed --- msal/oauth2.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/msal/oauth2.py b/msal/oauth2.py index 3a395549..76e8e12b 100644 --- a/msal/oauth2.py +++ b/msal/oauth2.py @@ -51,6 +51,7 @@ def authorization_url(self, 'state': state, } params.update(kwargs) + params = {k: v for k, v in params.items() if v is not None} # clean up return "%s%s%s" % (self.authorization_endpoint, sep, urlencode(params)) def get_token( @@ -64,6 +65,7 @@ def get_token( 'client_id': self.client_id, 'grant_type': grant_type, 'scope': scope} data.update(kwargs) + # We don't need to clean up None values here, because requests lib will. # Quoted from https://tools.ietf.org/html/rfc6749#section-2.3.1 # Clients in possession of a client password MAY use the HTTP Basic From 323d04d1f84ba409efb4ea49ce4f3bcd4ea9ca85 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Fri, 2 Sep 2016 13:30:48 -0700 Subject: [PATCH 6/8] Refactor OAuth2 interface --- msal/oauth2.py | 120 +++++++++++++++++++++++-------------------------- 1 file changed, 56 insertions(+), 64 deletions(-) diff --git a/msal/oauth2.py b/msal/oauth2.py index 76e8e12b..dccc0b40 100644 --- a/msal/oauth2.py +++ b/msal/oauth2.py @@ -1,3 +1,6 @@ +"""This OAuth2 client implementation aims to be spec-compliant, and generic.""" +# OAuth2 spec https://tools.ietf.org/html/rfc6749 + try: from urllib.parse import urlencode, parse_qs except ImportError: @@ -7,20 +10,9 @@ import requests -def validate_authorization(params, state=None): - """A thin helper to examine the authorization being redirected back""" - if not isinstance(params, dict): - params = parse_qs(params) - if params.get('state') != state: - raise ValueError('state mismatch') - return params - - class Client(object): - """This OAuth2 client implementation aims to be spec-compliant, and generic. - - https://tools.ietf.org/html/rfc6749 - """ + # This low-level interface works. Yet you'll find those *Grant sub-classes + # more friendly to remind you what parameters are needed in each scenario. def __init__( self, client_id, client_credential=None, # Only needed for Confidential Client @@ -30,40 +22,15 @@ def __init__( self.authorization_endpoint = authorization_endpoint self.token_endpoint = token_endpoint - def authorization_url(self, - response_type, # MUST be set to "code" or "token" - redirect_uri=None, - scope=None, - state=None, # Recommended by the spec - **kwargs): - """To generate an authorization url, to be visited by resource owner. - - :param scope: It is a space-delimited, case-sensitive string. - Some ID provider can accept empty string to represent default scope. - """ - assert response_type and self.client_id - sep = '&' if '?' in self.authorization_endpoint else '?' - params = { - 'client_id': self.client_id, - 'response_type': response_type, - 'redirect_uri': redirect_uri, - 'scope': scope, - 'state': state, - } + def authorization_url(self, response_type, **kwargs): + params = {'client_id': self.client_id, 'response_type': response_type} params.update(kwargs) params = {k: v for k, v in params.items() if v is not None} # clean up + sep = '&' if '?' in self.authorization_endpoint else '?' return "%s%s%s" % (self.authorization_endpoint, sep, urlencode(params)) - def get_token( - self, grant_type, - redirect_uri=None, - scope=None, # Not needed in Authorization Code Grant flow - **kwargs): - # Depending on your chosen grant flow, you may need 'code', - # or 'username' & 'password' pairs, or none of them in the parameters - data = { - 'client_id': self.client_id, 'grant_type': grant_type, - 'scope': scope} + def get_token(self, grant_type, **kwargs): + data = {'client_id': self.client_id, 'grant_type': grant_type} data.update(kwargs) # We don't need to clean up None values here, because requests lib will. @@ -82,7 +49,7 @@ def get_token( self.token_endpoint, headers={'Accept': 'application/json'}, data=data, auth=auth) if resp.status_code>=500: - resp.raise_for_status() # TODO: Will probably try to retry here + resp.raise_for_status() # TODO: Will probably retry here # The spec (https://tools.ietf.org/html/rfc6749#section-5.2) says # even an error response will be a valid json structure, # so we simply return it here, without needing to invent an exception. @@ -91,26 +58,51 @@ def get_token( class AuthorizationCodeGrant(Client): - def authorization_url(self, **kwargs): + def authorization_url( + self, redirect_uri=None, scope=None, state=None, **kwargs): + """Generate an authorization url to be visited by resource owner. + + :param response_type: MUST be set to "code" or "token". + :param scope: It is a space-delimited, case-sensitive string. + Some ID provider can accept empty string to represent default scope. + """ return super(AuthorizationCodeGrant, self).authorization_url( - 'code', **kwargs) - # Later when you receive the redirected feedback, + 'code', redirect_uri=redirect_uri, scope=scope, state=state, + **kwargs) + # Later when you receive the response at your redirect_uri, # validate_authorization() may be handy to check the returned state. - def get_token(self, code, **kwargs): + def get_token(self, code, redirect_uri=None, client_id=None, **kwargs): + """Get an access token. + + See also https://tools.ietf.org/html/rfc6749#section-4.1.3 + + :param code: The authorization code received from authorization server. + :param redirect_uri: + Required, if the "redirect_uri" parameter was included in the + authorization request, and their values MUST be identical. + :param client_id: Required, if the client is not authenticating itself. + See https://tools.ietf.org/html/rfc6749#section-3.2.1 + """ return super(AuthorizationCodeGrantFlow, self).get_token( - 'authorization_code', code=code, **kwargs) + 'authorization_code', code=code, + redirect_uri=redirect_uri, client_id=client_id, **kwargs) -class ImplicitGrant(Client): - """This class is only for illustrative purpose. +def validate_authorization(params, state=None): + """A thin helper to examine the authorization being redirected back""" + if not isinstance(params, dict): + params = parse_qs(params) + if params.get('state') != state: + raise ValueError('state mismatch') + return params - You probably won't implement your ImplicitGrant flow in Python. - """ - def authorization_url(self, **kwargs): - return super(ImplicitGrant, self).authorization_url( - 'token', **kwargs) +class ImplicitGrant(Client): + # This class is only for illustrative purpose. + # You probably won't implement your ImplicitGrant flow in Python anyway. + def authorization_url(self, redirect_uri=None, scope=None, state=None): + return super(ImplicitGrant, self).authorization_url('token', **locals()) def get_token(self): raise NotImplemented("Token is already issued during authorization") @@ -120,20 +112,20 @@ class ResourceOwnerPasswordCredentialsGrant(Client): def authorization_url(self, **kwargs): raise NotImplemented( - "You should have obtained resource owner's password, somehow.") + "You should have already obtained resource owner's password") - def get_token(self, username, password, **kwargs): + def get_token(self, username, password, scope=None, **kwargs): return super(ResourceOwnerPasswordCredentialsGrant, self).get_token( - "password", username=username, password=password, **kwargs) + "password", username=username, password=password, scope=scope, + **kwargs) class ClientCredentialGrant(Client): def authorization_url(self, **kwargs): - raise NotImplemented( - # Since the client authentication is used as the authorization grant - "No additional authorization request is needed") + # Since the client authentication is used as the authorization grant + raise NotImplemented("No additional authorization request is needed") - def get_token(self, **kwargs): + def get_token(self, scope=None, **kwargs): return super(ClientCredentialGrant, self).get_token( - "client_credentials", **kwargs) + "client_credentials", scope=scope, **kwargs) From 5687d979f719c4d625c8d80a4adcd2ce37b4540d Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Tue, 6 Sep 2016 19:22:26 -0700 Subject: [PATCH 7/8] Start the work on Request middle layer --- msal/application.py | 15 ++++----------- msal/request.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 msal/request.py diff --git a/msal/application.py b/msal/application.py index 26f4b169..ca511c2d 100644 --- a/msal/application.py +++ b/msal/application.py @@ -1,10 +1,8 @@ -from . import oauth2 -from .exceptions import MsalServiceError +from . import request class ClientApplication(object): DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/" - TOKEN_ENDPOINT_PATH = 'oauth2/v2.0/token' def __init__( self, client_id, @@ -37,12 +35,7 @@ def __init__(self, client_id, client_credential, user_token_cache, **kwargs): self.app_token_cache = None # TODO def acquire_token_for_client(self, scope, policy=''): - result = oauth2.ClientCredentialGrant( - self.client_id, - token_endpoint="%s%s?policy=%s" % ( - self.authority, self.TOKEN_ENDPOINT_PATH, policy), - ).get_token(scope=scope, client_secret=self.client_credential) - if 'error' in result: - raise MsalServiceError(**result) - return result + return request.ClientCredentialRequest( + client_id=self.client_id, client_credential=self.client_credential, + scope=scope, policy=policy, authority=self.authority).run() diff --git a/msal/request.py b/msal/request.py new file mode 100644 index 00000000..de06703e --- /dev/null +++ b/msal/request.py @@ -0,0 +1,42 @@ +from . import oauth2 +from .exceptions import MsalServiceError + + +class BaseRequest(object): + TOKEN_ENDPOINT_PATH = 'oauth2/v2.0/token' + + def __init__( + self, authority=None, token_cache=None, scope=None, policy="", + client_id=None, client_credential=None, authenticator=None, + support_adfs=False, restrict_to_single_user=False): + if not scope: + raise ValueError("scope cannot be empty") + self.__dict__.update(locals()) + + def run(self): + # TODO Some cache stuff here + raw = self.get_token() + if 'error' in raw: + raise MsalServiceError(**raw) + # TODO: Deal with refresh_token + return { # i.e. the AuthenticationResult + "token": raw.get('access_token'), + "expires_on": raw.get('expires_in'), # TODO: Change into EPOCH + "tenant_id": None, # TODO + "user": None, # TODO + "id_token": None, # TODO + "scope": set([]), # TODO + } + + def get_token(self): + raise NotImplemented("Use proper sub-class instead") + + +class ClientCredentialRequest(BaseRequest): + def get_token(self): + return oauth2.ClientCredentialGrant( + self.client_id, + token_endpoint="%s%s?policy=%s" % ( + self.authority, self.TOKEN_ENDPOINT_PATH, self.policy), + ).get_token(scope=self.scope, client_secret=self.client_credential) + From eb38e8bc678e83003b1192a1384162d4c13673ac Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Thu, 8 Sep 2016 14:18:14 -0700 Subject: [PATCH 8/8] Implement an AuthenticationResult equivalent --- msal/request.py | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/msal/request.py b/msal/request.py index de06703e..3579bc89 100644 --- a/msal/request.py +++ b/msal/request.py @@ -1,3 +1,5 @@ +import time + from . import oauth2 from .exceptions import MsalServiceError @@ -14,19 +16,41 @@ def __init__( self.__dict__.update(locals()) def run(self): + """Returns a dictionary, which typically contains following keys: + + * token: A string containing an access token (or id token) + * expires_on: A timestamp, in seconds. So compare it with time.time(). + * user: TBD + * and some other keys from the wire, such as "scope", "id_token", etc., + which may or may not appear in every different grant flow. + So you should NOT assume their existence, + instead you would need to access them safely by dict.get('...'). + """ # TODO Some cache stuff here raw = self.get_token() if 'error' in raw: raise MsalServiceError(**raw) # TODO: Deal with refresh_token - return { # i.e. the AuthenticationResult - "token": raw.get('access_token'), - "expires_on": raw.get('expires_in'), # TODO: Change into EPOCH - "tenant_id": None, # TODO - "user": None, # TODO - "id_token": None, # TODO - "scope": set([]), # TODO + + # Keep (most) contents in raw token response, extend it, and return it + raw['token'] = raw.get('access_token') or raw.get('id_token') + raw['expires_on'] = self.__timestamp( + # A timestamp is chosen because it is more lighweight than Datetime, + # and then the entire return value can be serialized as JSON string, + # should the developers choose to do so. + # This is the same timestamp format used in JWT's "iat", by the way. + raw.get('expires_in') or raw.get('id_token_expires_in')) + if 'scope' in raw: + raw['scope'] = set(raw['scope'].split()) # Using SPACE as delimiter + raw['user'] = { # Contents derived from raw['id_token'] + # TODO: Follow https://github.com/AzureAD/microsoft-authentication-library-for-android/blob/dev/msal/src/internal/java/com/microsoft/identity/client/IdToken.java + # https://github.com/AzureAD/microsoft-authentication-library-for-android/blob/dev/msal/src/internal/java/com/microsoft/identity/client/User.java } + return raw # equivalent to AuthenticationResult in other MSAL SDKs + + def __timestamp(self, seconds_from_now=None): # Returns timestamp IN SECOND + return time.time() + ( + seconds_from_now if seconds_from_now is not None else 3600) def get_token(self): raise NotImplemented("Use proper sub-class instead")