Skip to content

Commit

Permalink
Add support for Box Developer Edition.
Browse files Browse the repository at this point in the history
https://developers.box.com/developer-edition/

Adds a new auth class, JWTAuth, to handle the new auth types.
Adds a new client method, `create_user`, to facilitate creating app users.
  • Loading branch information
Jeff-Meadows committed Jul 6, 2015
1 parent 90e9af5 commit 423fa2a
Show file tree
Hide file tree
Showing 15 changed files with 552 additions and 2 deletions.
7 changes: 7 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
Release History
---------------

Upcoming
++++++++

- Added support for Box Developer Edition. This includes JWT auth (auth as enterprise or as app user),
and `create_user` functionality.


1.1.7 (2015-05-28)
++++++++++++++++++

Expand Down
51 changes: 51 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,57 @@ Metadata
metadata.update(update)
Box Developer Edition
---------------------

The Python SDK supports your
`Box Developer Edition <https://developers.box.com/developer-edition/>`__ applications.

Instead of instantiating your `Client` with an instance of `OAuth2`,
instead use an instance of `JWTAuth`.

.. code-block:: python
from boxsdk import JWTAuth
auth = JWTAuth(
client_id='YOUR_CLIENT_ID',
client_secret='YOUR_CLIENT_SECRET',
enterprise_token='YOUR_ENTERPRISE_TOKEN',
rsa_private_key_file_sys_path='CERT.PEM',
store_tokens=your_store_tokens_callback_method,
)
access_token = auth.authenticate_instance()
from boxsdk import Client
client = Client(auth)
This client is able to create application users:

.. code-block:: python
ned_stark_user = client.create_user('Ned Stark')
These users can then be authenticated:

