diff --git a/.flake8 b/.flake8 index 62bb0b5a..06d83ee9 100644 --- a/.flake8 +++ b/.flake8 @@ -10,7 +10,7 @@ # E402: module level import not at top of file # I100: Import statements are in the wrong order # I101: Imported names are in the wrong order. Should be -ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101 +ignore = E, C, W, F401, F403, F811, F841, E402, I100, I101, D400 exclude = .cache, diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index aa395edd..e943439e 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -5,14 +5,20 @@ from datetime import datetime import json -from urllib.parse import quote +from urllib.parse import ( + parse_qsl, + quote, + urlencode, + urlparse, + urlunparse, +) -from oauth2.web.tornado import OAuth2Handler +from oauthlib import oauth2 from tornado import web from .. import orm from ..user import User -from ..utils import token_authenticated +from ..utils import token_authenticated, compare_token from .base import BaseHandler, APIHandler @@ -98,24 +104,190 @@ class CookieAPIHandler(APIHandler): self.write(json.dumps(self.user_model(user))) -class OAuthHandler(BaseHandler, OAuth2Handler): - """Implement OAuth provider handlers +class OAuthHandler: + def extract_oauth_params(self): + """extract oauthlib params from a request - OAuth2Handler sets `self.provider` in initialize, - but we are already passing the Provider object via settings. - """ - @property - def provider(self): - return self.settings['oauth_provider'] + Returns: - def initialize(self): - pass + (uri, http_method, body, headers) + """ + return ( + self.request.uri, + self.request.method, + self.request.body, + self.request.headers, + ) + + def make_absolute_redirect_uri(self, uri): + """Make absolute redirect URIs + + internal redirect uris, e.g. `/user/foo/oauth_handler` + are allowed in jupyterhub, but oauthlib prohibits them. + Add `$HOST` header to redirect_uri to make them acceptable. + + Currently unused in favor of monkeypatching + oauthlib.is_absolute_uri to skip the check + """ + redirect_uri = self.get_argument('redirect_uri') + if not redirect_uri or not redirect_uri.startswith('/'): + return uri + # make absolute local redirects full URLs + # to satisfy oauthlib's absolute URI requirement + redirect_uri = self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri + parsed_url = urlparse(uri) + query_list = parse_qsl(parsed_url.query, keep_blank_values=True) + for idx, item in enumerate(query_list): + if item[0] == 'redirect_uri': + query_list[idx] = ('redirect_uri', redirect_uri) + break + + return urlunparse( + urlparse(uri) + ._replace(query=urlencode(query_list)) + ) + + def add_credentials(self, credentials=None): + """Add oauth credentials + + Adds user, session_id, client to oauth credentials + """ + if credentials is None: + credentials = {} + else: + credentials = credentials.copy() + + session_id = self.get_session_cookie() + if session_id is None: + session_id = self.set_session_cookie() + + user = self.get_current_user() + + # Extra credentials we need in the validator + credentials.update({ + 'user': user, + 'handler': self, + 'session_id': session_id, + }) + return credentials + + def send_oauth_response(self, headers, body, status): + """Send oauth response from provider return values + + Provider methods return headers, body, and status + to be set on the response. + + This method applies these values to the Handler + and sends the response. + """ + self.set_status(status) + for key, value in headers.items(): + self.set_header(key, value) + if body: + self.write(body) + + +class OAuthAuthorizeHandler(OAuthHandler, BaseHandler): + """Implement OAuth authorization endpoint(s)""" + + def _complete_login(self, uri, headers, scopes, credentials): + try: + headers, body, status = self.oauth_provider.create_authorization_response( + uri, 'POST', '', headers, scopes, credentials) + + except oauth2.FatalClientError as e: + # TODO: human error page + raise + self.send_oauth_response(headers, body, status) + + @web.authenticated + def get(self): + """GET /oauth/authorization + + Render oauth confirmation page: + "Server at ... would like permission to ...". + + Users accessing their own server will skip confirmation. + """ + + uri, http_method, body, headers = self.extract_oauth_params() + try: + scopes, credentials = self.oauth_provider.validate_authorization_request( + uri, http_method, body, headers) + credentials = self.add_credentials(credentials) + client = self.oauth_provider.fetch_by_client_id(credentials['client_id']) + if client.redirect_uri.startswith(self.get_current_user().url): + self.log.debug( + "Skipping oauth confirmation for %s accessing %s", + self.get_current_user(), client.description, + ) + # access to my own server doesn't require oauth confirmation + # this is the pre-1.0 behavior for all oauth + self._complete_login(uri, headers, scopes, credentials) + return + + # Render oauth 'Authorize application...' page + self.write( + self.render_template( + "oauth.html", + scopes=scopes, + oauth_client=client, + ) + ) + + # Errors that should be shown to the user on the provider website + except oauth2.FatalClientError as e: + raise web.HTTPError(e.status_code, e.description) + + # Errors embedded in the redirect URI back to the client + except oauth2.OAuth2Error as e: + self.log.error("OAuth error: %s", e.description) + self.redirect(e.in_uri(e.redirect_uri)) + + @web.authenticated + def post(self): + uri, http_method, body, headers = self.extract_oauth_params() + referer = self.request.headers.get('Referer', 'no referer') + full_url = self.request.full_url() + if referer != full_url: + # OAuth post must be made to the URL it came from + self.log.error("OAuth POST from %s != %s", referer, full_url) + raise web.HTTPError(403, "Authorization form must be sent from authorization page") + + # The scopes the user actually authorized, i.e. checkboxes + # that were selected. + scopes = self.get_arguments('scopes') + # credentials we need in the validator + credentials = self.add_credentials() + + try: + headers, body, status = self.oauth_provider.create_authorization_response( + uri, http_method, body, headers, scopes, credentials, + ) + except oauth2.FatalClientError as e: + raise web.HTTPError(e.status_code, e.description) + else: + self.send_oauth_response(headers, body, status) + + +class OAuthTokenHandler(OAuthHandler, APIHandler): + def post(self): + uri, http_method, body, headers = self.extract_oauth_params() + credentials = {} + + try: + headers, body, status = self.oauth_provider.create_token_response( + uri, http_method, body, headers, credentials) + except oauth2.FatalClientError as e: + raise web.HTTPError(e.status_code, e.description) + else: + self.send_oauth_response(headers, body, status) default_handlers = [ (r"/api/authorizations/cookie/([^/]+)(?:/([^/]+))?", CookieAPIHandler), (r"/api/authorizations/token/([^/]+)", TokenAPIHandler), (r"/api/authorizations/token", TokenAPIHandler), - (r"/api/oauth2/authorize", OAuthHandler), - (r"/api/oauth2/token", OAuthHandler), + (r"/api/oauth2/authorize", OAuthAuthorizeHandler), + (r"/api/oauth2/token", OAuthTokenHandler), ] diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 55683634..3ce31fce 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -54,7 +54,7 @@ from .services.service import Service from . import crypto from . import dbutil, orm from .user import UserDict -from .oauth.store import make_provider +from .oauth.provider import make_provider from ._data import DATA_FILES_PATH from .log import CoroutineLogFormatter, log_request from .proxy import Proxy, ConfigurableHTTPProxy @@ -1331,7 +1331,7 @@ class JupyterHub(Application): host = '%s://services.%s' % (parsed.scheme, parsed.netloc) else: domain = host = '' - client_store = self.oauth_provider.client_authenticator.client_store + for spec in self.services: if 'name' not in spec: raise ValueError('service spec must have a name: %r' % spec) @@ -1389,7 +1389,7 @@ class JupyterHub(Application): service.orm.server = None if service.oauth_available: - client_store.add_client( + self.oauth_provider.add_client( client_id=service.oauth_client_id, client_secret=service.api_token, redirect_uri=service.oauth_redirect_uri, @@ -1489,9 +1489,9 @@ class JupyterHub(Application): def init_oauth(self): base_url = self.hub.base_url self.oauth_provider = make_provider( - lambda : self.db, + lambda: self.db, url_prefix=url_path_join(base_url, 'api/oauth2'), - login_url=url_path_join(base_url, 'login') + login_url=url_path_join(base_url, 'login'), ) def cleanup_oauth_clients(self): @@ -1507,7 +1507,6 @@ class JupyterHub(Application): for spawner in user.spawners.values(): oauth_client_ids.add(spawner.oauth_client_id) - client_store = self.oauth_provider.client_authenticator.client_store for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)): if oauth_client.identifier not in oauth_client_ids: self.log.warning("Deleting OAuth client %s", oauth_client.identifier) diff --git a/jupyterhub/oauth/provider.py b/jupyterhub/oauth/provider.py new file mode 100644 index 00000000..a56706e4 --- /dev/null +++ b/jupyterhub/oauth/provider.py @@ -0,0 +1,591 @@ +"""Utilities for hooking up oauth2 to JupyterHub's database + +implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html +""" + +from datetime import datetime +from urllib.parse import urlparse + +from oauthlib.oauth2 import RequestValidator, WebApplicationServer + +from sqlalchemy.orm import scoped_session +from tornado.escape import url_escape +from tornado.log import app_log +from tornado import web + +from .. import orm +from ..utils import url_path_join, hash_token, compare_token + + +# patch absolute-uri check +# because we want to allow relative uri oauth +# for internal services +from oauthlib.oauth2.rfc6749.grant_types import authorization_code +authorization_code.is_absolute_uri = lambda uri: True + + +class JupyterHubRequestValidator(RequestValidator): + + def __init__(self, db): + self.db = db + super().__init__() + + def authenticate_client(self, request, *args, **kwargs): + """Authenticate client through means outside the OAuth 2 spec. + Means of authentication is negotiated beforehand and may for example + be `HTTP Basic Authentication Scheme`_ which utilizes the Authorization + header. + Headers may be accesses through request.headers and parameters found in + both body and query can be obtained by direct attribute access, i.e. + request.client_id for client_id in the URL query. + :param request: oauthlib.common.Request + :rtype: True or False + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant (may be disabled) + - Client Credentials Grant + - Refresh Token Grant + .. _`HTTP Basic Authentication Scheme`: https://tools.ietf.org/html/rfc1945#section-11.1 + """ + app_log.debug("authenticate_client %s", request) + client_id = request.client_id + client_secret = request.client_secret + oauth_client = ( + self.db + .query(orm.OAuthClient) + .filter_by(identifier=client_id) + .first() + ) + if oauth_client is None: + return False + if not compare_token(oauth_client.secret, client_secret): + app_log.warning("Client secret mismatch for %s", client_id) + return False + + request.client = oauth_client + return True + + def authenticate_client_id(self, client_id, request, *args, **kwargs): + """Ensure client_id belong to a non-confidential client. + A non-confidential client is one that is not required to authenticate + through other means, such as using HTTP Basic. + Note, while not strictly necessary it can often be very convenient + to set request.client to the client object associated with the + given client_id. + :param request: oauthlib.common.Request + :rtype: True or False + Method is used by: + - Authorization Code Grant + """ + orm_client = ( + self.db + .query(orm.OAuthClient) + .filter_by(identifier=client_id) + .first() + ) + if orm_client is None: + app_log.warning("No such oauth client %s", client_id) + return False + request.client = orm_client + return True + + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, + *args, **kwargs): + """Ensure that the authorization process represented by this authorization + code began with this 'redirect_uri'. + If the client specifies a redirect_uri when obtaining code then that + redirect URI must be bound to the code and verified equal in this + method, according to RFC 6749 section 4.1.3. Do not compare against + the client's allowed redirect URIs, but against the URI used when the + code was saved. + :param client_id: Unicode client identifier + :param code: Unicode authorization_code. + :param redirect_uri: Unicode absolute URI + :param client: Client object set by you, see authenticate_client. + :rtype: True or False + Method is used by: + - Authorization Code Grant (during token request) + """ + # TODO: record redirect_uri used during oauth + # if we ever support multiple destinations + app_log.debug("confirm_redirect_uri: client_id=%s, redirect_uri=%s", + client_id, redirect_uri, + ) + if redirect_uri == client.redirect_uri: + return True + else: + app_log.warning("Redirect uri %s != %s", redirect_uri, client.redirect_uri) + return False + + def get_default_redirect_uri(self, client_id, request, *args, **kwargs): + """Get the default redirect URI for the client. + :param client_id: Unicode client identifier + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: The default redirect URI for the client + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + orm_client = ( + self.db + .query(orm.OAuthClient) + .filter_by(identifier=client_id) + .first() + ) + if orm_client is None: + raise KeyError(client_id) + return orm_client.redirect_uri + + def get_default_scopes(self, client_id, request, *args, **kwargs): + """Get the default scopes for the client. + :param client_id: Unicode client identifier + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: List of default scopes + Method is used by all core grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials grant + """ + return ['identify'] + + def get_original_scopes(self, refresh_token, request, *args, **kwargs): + """Get the list of scopes associated with the refresh token. + :param refresh_token: Unicode refresh token + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: List of scopes. + Method is used by: + - Refresh token grant + """ + raise NotImplementedError() + + def is_within_original_scope(self, request_scopes, refresh_token, request, *args, **kwargs): + """Check if requested scopes are within a scope of the refresh token. + When access tokens are refreshed the scope of the new token + needs to be within the scope of the original token. This is + ensured by checking that all requested scopes strings are on + the list returned by the get_original_scopes. If this check + fails, is_within_original_scope is called. The method can be + used in situations where returning all valid scopes from the + get_original_scopes is not practical. + :param request_scopes: A list of scopes that were requested by client + :param refresh_token: Unicode refresh_token + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is used by: + - Refresh token grant + """ + raise NotImplementedError() + + def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): + """Invalidate an authorization code after use. + :param client_id: Unicode client identifier + :param code: The authorization code grant (request.code). + :param request: The HTTP Request (oauthlib.common.Request) + Method is used by: + - Authorization Code Grant + """ + app_log.debug("Deleting oauth code %s... for %s", code[:3], client_id) + orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first() + if orm_code is not None: + self.db.delete(orm_code) + self.db.commit() + + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): + """Revoke an access or refresh token. + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: The HTTP Request (oauthlib.common.Request) + Method is used by: + - Revocation Endpoint + """ + app_log.debug("Revoking %s %s", token_type_hint, token[:3] + '...') + raise NotImplementedError('Subclasses must implement this method.') + + def save_authorization_code(self, client_id, code, request, *args, **kwargs): + """Persist the authorization_code. + The code should at minimum be stored with: + - the client_id (client_id) + - the redirect URI used (request.redirect_uri) + - a resource owner / user (request.user) + - the authorized scopes (request.scopes) + - the client state, if given (code.get('state')) + The 'code' argument is actually a dictionary, containing at least a + 'code' key with the actual authorization code: + {'code': 'sdf345jsdf0934f'} + It may also have a 'state' key containing a nonce for the client, if it + chose to send one. That value should be saved and used in + 'validate_code'. + It may also have a 'claims' parameter which, when present, will be a dict + deserialized from JSON as described at + http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter + This value should be saved in this method and used again in 'validate_code'. + :param client_id: Unicode client identifier + :param code: A dict of the authorization code grant and, optionally, state. + :param request: The HTTP Request (oauthlib.common.Request) + Method is used by: + - Authorization Code Grant + """ + log_code = code.get('code', 'undefined')[:3] + '...' + app_log.debug("Saving authorization code %s, %s, %s, %s", client_id, log_code, args, kwargs) + orm_client = ( + self.db + .query(orm.OAuthClient) + .filter_by(identifier=client_id) + .first() + ) + if orm_client is None: + raise ValueError("No such client: %s" % client_id) + + orm_code = orm.OAuthCode( + client=orm_client, + code=code['code'], + # oauth has 5 minutes to complete + expires_at=int(datetime.utcnow().timestamp() + 300), + # TODO: persist oauth scopes + # scopes=request.scopes, + user=request.user.orm_user, + redirect_uri=orm_client.redirect_uri, + session_id=request.session_id, + ) + self.db.add(orm_code) + self.db.commit() + + def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): + """ Extracts scopes from saved authorization code. + The scopes returned by this method is used to route token requests + based on scopes passed to Authorization Code requests. + With that the token endpoint knows when to include OpenIDConnect + id_token in token response only based on authorization code scopes. + Only code param should be sufficient to retrieve grant code from + any storage you are using, `client_id` and `redirect_uri` can gave a + blank value `""` don't forget to check it before using those values + in a select query if a database is used. + :param client_id: Unicode client identifier + :param code: Unicode authorization code grant + :param redirect_uri: Unicode absolute URI + :return: A list of scope + Method is used by: + - Authorization Token Grant Dispatcher + """ + raise NotImplementedError("TODO") + + def save_token(self, token, request, *args, **kwargs): + """Persist the token with a token type specific method. + Currently, only save_bearer_token is supported. + """ + return self.save_bearer_token(token, request, *args, **kwargs) + + def save_bearer_token(self, token, request, *args, **kwargs): + """Persist the Bearer token. + The Bearer token should at minimum be associated with: + - a client and it's client_id, if available + - a resource owner / user (request.user) + - authorized scopes (request.scopes) + - an expiration time + - a refresh token, if issued + - a claims document, if present in request.claims + The Bearer token dict may hold a number of items:: + { + 'token_type': 'Bearer', + 'access_token': 'askfjh234as9sd8', + 'expires_in': 3600, + 'scope': 'string of space separated authorized scopes', + 'refresh_token': '23sdf876234', # if issued + 'state': 'given_by_client', # if supplied by client + } + Note that while "scope" is a string-separated list of authorized scopes, + the original list is still available in request.scopes. + The token dict is passed as a reference so any changes made to the dictionary + will go back to the user. If additional information must return to the client + user, and it is only possible to get this information after writing the token + to storage, it should be added to the token dictionary. If the token + dictionary must be modified but the changes should not go back to the user, + a copy of the dictionary must be made before making the changes. + Also note that if an Authorization Code grant request included a valid claims + parameter (for OpenID Connect) then the request.claims property will contain + the claims dict, which should be saved for later use when generating the + id_token and/or UserInfo response content. + :param token: A Bearer token dict + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: The default redirect URI for the client + Method is used by all core grant types issuing Bearer tokens: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant (might not associate a client) + - Client Credentials grant + """ + log_token = {} + log_token.update(token) + scopes = token['scope'].split(' ') + # TODO: + if scopes != ['identify']: + raise ValueError("Only 'identify' scope is supported") + # redact sensitive keys in log + for key in ('access_token', 'refresh_token', 'state'): + if key in token: + value = token[key] + if isinstance(value, str): + log_token[key] = 'REDACTED' + app_log.debug("Saving bearer token %s", log_token) + if request.user is None: + raise ValueError("No user for access token: %s" % request.user) + client = self.db.query(orm.OAuthClient).filter_by(identifier=request.client.client_id).first() + orm_access_token = orm.OAuthAccessToken( + client=client, + grant_type=orm.GrantType.authorization_code, + expires_at=datetime.utcnow().timestamp() + token['expires_in'], + refresh_token=token['refresh_token'], + # TODO: save scopes, + # scopes=scopes, + token=token['access_token'], + session_id=request.session_id, + user=request.user, + ) + self.db.add(orm_access_token) + self.db.commit() + return client.redirect_uri + + def validate_bearer_token(self, token, scopes, request): + """Ensure the Bearer token is valid and authorized access to scopes. + :param token: A string of random characters. + :param scopes: A list of scopes associated with the protected resource. + :param request: The HTTP Request (oauthlib.common.Request) + A key to OAuth 2 security and restricting impact of leaked tokens is + the short expiration time of tokens, *always ensure the token has not + expired!*. + Two different approaches to scope validation: + 1) all(scopes). The token must be authorized access to all scopes + associated with the resource. For example, the + token has access to ``read-only`` and ``images``, + thus the client can view images but not upload new. + Allows for fine grained access control through + combining various scopes. + 2) any(scopes). The token must be authorized access to one of the + scopes associated with the resource. For example, + token has access to ``read-only-images``. + Allows for fine grained, although arguably less + convenient, access control. + A powerful way to use scopes would mimic UNIX ACLs and see a scope + as a group with certain privileges. For a restful API these might + map to HTTP verbs instead of read, write and execute. + Note, the request.user attribute can be set to the resource owner + associated with this token. Similarly the request.client and + request.scopes attribute can be set to associated client object + and authorized scopes. If you then use a decorator such as the + one provided for django these attributes will be made available + in all protected views as keyword arguments. + :param token: Unicode Bearer token + :param scopes: List of scopes (defined by you) + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is indirectly used by all core Bearer token issuing grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_client_id(self, client_id, request, *args, **kwargs): + """Ensure client_id belong to a valid and active client. + Note, while not strictly necessary it can often be very convenient + to set request.client to the client object associated with the + given client_id. + :param request: oauthlib.common.Request + :rtype: True or False + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + app_log.debug("Validating client id %s", client_id) + orm_client = ( + self.db + .query(orm.OAuthClient) + .filter_by(identifier=client_id) + .first() + ) + if orm_client is None: + return False + request.client = orm_client + return True + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + """Verify that the authorization_code is valid and assigned to the given + client. + Before returning true, set the following based on the information stored + with the code in 'save_authorization_code': + - request.user + - request.state (if given) + - request.scopes + - request.claims (if given) + OBS! The request.user attribute should be set to the resource owner + associated with this authorization code. Similarly request.scopes + must also be set. + The request.claims property, if it was given, should assigned a dict. + :param client_id: Unicode client identifier + :param code: Unicode authorization code + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is used by: + - Authorization Code Grant + """ + orm_code = ( + self.db + .query(orm.OAuthCode) + .filter_by(code=code) + .first() + ) + if orm_code is None: + app_log.debug("No such code: %s", code) + return False + if orm_code.client_id != client_id: + app_log.debug( + "OAuth code client id mismatch: %s != %s", + client_id, orm_code.client_id, + ) + return False + request.user = orm_code.user + request.session_id = orm_code.session_id + # TODO: record state on oauth codes + # TODO: specify scopes + request.scopes = ['identify'] + return True + + def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + """Ensure client is authorized to use the grant_type requested. + :param client_id: Unicode client identifier + :param grant_type: Unicode grant type, i.e. authorization_code, password. + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + - Refresh Token Grant + """ + return grant_type == 'authorization_code' + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): + """Ensure client is authorized to redirect to the redirect_uri requested. + All clients should register the absolute URIs of all URIs they intend + to redirect to. The registration is outside of the scope of oauthlib. + :param client_id: Unicode client identifier + :param redirect_uri: Unicode absolute URI + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + app_log.debug("validate_redirect_uri: client_id=%s, redirect_uri=%s", + client_id, redirect_uri, + ) + orm_client = ( + self.db + .query(orm.OAuthClient) + .filter_by(identifier=client_id) + .first() + ) + if orm_client is None: + app_log.warning("No such oauth client %s", client_id) + return False + if redirect_uri == orm_client.redirect_uri: + return True + else: + app_log.warning("Redirect uri %s != %s", redirect_uri, orm_client.redirect_uri) + return False + + def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): + """Ensure the Bearer token is valid and authorized access to scopes. + OBS! The request.user attribute should be set to the resource owner + associated with this refresh token. + :param refresh_token: Unicode refresh token + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is used by: + - Authorization Code Grant (indirectly by issuing refresh tokens) + - Resource Owner Password Credentials Grant (also indirectly) + - Refresh Token Grant + """ + return False + raise NotImplementedError('Subclasses must implement this method.') + + def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): + """Ensure client is authorized to use the response_type requested. + :param client_id: Unicode client identifier + :param response_type: Unicode response type, i.e. code, token. + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + # TODO + return True + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + """Ensure the client is authorized access to requested scopes. + :param client_id: Unicode client identifier + :param scopes: List of scopes (defined by you) + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + Method is used by all core grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + """ + return True + + +class JupyterHubOAuthServer(WebApplicationServer): + def __init__(self, db, validator, *args, **kwargs): + self.db = db + super().__init__(validator, *args, **kwargs) + + def add_client(self, client_id, client_secret, redirect_uri, description=''): + """Add a client + + hash its client_secret before putting it in the database. + """ + # clear existing clients with same ID + for orm_client in ( + self.db + .query(orm.OAuthClient)\ + .filter_by(identifier=client_id) + ): + self.db.delete(orm_client) + self.db.commit() + + orm_client = orm.OAuthClient( + identifier=client_id, + secret=hash_token(client_secret), + redirect_uri=redirect_uri, + description=description, + ) + self.db.add(orm_client) + self.db.commit() + + def fetch_by_client_id(self, client_id): + """Find a client by its id""" + return ( + self.db + .query(orm.OAuthClient) + .filter_by(identifier=client_id) + .first() + ) + + +def make_provider(session_factory, url_prefix, login_url): + """Make an OAuth provider""" + db = session_factory() + validator = JupyterHubRequestValidator(db) + server = JupyterHubOAuthServer(db, validator) + return server + diff --git a/jupyterhub/oauth/store.py b/jupyterhub/oauth/store.py deleted file mode 100644 index 97ca345f..00000000 --- a/jupyterhub/oauth/store.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Utilities for hooking up oauth2 to JupyterHub's database - -implements https://python-oauth2.readthedocs.io/en/latest/store.html -""" - -import threading - -from oauth2.datatype import Client, AuthorizationCode -from oauth2.error import AuthCodeNotFound, ClientNotFoundError, UserNotAuthenticated -from oauth2.grant import AuthorizationCodeGrant -from oauth2.web import AuthorizationCodeGrantSiteAdapter -import oauth2.store -from oauth2 import Provider -from oauth2.tokengenerator import Uuid4 as UUID4 - -from sqlalchemy.orm import scoped_session -from tornado.escape import url_escape - -from .. import orm -from ..utils import url_path_join, hash_token, compare_token - - -class JupyterHubSiteAdapter(AuthorizationCodeGrantSiteAdapter): - """ - This adapter renders a confirmation page so the user can confirm the auth - request. - """ - def __init__(self, login_url): - self.login_url = login_url - - def render_auth_page(self, request, response, environ, scopes, client): - """Auth page is a redirect to login page""" - response.status_code = 302 - response.headers['Location'] = self.login_url + '?next={}'.format( - url_escape(request.handler.request.path + '?' + request.handler.request.query) - ) - return response - - def authenticate(self, request, environ, scopes, client): - handler = request.handler - user = handler.get_current_user() - # ensure session_id is set - session_id = handler.get_session_cookie() - if session_id is None: - session_id = handler.set_session_cookie() - if user: - return {'session_id': session_id}, user.id - else: - raise UserNotAuthenticated() - - def user_has_denied_access(self, request): - # user can't deny access - return False - - -class HubDBMixin(object): - """Mixin for connecting to the hub database""" - def __init__(self, session_factory): - self.db = session_factory() - - -class AccessTokenStore(HubDBMixin, oauth2.store.AccessTokenStore): - """OAuth2 AccessTokenStore, storing data in the Hub database""" - - def save_token(self, access_token): - """ - Stores an access token in the database. - - :param access_token: An instance of :class:`oauth2.datatype.AccessToken`. - - """ - - user = self.db.query(orm.User).filter_by(id=access_token.user_id).first() - if user is None: - raise ValueError("No user for access token: %s" % access_token.user_id) - client = self.db.query(orm.OAuthClient).filter_by(identifier=access_token.client_id).first() - orm_access_token = orm.OAuthAccessToken( - client=client, - grant_type=access_token.grant_type, - expires_at=access_token.expires_at, - refresh_token=access_token.refresh_token, - refresh_expires_at=access_token.refresh_expires_at, - token=access_token.token, - session_id=access_token.data['session_id'], - user=user, - ) - self.db.add(orm_access_token) - self.db.commit() - - -class AuthCodeStore(HubDBMixin, oauth2.store.AuthCodeStore): - """ - OAuth2 AuthCodeStore, storing data in the Hub database - """ - def fetch_by_code(self, code): - """ - Returns an AuthorizationCode fetched from a storage. - - :param code: The authorization code. - :return: An instance of :class:`oauth2.datatype.AuthorizationCode`. - :raises: :class:`oauth2.error.AuthCodeNotFound` if no data could be retrieved for - given code. - - """ - orm_code = ( - self.db - .query(orm.OAuthCode) - .filter_by(code=code) - .first() - ) - if orm_code is None: - raise AuthCodeNotFound() - else: - return AuthorizationCode( - client_id=orm_code.client_id, - code=code, - expires_at=orm_code.expires_at, - redirect_uri=orm_code.redirect_uri, - scopes=[], - user_id=orm_code.user_id, - data={'session_id': orm_code.session_id}, - ) - - def save_code(self, authorization_code): - """ - Stores the data belonging to an authorization code token. - - :param authorization_code: An instance of - :class:`oauth2.datatype.AuthorizationCode`. - """ - orm_client = ( - self.db - .query(orm.OAuthClient) - .filter_by(identifier=authorization_code.client_id) - .first() - ) - if orm_client is None: - raise ValueError("No such client: %s" % authorization_code.client_id) - - orm_user = ( - self.db - .query(orm.User) - .filter_by(id=authorization_code.user_id) - .first() - ) - if orm_user is None: - raise ValueError("No such user: %s" % authorization_code.user_id) - - orm_code = orm.OAuthCode( - client=orm_client, - code=authorization_code.code, - expires_at=authorization_code.expires_at, - user=orm_user, - redirect_uri=authorization_code.redirect_uri, - session_id=authorization_code.data.get('session_id', ''), - ) - self.db.add(orm_code) - self.db.commit() - - - def delete_code(self, code): - """ - Deletes an authorization code after its use per section 4.1.2. - - http://tools.ietf.org/html/rfc6749#section-4.1.2 - - :param code: The authorization code. - """ - orm_code = self.db.query(orm.OAuthCode).filter_by(code=code).first() - if orm_code is not None: - self.db.delete(orm_code) - self.db.commit() - - -class HashComparable: - """An object for storing hashed tokens - - Overrides `==` so that it compares as equal to its unhashed original - - Needed for storing hashed client_secrets - because python-oauth2 uses:: - - secret == client.client_secret - - and we don't want to store unhashed secrets in the database. - """ - def __init__(self, hashed_token): - self.hashed_token = hashed_token - - def __repr__(self): - return "<{} '{}'>".format(self.__class__.__name__, self.hashed_token) - - def __eq__(self, other): - return compare_token(self.hashed_token, other) - - -class ClientStore(HubDBMixin, oauth2.store.ClientStore): - """OAuth2 ClientStore, storing data in the Hub database""" - - def fetch_by_client_id(self, client_id): - """Retrieve a client by its identifier. - - :param client_id: Identifier of a client app. - :return: An instance of :class:`oauth2.datatype.Client`. - :raises: :class:`oauth2.error.ClientNotFoundError` if no data could be retrieved for - given client_id. - """ - orm_client = ( - self.db - .query(orm.OAuthClient) - .filter_by(identifier=client_id) - .first() - ) - if orm_client is None: - raise ClientNotFoundError() - return Client(identifier=client_id, - redirect_uris=[orm_client.redirect_uri], - secret=HashComparable(orm_client.secret), - ) - - def add_client(self, client_id, client_secret, redirect_uri, description=''): - """Add a client - - hash its client_secret before putting it in the database. - """ - # clear existing clients with same ID - for orm_client in ( - self.db - .query(orm.OAuthClient)\ - .filter_by(identifier=client_id) - ): - self.db.delete(orm_client) - self.db.commit() - - orm_client = orm.OAuthClient( - identifier=client_id, - secret=hash_token(client_secret), - redirect_uri=redirect_uri, - description=description, - ) - self.db.add(orm_client) - self.db.commit() - - -def make_provider(session_factory, url_prefix, login_url): - """Make an OAuth provider""" - token_store = AccessTokenStore(session_factory) - code_store = AuthCodeStore(session_factory) - client_store = ClientStore(session_factory) - - provider = Provider( - access_token_store=token_store, - auth_code_store=code_store, - client_store=client_store, - token_generator=UUID4(), - ) - provider.token_path = url_path_join(url_prefix, 'token') - provider.authorize_path = url_path_join(url_prefix, 'authorize') - site_adapter = JupyterHubSiteAdapter(login_url=login_url) - provider.add_grant(AuthorizationCodeGrant(site_adapter=site_adapter)) - return provider - diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 5da742b8..8cb5c423 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -469,6 +469,7 @@ class OAuthAccessToken(Hashed, Base): grant_type = Column(Enum(GrantType), nullable=False) expires_at = Column(Integer) refresh_token = Column(Unicode(255)) + # TODO: drop refresh_expires_at. Refresh tokens shouldn't expire refresh_expires_at = Column(Integer) user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE')) service = None # for API-equivalence with APIToken @@ -513,6 +514,7 @@ class OAuthCode(Base): expires_at = Column(Integer) redirect_uri = Column(Unicode(1023)) session_id = Column(Unicode(255)) + # state = Column(Unicode(1023)) user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE')) @@ -524,6 +526,10 @@ class OAuthClient(Base): secret = Column(Unicode(255)) redirect_uri = Column(Unicode(1023)) + @property + def client_id(self): + return self.identifier + access_tokens = relationship( OAuthAccessToken, backref='client', diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index 476ac936..3fa4f70d 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -302,7 +302,15 @@ class HubAuth(SingletonConfigurable): elif r.status_code >= 400: app_log.warning("Failed to check authorization: [%i] %s", r.status_code, r.reason) app_log.warning(r.text) - raise HTTPError(500, "Failed to check authorization") + msg = "Failed to check authorization" + # pass on error_description from oauth failure + try: + description = r.json().get("error_description") + except Exception: + pass + else: + msg += ": " + description + raise HTTPError(500, msg) else: data = r.json() @@ -847,6 +855,11 @@ class HubOAuthCallbackHandler(HubOAuthenticated, RequestHandler): @coroutine def get(self): + error = self.get_argument("error", False) + if error: + msg = self.get_argument("error_description", error) + raise HTTPError(400, "Error in oauth: %s" % msg) + code = self.get_argument("code", False) if not code: raise HTTPError(400, "oauth callback made without a token") diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index fad6960c..5dd99e2c 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -600,6 +600,21 @@ def test_announcements(app, announcements): assert_announcement("logout", r.text) +@pytest.mark.parametrize( + "params", + [ + "", + "redirect_uri=/noexist", + "redirect_uri=ok&client_id=nosuchthing", + ] +) +@pytest.mark.gen_test +def test_bad_oauth_get(app, params): + cookies = yield app.login_user("authorizer") + r = yield get_page("hub/api/oauth2/authorize?" + params, app, hub=False, cookies=cookies) + assert r.status_code == 400 + + @pytest.mark.gen_test def test_token_page(app): name = "cake" diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index d0e69a8a..ce253e05 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -1,6 +1,8 @@ +"""Tests for service authentication""" import asyncio from binascii import hexlify import copy +from functools import partial import json import os from queue import Queue @@ -24,7 +26,7 @@ from ..services.auth import _ExpiringDict, HubAuth, HubAuthenticated from ..utils import url_path_join from .mocking import public_url, public_host from .test_api import add_user -from .utils import async_requests +from .utils import async_requests, AsyncSession # mock for sending monotonic counter way into the future monotonic_future = mock.patch('time.monotonic', lambda : sys.maxsize) @@ -322,24 +324,29 @@ def test_hubauth_service_token(app, mockservice_url): def test_oauth_service(app, mockservice_url): service = mockservice_url url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x') - # first request is only going to set login cookie - s = requests.Session() + # first request is only going to login and get us to the oauth form page + s = AsyncSession() name = 'link' s.cookies = yield app.login_user(name) - # run session.get in async_requests thread - s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs) - r = yield s_get(url) + + r = yield s.get(url) + r.raise_for_status() + # we should be looking at the oauth confirmation page + assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize' + # verify oauth state cookie was set at some point + assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name} + + # submit the oauth form to complete authorization + r = yield s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url}) r.raise_for_status() assert r.url == url # verify oauth cookie is set assert 'service-%s' % service.name in set(s.cookies.keys()) # verify oauth state cookie has been consumed assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys()) - # verify oauth state cookie was set at some point - assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name} - # second request should be authenticated - r = yield s_get(url, allow_redirects=False) + # second request should be authenticated, which means no redirects + r = yield s.get(url, allow_redirects=False) r.raise_for_status() assert r.status_code == 200 reply = r.json() @@ -376,25 +383,23 @@ def test_oauth_cookie_collision(app, mockservice_url): service = mockservice_url url = url_path_join(public_url(app, mockservice_url), 'owhoami/') print(url) - s = requests.Session() + s = AsyncSession() name = 'mypha' s.cookies = yield app.login_user(name) - # run session.get in async_requests thread - s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs) state_cookie_name = 'service-%s-oauth-state' % service.name service_cookie_name = 'service-%s' % service.name - oauth_1 = yield s_get(url, allow_redirects=False) + oauth_1 = yield s.get(url) print(oauth_1.headers) print(oauth_1.cookies, oauth_1.url, url) assert state_cookie_name in s.cookies - state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ] + state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ] # only one state cookie assert state_cookies == [state_cookie_name] state_1 = s.cookies[state_cookie_name] # start second oauth login before finishing the first - oauth_2 = yield s_get(url, allow_redirects=False) - state_cookies = [ s for s in s.cookies.keys() if s.startswith(state_cookie_name) ] + oauth_2 = yield s.get(url) + state_cookies = [ c for c in s.cookies.keys() if c.startswith(state_cookie_name) ] assert len(state_cookies) == 2 # get the random-suffix cookie name state_cookie_2 = sorted(state_cookies)[-1] @@ -402,11 +407,14 @@ def test_oauth_cookie_collision(app, mockservice_url): assert s.cookies[state_cookie_name] == state_1 # finish oauth 2 - url = oauth_2.headers['Location'] - if not urlparse(url).netloc: - url = public_host(app) + url - r = yield s_get(url) + # submit the oauth form to complete authorization + r = yield s.post( + oauth_2.url, + data={'scopes': ['identify']}, + headers={'Referer': oauth_2.url}, + ) r.raise_for_status() + assert r.url == url # after finishing, state cookie is cleared assert state_cookie_2 not in s.cookies # service login cookie is set @@ -414,11 +422,14 @@ def test_oauth_cookie_collision(app, mockservice_url): service_cookie_2 = s.cookies[service_cookie_name] # finish oauth 1 - url = oauth_1.headers['Location'] - if not urlparse(url).netloc: - url = public_host(app) + url - r = yield s_get(url) + r = yield s.post( + oauth_1.url, + data={'scopes': ['identify']}, + headers={'Referer': oauth_1.url}, + ) r.raise_for_status() + assert r.url == url + # after finishing, state cookie is cleared (again) assert state_cookie_name not in s.cookies # service login cookie is set (again, to a different value) @@ -443,7 +454,7 @@ def test_oauth_logout(app, mockservice_url): service_cookie_name = 'service-%s' % service.name url = url_path_join(public_url(app, mockservice_url), 'owhoami/?foo=bar') # first request is only going to set login cookie - s = requests.Session() + s = AsyncSession() name = 'propha' app_user = add_user(app.db, app=app, name=name) def auth_tokens(): @@ -458,13 +469,16 @@ def test_oauth_logout(app, mockservice_url): s.cookies = yield app.login_user(name) assert 'jupyterhub-session-id' in s.cookies - # run session.get in async_requests thread - s_get = lambda *args, **kwargs: async_requests.executor.submit(s.get, *args, **kwargs) - r = yield s_get(url) + r = yield s.get(url) + r.raise_for_status() + assert urlparse(r.url).path.endswith('oauth2/authorize') + # submit the oauth form to complete authorization + r = yield s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url}) r.raise_for_status() assert r.url == url + # second request should be authenticated - r = yield s_get(url, allow_redirects=False) + r = yield s.get(url, allow_redirects=False) r.raise_for_status() assert r.status_code == 200 reply = r.json() @@ -483,13 +497,13 @@ def test_oauth_logout(app, mockservice_url): assert len(auth_tokens()) == 1 # hit hub logout URL - r = yield s_get(public_url(app, path='hub/logout')) + r = yield s.get(public_url(app, path='hub/logout')) r.raise_for_status() # verify that all cookies other than the service cookie are cleared assert list(s.cookies.keys()) == [service_cookie_name] # verify that clearing session id invalidates service cookie # i.e. redirect back to login page - r = yield s_get(url) + r = yield s.get(url) r.raise_for_status() assert r.url.split('?')[0] == public_url(app, path='hub/login') @@ -506,7 +520,7 @@ def test_oauth_logout(app, mockservice_url): # check that we got the old session id back assert session_id == s.cookies['jupyterhub-session-id'] - r = yield s_get(url, allow_redirects=False) + r = yield s.get(url, allow_redirects=False) r.raise_for_status() assert r.status_code == 200 reply = r.json() diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py index 4005a6b5..c9ab77d6 100644 --- a/jupyterhub/tests/test_singleuser.py +++ b/jupyterhub/tests/test_singleuser.py @@ -10,7 +10,7 @@ import jupyterhub from .mocking import StubSingleUserSpawner, public_url from ..utils import url_path_join -from .utils import async_requests +from .utils import async_requests, AsyncSession @pytest.mark.gen_test @@ -41,9 +41,20 @@ def test_singleuser_auth(app): r = yield async_requests.get(url_path_join(url, 'logout'), cookies=cookies) assert len(r.cookies) == 0 - # another user accessing should get 403, not redirect to login + # accessing another user's server hits the oauth confirmation page cookies = yield app.login_user('burgess') - r = yield async_requests.get(url, cookies=cookies) + s = AsyncSession() + s.cookies = cookies + r = yield s.get(url) + assert urlparse(r.url).path.endswith('/oauth2/authorize') + # submit the oauth form to complete authorization + r = yield s.post( + r.url, + data={'scopes': ['identify']}, + headers={'Referer': r.url}, + ) + assert urlparse(r.url).path.rstrip('/').endswith('/user/nandy/tree') + # user isn't authorized, should raise 403 assert r.status_code == 403 assert 'burgess' in r.text diff --git a/jupyterhub/tests/utils.py b/jupyterhub/tests/utils.py index 58d9222a..36bcb5f9 100644 --- a/jupyterhub/tests/utils.py +++ b/jupyterhub/tests/utils.py @@ -3,7 +3,7 @@ import requests class _AsyncRequests: """Wrapper around requests to return a Future from request methods - + A single thread is allocated to avoid blocking the IOLoop thread. """ def __init__(self): @@ -16,3 +16,7 @@ class _AsyncRequests: # async_requests.get = requests.get returning a Future, etc. async_requests = _AsyncRequests() +class AsyncSession(requests.Session): + """requests.Session object that runs in the background thread""" + def request(self, *args, **kwargs): + return async_requests.executor.submit(super().request, *args, **kwargs) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 95cbb5f8..f5a84f8c 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -6,7 +6,6 @@ from datetime import datetime, timedelta from urllib.parse import quote, urlparse import warnings -from oauth2.error import ClientNotFoundError from sqlalchemy import inspect from tornado import gen from tornado.log import app_log @@ -375,17 +374,14 @@ class User: client_id = spawner.oauth_client_id oauth_provider = self.settings.get('oauth_provider') if oauth_provider: - client_store = oauth_provider.client_authenticator.client_store - try: - oauth_client = client_store.fetch_by_client_id(client_id) - except ClientNotFoundError: - oauth_client = None + oauth_client = oauth_provider.fetch_by_client_id(client_id) # create a new OAuth client + secret on every launch # containers that resume will be updated below - client_store.add_client(client_id, api_token, - url_path_join(self.url, server_name, 'oauth_callback'), - description="Server at %s" % (url_path_join(self.base_url, server_name) + '/'), - ) + oauth_provider.add_client( + client_id, api_token, + url_path_join(self.url, server_name, 'oauth_callback'), + description="Server at %s" % (url_path_join(self.base_url, server_name) + '/'), + ) db.commit() # trigger pre-spawn hook on authenticator @@ -459,10 +455,10 @@ class User: ) # update OAuth client secret with updated API token if oauth_provider: - client_store = oauth_provider.client_authenticator.client_store - client_store.add_client(client_id, spawner.api_token, - url_path_join(self.url, server_name, 'oauth_callback'), - ) + oauth_provider.add_client( + client_id, spawner.api_token, + url_path_join(self.url, server_name, 'oauth_callback'), + ) db.commit() except Exception as e: diff --git a/requirements.txt b/requirements.txt index 33ce10b8..ec8fe23f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ traitlets>=4.3.2 tornado>=5.0 jinja2 pamela -python-oauth2>=1.0 +oauthlib>=2.0 python-dateutil SQLAlchemy>=1.1 requests diff --git a/share/jupyterhub/templates/oauth.html b/share/jupyterhub/templates/oauth.html new file mode 100644 index 00000000..bb297001 --- /dev/null +++ b/share/jupyterhub/templates/oauth.html @@ -0,0 +1,51 @@ +{% extends "page.html" %} + +{% block login_widget %} +{% endblock %} + +{% block main %} +
+

Authorize access

+ +

+ A service is attempting to authorize with your + JupyterHub account +

+ +

+ {{ oauth_client.description }} (oauth URL: {{ oauth_client.redirect_uri }}) + would like permission to identify you. + {% if scopes == ["identify"] %} + It will not be able to take actions on your behalf. + {% endif %} +

+ +

The application will be able to:

+
+
+ {% for scope in scopes %} +
+ +
+ {% endfor %} + +
+
+
+ + +{% endblock %}