Skip to content

Commit bab4d16

Browse files
authored
Merge pull request #3 from AzureAD/defining-interfaces
Merge these interfaces now, so that new developments will be built on top of them.
2 parents 4f00884 + 0c1bb4a commit bab4d16

File tree

7 files changed

+286
-67
lines changed

7 files changed

+286
-67
lines changed

msal/application.py

+141-17
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,78 @@
1+
from . import oauth2
2+
from .authority import Authority
3+
from .request import decorate_scope
14
from .client_credential import ClientCredentialRequest
25

36

47
class ClientApplication(object):
5-
DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common/"
68

79
def __init__(
810
self, client_id,
9-
validate_authority=True, authority=DEFAULT_AUTHORITY):
11+
authority_url="https://login.microsoftonline.com/common/",
12+
validate_authority=True):
1013
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
14+
self.authority = Authority(authority_url, validate_authority)
1715

16+
def acquire_token_silent(
17+
self, scope,
18+
user=None, # It can be a string as user id, or a User object
19+
authority=None, # See get_authorization_request_url()
20+
policy='',
21+
force_refresh=False, # To force refresh an Access Token (not a RT)
22+
**kwargs):
23+
a = Authority(authority) if authority else self.authority
24+
client = oauth2.Client(self.client_id, token_endpoint=a.token_endpoint)
25+
refresh_token = kwargs.get('refresh_token') # For testing purpose
26+
response = client.get_token_by_refresh_token(
27+
refresh_token,
28+
scope=decorate_scope(scope, self.client_id, policy),
29+
client_secret=getattr(self, 'client_credential'), # TODO: JWT too
30+
query={'policy': policy} if policy else None)
31+
# TODO: refresh the refresh_token
32+
return response
1833

19-
class PublicClientApplication(ClientApplication):
20-
DEFAULT_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"
2134

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
35+
class PublicClientApplication(ClientApplication): # browser app or mobile app
2536

