Skip to content
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

Defining interfaces #3

Merged
merged 14 commits into from
Oct 10, 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
158 changes: 141 additions & 17 deletions msal/application.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,78 @@
from . import oauth2
from .authority import Authority
from .request import decorate_scope
from .client_credential import ClientCredentialRequest


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

def __init__(
self, client_id,
validate_authority=True, authority=DEFAULT_AUTHORITY):
authority_url="https://login.microsoftonline.com/common/",
validate_authority=True):
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
self.authority = Authority(authority_url, validate_authority)

def acquire_token_silent(
self, scope,
user=None, # It can be a string as user id, or a User object
authority=None, # See get_authorization_request_url()
policy='',
force_refresh=False, # To force refresh an Access Token (not a RT)
**kwargs):
a = Authority(authority) if authority else self.authority
client = oauth2.Client(self.client_id, token_endpoint=a.token_endpoint)
refresh_token = kwargs.get('refresh_token') # For testing purpose
response = client.get_token_by_refresh_token(
refresh_token,
scope=decorate_scope(scope, self.client_id, policy),
client_secret=getattr(self, 'client_credential'), # TODO: JWT too
query={'policy': policy} if policy else None)
# TODO: refresh the refresh_token
return response

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

class ConfidentialClientApplication(ClientApplication):
def __init__(self, client_id, client_credential, user_token_cache, **kwargs):
## TBD: what if redirect_uri is not needed in the constructor at all?
## Device Code flow does not need redirect_uri anyway.

# OUT_OF_BAND = "urn:ietf:wg:oauth:2.0:oob"
# def __init__(self, client_id, redirect_uri=None, **kwargs):
# super(PublicClientApplication, self).__init__(client_id, **kwargs)
# self.redirect_uri = redirect_uri or self.OUT_OF_BAND

def acquire_token(
self,
scope,
# additional_scope=None, # See also get_authorization_request_url()
login_hint=None,
ui_options=None,
# user=None, # TBD: It exists in MSAL-dotnet but not in MSAL-Android
policy='',
authority=None, # See get_authorization_request_url()
extra_query_params=None,
):
# It will handle the TWO round trips of Authorization Code Grant flow.
raise NotImplemented()

Copy link
Collaborator Author

@rayluo rayluo Sep 23, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incline to add 2 more methods for device code grant here, rather than creating a dedicated sub class DeviceCodeClient.

# TODO: Support Device Code flow


class ConfidentialClientApplication(ClientApplication): # server-side web app
def __init__(
self, client_id, client_credential, user_token_cache=None,
# redirect_uri=None, # Experimental: Removed for now.
# acquire_token_for_client() doesn't need it
**kwargs):
"""
:param client_credential: It can be a string containing client secret,
or an X509 certificate object.
or an X509 certificate container in this form:

{
"certificate": "-----BEGIN PRIVATE KEY-----...",
"thumbprint": "A1B2C3D4E5F6...",
}
"""
super(ConfidentialClientApplication, self).__init__(client_id, **kwargs)
self.client_credential = client_credential
Expand All @@ -37,5 +82,84 @@ def __init__(self, client_id, client_credential, user_token_cache, **kwargs):
def acquire_token_for_client(self, scope, policy=''):
return ClientCredentialRequest(
client_id=self.client_id, client_credential=self.client_credential,
scope=scope, policy=policy, authority=self.authority).run()
scope=scope, # This grant flow requires no scope decoration
policy=policy, authority=self.authority).run()

def get_authorization_request_url(
self,
scope,
# additional_scope=None, # Not yet implemented
login_hint=None,
state=None, # Recommended by OAuth2 for CSRF protection
policy='',
redirect_uri=None,
authority=None, # By default, it will use self.authority;
# Multi-tenant app can use new authority on demand
extra_query_params=None, # None or a dictionary
):
"""Constructs a URL for you to start a Authorization Code Grant.

:param scope: Scope refers to the resource that will be used in the
resulting token's audience.
:param additional_scope: Additional scope is a concept only in AAD.
It refers to other resources you might want to prompt to consent
for in the same interaction, but for which you won't get back a
token for in this particular operation.
(Under the hood, we simply merge scope and additional_scope before
sending them on the wire.)
:param str state: Recommended by OAuth2 for CSRF protection.
"""
a = Authority(authority) if authority else self.authority
grant = oauth2.AuthorizationCodeGrant(
self.client_id, authorization_endpoint=a.authorization_endpoint)
return grant.authorization_url(
redirect_uri=redirect_uri, state=state, login_hint=login_hint,
scope=decorate_scope(scope, self.client_id, policy),
policy=policy if policy else None,
**(extra_query_params or {}))

