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/application.py b/msal/application.py new file mode 100644 index 00000000..ca511c2d --- /dev/null +++ b/msal/application.py @@ -0,0 +1,41 @@ +from . import request + + +class ClientApplication(object): + DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/" + + 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=''): + 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/exceptions.py b/msal/exceptions.py new file mode 100644 index 00000000..21fff815 --- /dev/null +++ b/msal/exceptions.py @@ -0,0 +1,37 @@ +#------------------------------------------------------------------------------ +# +# 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 + +class MsalServiceError(MsalError): + msg = "{error}: {error_description}" + diff --git a/msal/oauth2.py b/msal/oauth2.py new file mode 100644 index 00000000..dccc0b40 --- /dev/null +++ b/msal/oauth2.py @@ -0,0 +1,131 @@ +"""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: + from urlparse import parse_qs + from urllib import urlencode + +import requests + + +class Client(object): + # 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 + 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, **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, **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. + + # 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 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, 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', 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, 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, + redirect_uri=redirect_uri, client_id=client_id, **kwargs) + + +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 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") + + +class ResourceOwnerPasswordCredentialsGrant(Client): + + def authorization_url(self, **kwargs): + raise NotImplemented( + "You should have already obtained resource owner's password") + + def get_token(self, username, password, scope=None, **kwargs): + return super(ResourceOwnerPasswordCredentialsGrant, self).get_token( + "password", username=username, password=password, scope=scope, + **kwargs) + + +class ClientCredentialGrant(Client): + def authorization_url(self, **kwargs): + # Since the client authentication is used as the authorization grant + raise NotImplemented("No additional authorization request is needed") + + def get_token(self, scope=None, **kwargs): + return super(ClientCredentialGrant, self).get_token( + "client_credentials", scope=scope, **kwargs) + diff --git a/msal/request.py b/msal/request.py new file mode 100644 index 00000000..3579bc89 --- /dev/null +++ b/msal/request.py @@ -0,0 +1,66 @@ +import time + +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): + """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 + + # 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") + + +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) + 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 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) +