Skip to content

Client credential #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Sep 22, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions msal/__init__.py
Original file line number Diff line number Diff line change
@@ -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'

41 changes: 41 additions & 0 deletions msal/application.py
Original file line number Diff line number Diff line change
@@ -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()

37 changes: 37 additions & 0 deletions msal/exceptions.py
Original file line number Diff line number Diff line change
@@ -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}"

131 changes: 131 additions & 0 deletions msal/oauth2.py
Original file line number Diff line number Diff line change
@@ -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)

66 changes: 66 additions & 0 deletions msal/request.py
Original file line number Diff line number Diff line change
@@ -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)

1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
requests>=2,<3
8 changes: 8 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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

13 changes: 13 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
@@ -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)