Skip to content

Commit 972c0d5

Browse files
authored
Merge pull request #1 from AzureAD/client-credential
merge this branch as-is
2 parents 80d93af + eb38e8b commit 972c0d5

File tree

8 files changed

+328
-0
lines changed

8 files changed

+328
-0
lines changed

msal/__init__.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#------------------------------------------------------------------------------
2+
#
3+
# Copyright (c) Microsoft Corporation.
4+
# All rights reserved.
5+
#
6+
# This code is licensed under the MIT License.
7+
#
8+
# Permission is hereby granted, free of charge, to any person obtaining a copy
9+
# of this software and associated documentation files(the "Software"), to deal
10+
# in the Software without restriction, including without limitation the rights
11+
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12+
# copies of the Software, and to permit persons to whom the Software is
13+
# furnished to do so, subject to the following conditions :
14+
#
15+
# The above copyright notice and this permission notice shall be included in
16+
# all copies or substantial portions of the Software.
17+
#
18+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24+
# THE SOFTWARE.
25+
#
26+
#------------------------------------------------------------------------------
27+
28+
# pylint: disable=wrong-import-position
29+
30+
__version__ = '0.1.0'
31+

msal/application.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from . import request
2+
3+
4+
class ClientApplication(object):
5+
DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/"
6+
7+
def __init__(
8+
self, client_id,
9+
validate_authority=True, authority=DEFAULT_AUTHORITY):
10+
self.client_id = client_id
11+
self.validate_authority = validate_authority
12+
self.authority = authority
13+
# def aquire_token_silent(
14+
# self, scopes, user=None, authority=None, policy=None,
15+
# force_refresh=False):
16+
# pass
17+
18+
19+
class PublicClientApplication(ClientApplication):
20+
DEFAULT_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"
21+
22+
def __init__(self, client_id, redirect_uri=DEFAULT_REDIRECT_URI, **kwargs):
23+
super(PublicClientApplication, self).__init__(client_id, **kwargs)
24+
self.redirect_uri = redirect_uri
25+
26+
class ConfidentialClientApplication(ClientApplication):
27+
def __init__(self, client_id, client_credential, user_token_cache, **kwargs):
28+
"""
29+
:param client_credential: It can be a string containing client secret,
30+
or an X509 certificate object.
31+
"""
32+
super(ConfidentialClientApplication, self).__init__(client_id, **kwargs)
33+
self.client_credential = client_credential
34+
self.user_token_cache = user_token_cache
35+
self.app_token_cache = None # TODO
36+
37+
def acquire_token_for_client(self, scope, policy=''):
38+
return request.ClientCredentialRequest(
39+
client_id=self.client_id, client_credential=self.client_credential,
40+
scope=scope, policy=policy, authority=self.authority).run()
41+

msal/exceptions.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#------------------------------------------------------------------------------
2+
#
3+
# Copyright (c) Microsoft Corporation.
4+
# All rights reserved.
5+
#
6+
# This code is licensed under the MIT License.
7+
#
8+
# Permission is hereby granted, free of charge, to any person obtaining a copy
9+
# of this software and associated documentation files(the "Software"), to deal
10+
# in the Software without restriction, including without limitation the rights
11+
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12+
# copies of the Software, and to permit persons to whom the Software is
13+
# furnished to do so, subject to the following conditions :
14+
#
15+
# The above copyright notice and this permission notice shall be included in
16+
# all copies or substantial portions of the Software.
17+
#
18+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24+
# THE SOFTWARE.
25+
#
26+
#------------------------------------------------------------------------------
27+
28+
class MsalError(Exception):
29+
msg = 'An unspecified error'
30+
31+
def __init__(self, *args, **kwargs):
32+
super(MsalError, self).__init__(self.msg.format(**kwargs), *args)
33+
self.kwargs = kwargs
34+
35+
class MsalServiceError(MsalError):
36+
msg = "{error}: {error_description}"
37+