def acquire_token_by_authorization_code(
self,
code,
scope, # Syntactically required. STS accepts empty value though.
redirect_uri=None,
# REQUIRED, if the "redirect_uri" parameter was included in the
# authorization request as described in Section 4.1.1, and their
# values MUST be identical.
policy=''
):
"""The second half of the Authorization Code Grant.

:param code: The authorization code returned from Authorization Server.
:param scope:

If you requested user consent for multiple resources, here you will
typically want to provide a subset of what you required in AC.

OAuth2 was designed mostly for singleton services,
where tokens are always meant for the same resource and the only
changes are in the scopes.
In AAD, tokens can be issued for multiple 3rd parth resources.
You can ask authorization code for multiple resources,
but when you redeem it, the token is for only one intended
recipient, called audience.
So the developer need to specify a scope so that we can restrict the
token to be issued for the corresponding audience.
"""
# If scope is absent on the wire, STS will give you a token associated
# to the FIRST scope sent during the authorization request.
# So in theory, you can omit scope here when you were working with only
# one scope. But, MSAL decorates your scope anyway, so they are never
# really empty.
grant = oauth2.AuthorizationCodeGrant(
self.client_id, token_endpoint=self.authority.token_endpoint)
return grant.get_token(
code, redirect_uri=redirect_uri,
scope=decorate_scope(scope, self.client_id, policy),
client_secret=self.client_credential, # TODO: Support certificate
query={'policy': policy} if policy else None)

def acquire_token_on_behalf_of(
self, user_assertion, scope, authority=None, policy=''):
pass

9 changes: 9 additions & 0 deletions msal/authority.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class Authority(object):
def __init__(self, authority_url, validate=True, **kwargs):
if validate and not authority_url.lower().startswith('https'):
raise ValueError("authority_url should start with https")
if authority_url.endswith('/'): # trim it
authority_url = authority_url[:-1]
self.authorization_endpoint = authority_url + "/oauth2/v2.0/authorize"
self.token_endpoint = authority_url + "/oauth2/v2.0/token"

2 changes: 1 addition & 1 deletion msal/client_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class ClientCredentialRequest(BaseRequest):
def __init__(self, **kwargs):
super(ClientCredentialRequest, self).__init__(**kwargs)
self.grant = ClientCredentialGrant(
self.client_id, token_endpoint=self.token_endpoint)
self.client_id, token_endpoint=self.authority.token_endpoint)

def get_token(self):
if isinstance(self.client_credential, dict):
Expand Down
5 changes: 3 additions & 2 deletions msal/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@
#------------------------------------------------------------------------------

class MsalError(Exception):
msg = 'An unspecified error'
# Define the template in Unicode to accommodate possible Unicode variables
msg = u'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}"
msg = u"{error}: {error_description}"

85 changes: 53 additions & 32 deletions msal/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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.
# More on Client Types at https://tools.ietf.org/html/rfc6749#section-2.1
def __init__(
self, client_id,
client_credential=None, # Only needed for Confidential Client
Expand All @@ -22,18 +23,24 @@ def __init__(
self.authorization_endpoint = authorization_endpoint
self.token_endpoint = token_endpoint

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

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

if data.get('scope'):
data['scope'] = normalize_to_string(data['scope'])

# 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.
Expand All @@ -42,37 +49,49 @@ def get_token(self, grant_type, **kwargs):
# 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
if (self.client_credential and data.get('client_id')
and 'client_secret' not in data):
auth = (data['client_id'], self.client_credential) # HTTP Basic Auth

assert self.token_endpoint, "You need to provide token_endpoint"
resp = requests.post(
self.token_endpoint, headers={'Accept': 'application/json'},
data=data, auth=auth)
params=query, 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()

def get_token_by_refresh_token(self, refresh_token, scope=None, **kwargs):
return self._get_token(
"refresh_token", refresh_token=refresh_token, scope=scope, **kwargs)


def normalize_to_string(scope):
return ' '.join(scope) if isinstance(scope, (list, set, tuple)) else scope


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

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 redirect_uri: Optional. Server will use the pre-registered one.
: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(
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):
def get_token(self, code, redirect_uri=None, **kwargs):
"""Get an access token.

See also https://tools.ietf.org/html/rfc6749#section-4.1.3
Expand All @@ -84,9 +103,9 @@ def get_token(self, code, redirect_uri=None, client_id=None, **kwargs):
: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(
return super(AuthorizationCodeGrant, self)._get_token(
'authorization_code', code=code,
redirect_uri=redirect_uri, client_id=client_id, **kwargs)
redirect_uri=redirect_uri, **kwargs)


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


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())
"""Implicit Grant is used to obtain access tokens (but not refresh token).

def get_token(self):
raise NotImplemented("Token is already issued during authorization")


class ResourceOwnerPasswordCredentialsGrant(Client):
It is optimized for public clients known to operate a particular
redirection URI. These clients are typically implemented in a browser
using a scripting language such as JavaScript.
Quoted from https://tools.ietf.org/html/rfc6749#section-4.2
"""
def authorization_url(self, redirect_uri=None, scope=None, state=None):
return super(ImplicitGrant, self)._authorization_url(
'token', **locals())

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

class ResourceOwnerPasswordCredentialsGrant(Client): # Legacy Application flow
def get_token(self, username, password, scope=None, **kwargs):
return super(ResourceOwnerPasswordCredentialsGrant, self).get_token(
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")
class ClientCredentialGrant(Client): # a.k.a. Backend Application flow
def get_token(self, client_secret=None, scope=None, **kwargs):
'''Get token by client credential.

def get_token(self, scope=None, **kwargs):
return super(ClientCredentialGrant, self).get_token(
"client_credentials", scope=scope, **kwargs)
:param client_secret:
You may explicitly provide it, so that it will show up in http body;
Or you may skip it, the base class will use self.client_credentials;
Or you may skip it and provide other parameters required by your AS.
'''
return super(ClientCredentialGrant, self)._get_token(
"client_credentials", client_secret=client_secret, scope=scope,
**kwargs)

Loading