.. code-block:: python
ned_auth = JWTAuth(
client_id='YOUR_CLIENT_ID',
client_secret='YOUR_CLIENT_SECRET',
enterprise_token='YOUR_ENTERPRISE_TOKEN',
rsa_private_key_file_sys_path='CERT.PEM',
store_tokens=your_store_tokens_callback_method,
)
ned_auth.authenticate_app_user(ned_stark_user)
ned_client = Client(ned_auth)
Requests made with `ned_client` (or objects returned from `ned_client`'s methods)
will be performed on behalf of the newly created app user.


Contributing
------------

Expand Down
1 change: 1 addition & 0 deletions boxsdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import unicode_literals

from .auth.jwt_auth import JWTAuth
from .auth.oauth2 import OAuth2
from .client import Client
from .object import * # pylint:disable=wildcard-import,redefined-builtin
180 changes: 180 additions & 0 deletions boxsdk/auth/jwt_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# coding: utf-8

from __future__ import unicode_literals
from datetime import datetime, timedelta
import jwt
from Crypto.PublicKey.RSA import importKey as import_key
import random
import string
from .oauth2 import OAuth2
from boxsdk.util.compat import total_seconds


class JWTAuth(OAuth2):
"""
Responsible for handling JWT Auth for Box Developer Edition. Can authenticate enterprise instances or app users.
"""
_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'

def __init__(
self,
client_id,
client_secret,
enterprise_id,
rsa_private_key_file_sys_path,
rsa_private_key_passphrase=None,
store_tokens=None,
box_device_id='0',
box_device_name='',
access_token=None,
network_layer=None,
jwt_algorithm='RS256',
):
"""
:param client_id:
Box API key used for identifying the application the user is authenticating with.
:type client_id:
`unicode`
:param client_secret:
Box API secret used for making OAuth2 requests.
:type client_secret:
`unicode`
:param enterprise_id:
The ID of the Box Developer Edition enterprise.
:type enterprise_id:
`unicode`
:param rsa_private_key_file_sys_path:
Path to an RSA private key file, used for signing the JWT assertion.
:type rsa_private_key_file_sys_path:
`unicode`
:param rsa_private_key_passphrase:
Passphrase used to unlock the private key.
:type rsa_private_key_passphrase:
`unicode`
:param store_tokens:
Optional callback for getting access to tokens for storing them.
:type store_tokens:
`callable`
:param box_device_id:
Optional unique ID of this device. Used for applications that want to support device-pinning.
:type box_device_id:
`unicode`
:param box_device_name:
Optional human readable name for this device.
:type box_device_name:
`unicode`
:param access_token:
Access token to use for auth until it expires.
:type access_token:
`unicode`
:param network_layer:
If specified, use it to make network requests. If not, the default network implementation will be used.
:type network_layer:
:class:`Network`
:param jwt_algorithm:
Which algorithm to use for signing the JWT assertion. Must be one of 'RS256', 'RS384', 'RS512'.
:type jwt_algorithm:
`unicode`
"""
super(JWTAuth, self).__init__(
client_id,
client_secret,
store_tokens=store_tokens,
box_device_id=box_device_id,
box_device_name=box_device_name,
access_token=access_token,
refresh_token=None,
network_layer=network_layer,
)
with open(rsa_private_key_file_sys_path) as key_file:
self._rsa_private_key = import_key(key_file.read(), rsa_private_key_passphrase).exportKey('PEM')
self._enterprise_token = enterprise_id
self._jwt_algorithm = jwt_algorithm
self._user_id = None

def _auth_with_jwt(self, sub, sub_type):
"""
Get an access token for use with Box Developer Edition. Pass an enterprise ID to get an enterprise token
(which can be used to provision/deprovision users), or a user ID to get an app user token.
:param sub:
The enterprise ID or user ID to auth.
:type sub:
`unicode`
:param sub_type:
Either 'enterprise' or 'user'
:type sub_type:
`unicode`
:return:
The access token for the enterprise or app user.
:rtype:
`unicode`
"""
system_random = random.SystemRandom()
jti_length = system_random.randint(16, 128)
ascii_alphabet = string.ascii_letters + string.digits
ascii_len = len(ascii_alphabet)
jti = ''.join(ascii_alphabet[int(system_random.random() * ascii_len)] for _ in range(jti_length))
now_plus_30 = datetime.utcnow() + timedelta(seconds=30)
assertion = jwt.encode(
{
'iss': self._client_id,
'sub': sub,
'box_sub_type': sub_type,
'aud': 'https://api.box.com/oauth2/token',
'jti': jti,
'exp': int(total_seconds(now_plus_30 - datetime(1970, 1, 1))),
},
self._rsa_private_key,
algorithm=self._jwt_algorithm,
)
data = {
'grant_type': self._GRANT_TYPE,
'client_id': self._client_id,
'client_secret': self._client_secret,
'assertion': assertion,
}
if self._box_device_id:
data['box_device_id'] = self._box_device_id
if self._box_device_name:
data['box_device_name'] = self._box_device_name
return self.send_token_request(data, access_token=None, expect_refresh_token=False)[0]

def authenticate_app_user(self, user):
"""
Get an access token for an App User (part of Box Developer Edition).
:param user:
The user to authenticate.
:type user:
:class:`User`
:return:
The access token for the app user.
:rtype:
`unicode`
"""
sub = self._user_id = user.object_id
return self._auth_with_jwt(sub, 'user')

def authenticate_instance(self):
"""
Get an access token for a Box Developer Edition enterprise.
:return:
The access token for the enterprise which can provision/deprovision app users.
:rtype:
`unicode`
"""
return self._auth_with_jwt(self._enterprise_token, 'enterprise')

def _refresh(self, access_token):
"""
Base class override.
Instead of refreshing an access token using a refresh token, we just issue a new JWT request.
"""
# pylint:disable=unused-argument
if self._user_id is None:
return self.authenticate_instance()
else:
return self._auth_with_jwt(self._user_id, 'user')
6 changes: 4 additions & 2 deletions boxsdk/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ def _get_state_csrf_token():
ascii_len = len(ascii_alphabet)
return 'box_csrf_token_' + ''.join(ascii_alphabet[int(system_random.random() * ascii_len)] for _ in range(16))

def send_token_request(self, data, access_token):
def send_token_request(self, data, access_token, expect_refresh_token=True):
"""
Send the request to acquire or refresh an access token.
Expand Down Expand Up @@ -226,7 +226,9 @@ def send_token_request(self, data, access_token):
try:
response = network_response.json()
self._access_token = response['access_token']
self._refresh_token = response['refresh_token']
self._refresh_token = response.get('refresh_token', None)
if self._refresh_token is None and expect_refresh_token:
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
except (ValueError, KeyError):
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
if self._store_tokens:
Expand Down
28 changes: 28 additions & 0 deletions boxsdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,31 @@ def make_request(self, method, url, **kwargs):
:class:`BoxAPIException`
"""
return self._session.request(method, url, **kwargs)

def create_user(self, name, login=None, **user_attributes):
"""
Create a new user. Can only be used if the current user is an enterprise admin, or the current authorization
scope is a Box developer edition instance.
:param name:
The user's display name.
:type name:
`unicode`
:param login:
The user's email address. Required for an enterprise user, but None for an app user.
:type login:
`unicode` or None
:param user_attributes:
Additional attributes for the user. See the documentation at
https://box-content.readme.io/#create-an-enterprise-user for enterprise users
or https://developers.box.com/developer-edition/ for app users.
"""
url = '{0}/users'.format(API.BASE_API_URL)
user_attributes['name'] = name
if login is not None:
user_attributes['login'] = login
else:
user_attributes['is_platform_access_only'] = True
box_response = self._session.post(url, data=json.dumps(user_attributes))
response = box_response.json()
return User(self._session, response['id'], response)
13 changes: 13 additions & 0 deletions boxsdk/util/compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# coding: utf-8

from __future__ import division, unicode_literals


from datetime import timedelta

if not hasattr(timedelta, 'total_seconds'):
def total_seconds(delta):
return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 10 ** 6) / 10 ** 6
else:
def total_seconds(delta):
return delta.total_seconds()
8 changes: 8 additions & 0 deletions docs/source/boxsdk.auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ boxsdk.auth package
Submodules
----------

boxsdk.auth.jwt_auth module
---------------------------

.. automodule:: boxsdk.auth.jwt_auth
:members:
:undoc-members:
:show-inheritance:

boxsdk.auth.oauth2 module
-------------------------

Expand Down
8 changes: 8 additions & 0 deletions docs/source/boxsdk.util.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ boxsdk.util package
Submodules
----------

boxsdk.util.compat module
-------------------------

.. automodule:: boxsdk.util.compat
:members:
:undoc-members:
:show-inheritance:

boxsdk.util.lru_cache module
----------------------------

Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
enum34>=1.0.4
ordereddict>=1.1
pycrypto>=2.6.1
pyjwt>=1.3.0
requests>=2.4.3
six >= 1.4.0
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def main():
url='http://opensource.box.com',
packages=find_packages(exclude=['demo', 'docs', 'test']),
install_requires=install_requires,
extras_require={'jwt': ['pycrypto>=2.6.1', 'pyjwt>=1.3.0']},
tests_require=['pytest', 'pytest-xdist', 'mock', 'sqlalchemy', 'bottle', 'jsonpatch'],
cmdclass={'test': PyTest},
classifiers=CLASSIFIERS,
Expand Down
Loading

0 comments on commit 423fa2a

Please sign in to comment.