26-
class ConfidentialClientApplication(ClientApplication):
27-
def __init__(self, client_id, client_credential, user_token_cache, **kwargs):
37+
## TBD: what if redirect_uri is not needed in the constructor at all?
38+
## Device Code flow does not need redirect_uri anyway.
39+
40+
# OUT_OF_BAND = "urn:ietf:wg:oauth:2.0:oob"
41+
# def __init__(self, client_id, redirect_uri=None, **kwargs):
42+
# super(PublicClientApplication, self).__init__(client_id, **kwargs)
43+
# self.redirect_uri = redirect_uri or self.OUT_OF_BAND
44+
45+
def acquire_token(
46+
self,
47+
scope,
48+
# additional_scope=None, # See also get_authorization_request_url()
49+
login_hint=None,
50+
ui_options=None,
51+
# user=None, # TBD: It exists in MSAL-dotnet but not in MSAL-Android
52+
policy='',
53+
authority=None, # See get_authorization_request_url()
54+
extra_query_params=None,
55+
):
56+
# It will handle the TWO round trips of Authorization Code Grant flow.
57+
raise NotImplemented()
58+
59+
# TODO: Support Device Code flow
60+
61+
62+
class ConfidentialClientApplication(ClientApplication): # server-side web app
63+
def __init__(
64+
self, client_id, client_credential, user_token_cache=None,
65+
# redirect_uri=None, # Experimental: Removed for now.
66+
# acquire_token_for_client() doesn't need it
67+
**kwargs):
2868
"""
2969
:param client_credential: It can be a string containing client secret,
30-
or an X509 certificate object.
70+
or an X509 certificate container in this form:
71+
72+
{
73+
"certificate": "-----BEGIN PRIVATE KEY-----...",
74+
"thumbprint": "A1B2C3D4E5F6...",
75+
}
3176
"""
3277
super(ConfidentialClientApplication, self).__init__(client_id, **kwargs)
3378
self.client_credential = client_credential
@@ -37,5 +82,84 @@ def __init__(self, client_id, client_credential, user_token_cache, **kwargs):
3782
def acquire_token_for_client(self, scope, policy=''):
3883
return ClientCredentialRequest(
3984
client_id=self.client_id, client_credential=self.client_credential,
40-
scope=scope, policy=policy, authority=self.authority).run()
85+
scope=scope, # This grant flow requires no scope decoration
86+
policy=policy, authority=self.authority).run()
87+
88+
def get_authorization_request_url(
89+
self,
90+
scope,
91+
# additional_scope=None, # Not yet implemented
92+
login_hint=None,
93+
state=None, # Recommended by OAuth2 for CSRF protection
94+
policy='',
95+
redirect_uri=None,
96+
authority=None, # By default, it will use self.authority;
97+
# Multi-tenant app can use new authority on demand
98+
extra_query_params=None, # None or a dictionary
99+
):
100+
"""Constructs a URL for you to start a Authorization Code Grant.
101+
102+
:param scope: Scope refers to the resource that will be used in the
103+
resulting token's audience.
104+
:param additional_scope: Additional scope is a concept only in AAD.
105+
It refers to other resources you might want to prompt to consent
106+
for in the same interaction, but for which you won't get back a
107+
token for in this particular operation.
108+
(Under the hood, we simply merge scope and additional_scope before
109+
sending them on the wire.)
110+
:param str state: Recommended by OAuth2 for CSRF protection.
111+
"""
112+
a = Authority(authority) if authority else self.authority
113+
grant = oauth2.AuthorizationCodeGrant(
114+
self.client_id, authorization_endpoint=a.authorization_endpoint)
115+
return grant.authorization_url(
116+
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
117+
scope=decorate_scope(scope, self.client_id, policy),
118+
policy=policy if policy else None,
119+
**(extra_query_params or {}))
120+
121+
def acquire_token_by_authorization_code(
122+
self,
123+
code,
124+
scope, # Syntactically required. STS accepts empty value though.
125+
redirect_uri=None,
126+
# REQUIRED, if the "redirect_uri" parameter was included in the
127+
# authorization request as described in Section 4.1.1, and their
128+
# values MUST be identical.
129+
policy=''
130+
):
131+
"""The second half of the Authorization Code Grant.
132+
133+
:param code: The authorization code returned from Authorization Server.
134+
:param scope:
135+
136+
If you requested user consent for multiple resources, here you will
137+
typically want to provide a subset of what you required in AC.
138+
139+
OAuth2 was designed mostly for singleton services,
140+
where tokens are always meant for the same resource and the only
141+
changes are in the scopes.
142+
In AAD, tokens can be issued for multiple 3rd parth resources.
143+
You can ask authorization code for multiple resources,
144+
but when you redeem it, the token is for only one intended
145+
recipient, called audience.
146+
So the developer need to specify a scope so that we can restrict the
147+
token to be issued for the corresponding audience.
148+
"""
149+
# If scope is absent on the wire, STS will give you a token associated
150+
# to the FIRST scope sent during the authorization request.
151+
# So in theory, you can omit scope here when you were working with only
152+
# one scope. But, MSAL decorates your scope anyway, so they are never
153+
# really empty.
154+
grant = oauth2.AuthorizationCodeGrant(
155+
self.client_id, token_endpoint=self.authority.token_endpoint)
156+
return grant.get_token(
157+
code, redirect_uri=redirect_uri,
158+
scope=decorate_scope(scope, self.client_id, policy),
159+
client_secret=self.client_credential, # TODO: Support certificate
160+
query={'policy': policy} if policy else None)
161+
162+
def acquire_token_on_behalf_of(
163+
self, user_assertion, scope, authority=None, policy=''):
164+
pass
41165

msal/authority.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class Authority(object):
2+
def __init__(self, authority_url, validate=True, **kwargs):
3+
if validate and not authority_url.lower().startswith('https'):
4+
raise ValueError("authority_url should start with https")
5+
if authority_url.endswith('/'): # trim it
6+
authority_url = authority_url[:-1]
7+
self.authorization_endpoint = authority_url + "/oauth2/v2.0/authorize"
8+
self.token_endpoint = authority_url + "/oauth2/v2.0/token"
9+