msal/oauth2.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""This OAuth2 client implementation aims to be spec-compliant, and generic."""
2+
# OAuth2 spec https://tools.ietf.org/html/rfc6749
3+
4+
try:
5+
from urllib.parse import urlencode, parse_qs
6+
except ImportError:
7+
from urlparse import parse_qs
8+
from urllib import urlencode
9+
10+
import requests
11+
12+
13+
class Client(object):
14+
# This low-level interface works. Yet you'll find those *Grant sub-classes
15+
# more friendly to remind you what parameters are needed in each scenario.
16+
def __init__(
17+
self, client_id,
18+
client_credential=None, # Only needed for Confidential Client
19+
authorization_endpoint=None, token_endpoint=None):
20+
self.client_id = client_id
21+
self.client_credential = client_credential
22+
self.authorization_endpoint = authorization_endpoint
23+
self.token_endpoint = token_endpoint
24+
25+
def authorization_url(self, response_type, **kwargs):
26+
params = {'client_id': self.client_id, 'response_type': response_type}
27+
params.update(kwargs)
28+
params = {k: v for k, v in params.items() if v is not None} # clean up
29+
sep = '&' if '?' in self.authorization_endpoint else '?'
30+
return "%s%s%s" % (self.authorization_endpoint, sep, urlencode(params))
31+
32+
def get_token(self, grant_type, **kwargs):
33+
data = {'client_id': self.client_id, 'grant_type': grant_type}
34+
data.update(kwargs)
35+
# We don't need to clean up None values here, because requests lib will.
36+
37+
# Quoted from https://tools.ietf.org/html/rfc6749#section-2.3.1
38+
# Clients in possession of a client password MAY use the HTTP Basic
39+
# authentication.
40+
# Alternatively, (but NOT RECOMMENDED,)
41+
# the authorization server MAY support including the
42+
# client credentials in the request-body using the following
43+
# parameters: client_id, client_secret.
44+
auth = None
45+
if self.client_credential and not 'client_secret' in data:
46+
auth = (self.client_id, self.client_credential) # HTTP Basic Auth
47+
48+
resp = requests.post(
49+
self.token_endpoint, headers={'Accept': 'application/json'},
50+
data=data, auth=auth)
51+
if resp.status_code>=500:
52+
resp.raise_for_status() # TODO: Will probably retry here
53+
# The spec (https://tools.ietf.org/html/rfc6749#section-5.2) says
54+
# even an error response will be a valid json structure,
55+
# so we simply return it here, without needing to invent an exception.
56+
return resp.json()
57+
58+
59+
class AuthorizationCodeGrant(Client):
60+
61+
def authorization_url(
62+
self, redirect_uri=None, scope=None, state=None, **kwargs):
63+
"""Generate an authorization url to be visited by resource owner.
64+
65+
:param response_type: MUST be set to "code" or "token".
66+
:param scope: It is a space-delimited, case-sensitive string.
67+
Some ID provider can accept empty string to represent default scope.
68+
"""
69+
return super(AuthorizationCodeGrant, self).authorization_url(
70+
'code', redirect_uri=redirect_uri, scope=scope, state=state,
71+
**kwargs)
72+
# Later when you receive the response at your redirect_uri,
73+
# validate_authorization() may be handy to check the returned state.
74+
75+
def get_token(self, code, redirect_uri=None, client_id=None, **kwargs):
76+
"""Get an access token.
77+
78+
See also https://tools.ietf.org/html/rfc6749#section-4.1.3
79+
80+
:param code: The authorization code received from authorization server.
81+
:param redirect_uri:
82+
Required, if the "redirect_uri" parameter was included in the
83+
authorization request, and their values MUST be identical.
84+
:param client_id: Required, if the client is not authenticating itself.
85+
See https://tools.ietf.org/html/rfc6749#section-3.2.1
86+
"""
87+
return super(AuthorizationCodeGrantFlow, self).get_token(
88+
'authorization_code', code=code,
89+
redirect_uri=redirect_uri, client_id=client_id, **kwargs)
90+
91+
92+
def validate_authorization(params, state=None):
93+
"""A thin helper to examine the authorization being redirected back"""
94+
if not isinstance(params, dict):
95+
params = parse_qs(params)
96+
if params.get('state') != state:
97+
raise ValueError('state mismatch')
98+
return params
99+
100+
101+
class ImplicitGrant(Client):
102+
# This class is only for illustrative purpose.
103+
# You probably won't implement your ImplicitGrant flow in Python anyway.
104+
def authorization_url(self, redirect_uri=None, scope=None, state=None):
105+
return super(ImplicitGrant, self).authorization_url('token', **locals())
106+
107+
def get_token(self):
108+
raise NotImplemented("Token is already issued during authorization")
109+
110+
111+
class ResourceOwnerPasswordCredentialsGrant(Client):
112+
113+
def authorization_url(self, **kwargs):
114+
raise NotImplemented(
115+
"You should have already obtained resource owner's password")
116+
117+
def get_token(self, username, password, scope=None, **kwargs):
118+
return super(ResourceOwnerPasswordCredentialsGrant, self).get_token(
119+
"password", username=username, password=password, scope=scope,
120+
**kwargs)
121+
122+
123+
class ClientCredentialGrant(Client):
124+
def authorization_url(self, **kwargs):
125+
# Since the client authentication is used as the authorization grant
126+
raise NotImplemented("No additional authorization request is needed")
127+
128+
def get_token(self, scope=None, **kwargs):
129+
return super(ClientCredentialGrant, self).get_token(
130+
"client_credentials", scope=scope, **kwargs)
131+

msal/request.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import time
2+
3+
from . import oauth2
4+
from .exceptions import MsalServiceError
5+
6+
7+
class BaseRequest(object):
8+
TOKEN_ENDPOINT_PATH = 'oauth2/v2.0/token'
9+
10+
def __init__(
11+
self, authority=None, token_cache=None, scope=None, policy="",
12+
client_id=None, client_credential=None, authenticator=None,
13+
support_adfs=False, restrict_to_single_user=False):
14+
if not scope:
15+
raise ValueError("scope cannot be empty")
16+
self.__dict__.update(locals())
17+
18+
def run(self):
19+
"""Returns a dictionary, which typically contains following keys:
20+
21+
* token: A string containing an access token (or id token)
22+
* expires_on: A timestamp, in seconds. So compare it with time.time().
23+
* user: TBD
24+
* and some other keys from the wire, such as "scope", "id_token", etc.,
25+
which may or may not appear in every different grant flow.
26+
So you should NOT assume their existence,
27+
instead you would need to access them safely by dict.get('...').
28+
"""
29+
# TODO Some cache stuff here
30+
raw = self.get_token()
31+
if 'error' in raw:
32+
raise MsalServiceError(**raw)
33+
# TODO: Deal with refresh_token
34+
35+
# Keep (most) contents in raw token response, extend it, and return it
36+
raw['token'] = raw.get('access_token') or raw.get('id_token')
37+
raw['expires_on'] = self.__timestamp(
38+
# A timestamp is chosen because it is more lighweight than Datetime,
39+
# and then the entire return value can be serialized as JSON string,
40+
# should the developers choose to do so.
41+
# This is the same timestamp format used in JWT's "iat", by the way.
42+
raw.get('expires_in') or raw.get('id_token_expires_in'))
43+
if 'scope' in raw:
44+
raw['scope'] = set(raw['scope'].split()) # Using SPACE as delimiter
45+
raw['user'] = { # Contents derived from raw['id_token']
46+
# TODO: Follow https://github.com/AzureAD/microsoft-authentication-library-for-android/blob/dev/msal/src/internal/java/com/microsoft/identity/client/IdToken.java
47+
# https://github.com/AzureAD/microsoft-authentication-library-for-android/blob/dev/msal/src/internal/java/com/microsoft/identity/client/User.java
48+
}
49+
return raw # equivalent to AuthenticationResult in other MSAL SDKs
50+
51+
def __timestamp(self, seconds_from_now=None): # Returns timestamp IN SECOND
52+
return time.time() + (
53+
seconds_from_now if seconds_from_now is not None else 3600)
54+
55+
def get_token(self):
56+
raise NotImplemented("Use proper sub-class instead")
57+
58+
59+
class ClientCredentialRequest(BaseRequest):
60+
def get_token(self):
61+
return oauth2.ClientCredentialGrant(
62+
self.client_id,
63+
token_endpoint="%s%s?policy=%s" % (
64+
self.authority, self.TOKEN_ENDPOINT_PATH, self.policy),
65+
).get_token(scope=self.scope, client_secret=self.client_credential)
66+

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
requests>=2,<3

tests/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import sys
2+
if sys.version_info[:2] < (2, 7):
3+
# The unittest module got a significant overhaul in Python 2.7,
4+
# so if we're in 2.6 we can use the backported version unittest2.
5+
import unittest2 as unittest
6+
else:
7+
import unittest
8+

tests/test_application.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from msal.application import ConfidentialClientApplication
2+
3+
from tests import unittest
4+
5+
6+
class TestConfidentialClientApplication(unittest.TestCase):
7+
def test_confidential_client_using_secret(self):
8+
app = ConfidentialClientApplication(
9+
"client_id", "client_secret", "TBD: TokenCache()")
10+
result = app.acquire_token_for_client(
11+
["r1/scope1", "r1/scope2"], "policy")
12+
self.assertIsNone(result)
13+

0 commit comments

Comments
 (0)