msal/client_credential.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class ClientCredentialRequest(BaseRequest):
1313
def __init__(self, **kwargs):
1414
super(ClientCredentialRequest, self).__init__(**kwargs)
1515
self.grant = ClientCredentialGrant(
16-
self.client_id, token_endpoint=self.token_endpoint)
16+
self.client_id, token_endpoint=self.authority.token_endpoint)
1717

1818
def get_token(self):
1919
if isinstance(self.client_credential, dict):

msal/exceptions.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@
2626
#------------------------------------------------------------------------------
2727

2828
class MsalError(Exception):
29-
msg = 'An unspecified error'
29+
# Define the template in Unicode to accommodate possible Unicode variables
30+
msg = u'An unspecified error'
3031

3132
def __init__(self, *args, **kwargs):
3233
super(MsalError, self).__init__(self.msg.format(**kwargs), *args)
3334
self.kwargs = kwargs
3435

3536
class MsalServiceError(MsalError):
36-
msg = "{error}: {error_description}"
37+
msg = u"{error}: {error_description}"
3738

msal/oauth2.py

+53-32
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
class Client(object):
1414
# This low-level interface works. Yet you'll find those *Grant sub-classes
1515
# more friendly to remind you what parameters are needed in each scenario.
16+
# More on Client Types at https://tools.ietf.org/html/rfc6749#section-2.1
1617
def __init__(
1718
self, client_id,
1819
client_credential=None, # Only needed for Confidential Client
@@ -22,18 +23,24 @@ def __init__(
2223
self.authorization_endpoint = authorization_endpoint
2324
self.token_endpoint = token_endpoint
2425

25-
def authorization_url(self, response_type, **kwargs):
26+
def _authorization_url(self, response_type, **kwargs):
27+
# response_type can be set to "code" or "token".
2628
params = {'client_id': self.client_id, 'response_type': response_type}
27-
params.update(kwargs)
29+
params.update(kwargs) # Note: None values will override params
2830
params = {k: v for k, v in params.items() if v is not None} # clean up
31+
if params.get('scope'):
32+
params['scope'] = normalize_to_string(params['scope'])
2933
sep = '&' if '?' in self.authorization_endpoint else '?'
3034
return "%s%s%s" % (self.authorization_endpoint, sep, urlencode(params))
3135

32-
def get_token(self, grant_type, **kwargs):
36+
def _get_token(self, grant_type, query=None, **kwargs):
3337
data = {'client_id': self.client_id, 'grant_type': grant_type}
34-
data.update(kwargs)
38+
data.update(kwargs) # Note: None values will override data
3539
# We don't need to clean up None values here, because requests lib will.
3640

41+
if data.get('scope'):
42+
data['scope'] = normalize_to_string(data['scope'])
43+
3744
# Quoted from https://tools.ietf.org/html/rfc6749#section-2.3.1
3845
# Clients in possession of a client password MAY use the HTTP Basic
3946
# authentication.
@@ -42,37 +49,49 @@ def get_token(self, grant_type, **kwargs):
4249
# client credentials in the request-body using the following
4350
# parameters: client_id, client_secret.
4451
auth = None
45-
if self.client_credential and not 'client_secret' in data:
46-
auth = (self.client_id, self.client_credential) # HTTP Basic Auth
52+
if (self.client_credential and data.get('client_id')
53+
and 'client_secret' not in data):
54+
auth = (data['client_id'], self.client_credential) # HTTP Basic Auth
4755

56+
assert self.token_endpoint, "You need to provide token_endpoint"
4857
resp = requests.post(
4958
self.token_endpoint, headers={'Accept': 'application/json'},
50-
data=data, auth=auth)
59+
params=query, data=data, auth=auth)
5160
if resp.status_code>=500:
5261
resp.raise_for_status() # TODO: Will probably retry here
5362
# The spec (https://tools.ietf.org/html/rfc6749#section-5.2) says
5463
# even an error response will be a valid json structure,
5564
# so we simply return it here, without needing to invent an exception.
5665
return resp.json()
5766

67+
def get_token_by_refresh_token(self, refresh_token, scope=None, **kwargs):
68+
return self._get_token(
69+
"refresh_token", refresh_token=refresh_token, scope=scope, **kwargs)
70+
71+
72+
def normalize_to_string(scope):
73+
return ' '.join(scope) if isinstance(scope, (list, set, tuple)) else scope
74+
5875

5976
class AuthorizationCodeGrant(Client):
77+
# Can be used by Confidential Client or Public Client.
78+
# See https://tools.ietf.org/html/rfc6749#section-4.1.3
6079

6180
def authorization_url(
6281
self, redirect_uri=None, scope=None, state=None, **kwargs):
6382
"""Generate an authorization url to be visited by resource owner.
6483
65-
:param response_type: MUST be set to "code" or "token".
84+
:param redirect_uri: Optional. Server will use the pre-registered one.
6685
:param scope: It is a space-delimited, case-sensitive string.
6786
Some ID provider can accept empty string to represent default scope.
6887
"""
69-
return super(AuthorizationCodeGrant, self).authorization_url(
88+
return super(AuthorizationCodeGrant, self)._authorization_url(
7089
'code', redirect_uri=redirect_uri, scope=scope, state=state,
7190
**kwargs)
7291
# Later when you receive the response at your redirect_uri,
7392
# validate_authorization() may be handy to check the returned state.
7493

75-
def get_token(self, code, redirect_uri=None, client_id=None, **kwargs):
94+
def get_token(self, code, redirect_uri=None, **kwargs):
7695
"""Get an access token.
7796
7897
See also https://tools.ietf.org/html/rfc6749#section-4.1.3
@@ -84,9 +103,9 @@ def get_token(self, code, redirect_uri=None, client_id=None, **kwargs):
84103
:param client_id: Required, if the client is not authenticating itself.
85104
See https://tools.ietf.org/html/rfc6749#section-3.2.1
86105
"""
87-
return super(AuthorizationCodeGrantFlow, self).get_token(
106+
return super(AuthorizationCodeGrant, self)._get_token(
88107
'authorization_code', code=code,
89-
redirect_uri=redirect_uri, client_id=client_id, **kwargs)
108+
redirect_uri=redirect_uri, **kwargs)
90109

91110

92111
def validate_authorization(params, state=None):
@@ -99,33 +118,35 @@ def validate_authorization(params, state=None):
99118

100119

101120
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())
121+
"""Implicit Grant is used to obtain access tokens (but not refresh token).
106122
107-
def get_token(self):
108-
raise NotImplemented("Token is already issued during authorization")
109-
110-
111-
class ResourceOwnerPasswordCredentialsGrant(Client):
123+
It is optimized for public clients known to operate a particular
124+
redirection URI. These clients are typically implemented in a browser
125+
using a scripting language such as JavaScript.
126+
Quoted from https://tools.ietf.org/html/rfc6749#section-4.2
127+
"""
128+
def authorization_url(self, redirect_uri=None, scope=None, state=None):
129+
return super(ImplicitGrant, self)._authorization_url(
130+
'token', **locals())
112131

113-
def authorization_url(self, **kwargs):
114-
raise NotImplemented(
115-
"You should have already obtained resource owner's password")
116132

133+
class ResourceOwnerPasswordCredentialsGrant(Client): # Legacy Application flow
117134
def get_token(self, username, password, scope=None, **kwargs):
118-
return super(ResourceOwnerPasswordCredentialsGrant, self).get_token(
135+
return super(ResourceOwnerPasswordCredentialsGrant, self)._get_token(
119136
"password", username=username, password=password, scope=scope,
120137
**kwargs)
121138

122139

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")
140+
class ClientCredentialGrant(Client): # a.k.a. Backend Application flow
141+
def get_token(self, client_secret=None, scope=None, **kwargs):
142+
'''Get token by client credential.
127143
128-
def get_token(self, scope=None, **kwargs):
129-
return super(ClientCredentialGrant, self).get_token(
130-
"client_credentials", scope=scope, **kwargs)
144+
:param client_secret:
145+
You may explicitly provide it, so that it will show up in http body;
146+
Or you may skip it, the base class will use self.client_credentials;
147+
Or you may skip it and provide other parameters required by your AS.
148+
'''
149+
return super(ClientCredentialGrant, self)._get_token(
150+
"client_credentials", client_secret=client_secret, scope=scope,
151+
**kwargs)
131152

0 commit comments

Comments
 (0)