Repository: jupyterhub/jupyterhub

Issue:
External OAuth Client unable to access API
Hi,
So I'm testing the recently merged "enable external oauth client" but unfortunately my oauth client is unable to access the API after retrieving the token and is being redirected to the login page (see logs below).

**How to reproduce the issue**
I am using my self hosted Gitlab as an external oauth client

**What you expected to happen**
The API request to  `/jupyterhub/hub/api/user` with oauth token should be able to retrieve the user info.

**What actually happens**
The request is being redirected to the login page

**Share what version of JupyterHub you are using** : latest

Jupyterhub logs

```
jupyterhub | [I 2018-02-27 16:18:07.927 JupyterHub log:124] 302 GET /jupyterhub/hub/api/oauth2/authorize?client_id=gitlab-oauth&redirect_uri=http%3A%2F%2Fhost.domain%2Fgitlab%2Fusers%2Fauth%2FJupyterhub%2Fcallback&response_type=code&state=[state] -> http://host.domain/gitlab/users/auth/Jupyterhub/callback?code=[code]&state=[state] ([user]@194.199.19.22) 14.87ms
jupyterhub | [I 2018-02-27 16:18:08.108 JupyterHub log:124] 200 POST /jupyterhub/hub/api/oauth2/token (@172.18.0.1) 40.60ms
jupyterhub | [I 2018-02-27 16:18:08.134 JupyterHub log:124] 302 GET /jupyterhub/hub/api/user -> /jupyterhub/hub/login?next=%2Fjupyterhub%2Fhub%2Fapi%2Fuser (@172.18.0.1) 4.79ms
jupyterhub | [I 2018-02-27 16:18:08.143 JupyterHub log:124] 302 GET /jupyterhub/hub/login?next=%2Fjupyterhub%2Fhub%2Fapi%2Fuser -> /jupyterhub/hub/lti/launch?next=%2Fjupyterhub%2Fhub%2Fapi%2Fuser (@172.18.0.1) 2.19ms
jupyterhub | [W 2018-02-27 16:18:08.205 JupyterHub log:124] 405 GET /jupyterhub/hub/lti/launch?next=%2Fjupyterhub%2Fhub%2Fapi%2Fuser (@172.18.0.1) 53.75ms
```

Am I missing something ?



Discussion:
Can you show how you are making the `/api/user` requests?

Resolving patch:
diff --git a/examples/external-oauth/jupyterhub_config.py b/examples/external-oauth/jupyterhub_config.py
--- a/examples/external-oauth/jupyterhub_config.py
+++ b/examples/external-oauth/jupyterhub_config.py
@@ -7,6 +7,7 @@
     raise ValueError("Make sure to `export JUPYTERHUB_API_TOKEN=$(openssl rand -hex 32)`")
 
 # tell JupyterHub to register the service as an external oauth client
+
 c.JupyterHub.services = [
     {
         'name': 'external-oauth',
diff --git a/examples/external-oauth/whoami-oauth-basic.py b/examples/external-oauth/whoami-oauth-basic.py
new file mode 100644
--- /dev/null
+++ b/examples/external-oauth/whoami-oauth-basic.py
@@ -0,0 +1,135 @@
+"""Basic implementation of OAuth without any inheritance
+
+Implements OAuth handshake manually
+so all URLs and requests necessary for OAuth with JupyterHub should be in one place
+"""
+
+import json
+import os
+import sys
+from urllib.parse import urlencode, urlparse
+
+from tornado.auth import OAuth2Mixin
+from tornado.httpclient import AsyncHTTPClient, HTTPRequest
+from tornado.httputil import url_concat
+from tornado.ioloop import IOLoop
+from tornado import log
+from tornado import web
+
+
+class JupyterHubLoginHandler(web.RequestHandler):
+    """Login Handler
+
+    this handler both begins and ends the OAuth process
+    """
+
+    async def token_for_code(self, code):
+        """Complete OAuth by requesting an access token for an oauth code"""
+        params = dict(
+            client_id=self.settings['client_id'],
+            client_secret=self.settings['api_token'],
+            grant_type='authorization_code',
+            code=code,
+            redirect_uri=self.settings['redirect_uri'],
+        )
+        req = HTTPRequest(self.settings['token_url'], method='POST',
+                          body=urlencode(params).encode('utf8'),
+                          headers={
+            'Content-Type': 'application/x-www-form-urlencoded',
+        },
+        )
+        response = await AsyncHTTPClient().fetch(req)
+        data = json.loads(response.body.decode('utf8', 'replace'))
+        return data['access_token']
+
+    async def get(self):
+        code = self.get_argument('code', None)
+        if code:
+            # code is set, we are the oauth callback
+            # complete oauth
+            token = await self.token_for_code(code)
+            # login successful, set cookie and redirect back to home
+            self.set_secure_cookie('whoami-oauth-token', token)
+            self.redirect('/')
+        else:
+            # we are the login handler,
+            # begin oauth process which will come back later with an
+            # authorization_code
+            self.redirect(url_concat(
+                self.settings['authorize_url'],
+                dict(
+                    redirect_uri=self.settings['redirect_uri'],
+                    client_id=self.settings['client_id'],
+                    response_type='code',
+                )
+            ))
+
+
+class WhoAmIHandler(web.RequestHandler):
+    """Serve the JSON model for the authenticated user"""
+
+    def get_current_user(self):
+        """The login handler stored a JupyterHub API token in a cookie
+
+        @web.authenticated calls this method.
+        If a Falsy value is returned, the request is redirected to `login_url`.
+        If a Truthy value is returned, the request is allowed to proceed.
+        """
+        token = self.get_secure_cookie('whoami-oauth-token')
+
+        if token:
+            # secure cookies are bytes, decode to str
+            return token.decode('ascii', 'replace')
+
+    async def user_for_token(self, token):
+        """Retrieve the user for a given token, via /hub/api/user"""
+
+        req = HTTPRequest(
+            self.settings['user_url'],
+            headers={
+                'Authorization': f'token {token}'
+            },
+        )
+        response = await AsyncHTTPClient().fetch(req)
+        return json.loads(response.body.decode('utf8', 'replace'))
+
+    @web.authenticated
+    async def get(self):
+        user_token = self.get_current_user()
+        user_model = await self.user_for_token(user_token)
+        self.set_header('content-type', 'application/json')
+        self.write(json.dumps(user_model, indent=1, sort_keys=True))
+
+
+def main():
+    log.enable_pretty_logging()
+
+    # construct OAuth URLs from jupyterhub base URL
+    hub_api = os.environ['JUPYTERHUB_URL'].rstrip('/') + '/hub/api'
+    authorize_url = hub_api + '/oauth2/authorize'
+    token_url = hub_api + '/oauth2/token'
+    user_url = hub_api + '/user'
+
+    app = web.Application([
+        ('/oauth_callback', JupyterHubLoginHandler),
+        ('/', WhoAmIHandler),
+    ],
+        login_url='/oauth_callback',
+        cookie_secret=os.urandom(32),
+        api_token=os.environ['JUPYTERHUB_API_TOKEN'],
+        client_id=os.environ['JUPYTERHUB_CLIENT_ID'],
+        redirect_uri=os.environ['JUPYTERHUB_SERVICE_URL'].rstrip('/') + '/oauth_callback',
+        authorize_url=authorize_url,
+        token_url=token_url,
+        user_url=user_url,
+    )
+
+    url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
+    log.app_log.info("Running basic whoami service on %s",
+                     os.environ['JUPYTERHUB_SERVICE_URL'])
+    app.listen(url.port, url.hostname)
+    IOLoop.current().start()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py
--- a/jupyterhub/apihandlers/users.py
+++ b/jupyterhub/apihandlers/users.py
@@ -17,7 +17,6 @@ class SelfAPIHandler(APIHandler):
 
     Based on the authentication info. Acts as a 'whoami' for auth tokens.
     """
-    @web.authenticated
     def get(self):
         user = self.get_current_user()
         if user is None:


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -6,6 +6,7 @@
 import sys
 from unittest import mock
 from urllib.parse import urlparse, quote
+import uuid
 
 import pytest
 from pytest import mark
@@ -186,7 +187,6 @@ def test_referer_check(app):
 # User API tests
 # --------------
 
-
 @mark.user
 @mark.gen_test
 def test_get_users(app):
@@ -222,6 +222,40 @@ def test_get_users(app):
     assert r.status_code == 403
 
 
+@mark.user
+@mark.gen_test
+def test_get_self(app):
+    db = app.db
+
+    # basic get self
+    r = yield api_request(app, 'user')
+    r.raise_for_status()
+    assert r.json()['kind'] == 'user'
+
+    # identifying user via oauth token works
+    u = add_user(db, app=app, name='orpheus')
+    token = uuid.uuid4().hex
+    oauth_token = orm.OAuthAccessToken(
+        user=u.orm_user,
+        token=token,
+        grant_type=orm.GrantType.authorization_code,
+    )
+    db.add(oauth_token)
+    db.commit()
+    r = yield api_request(app, 'user', headers={
+        'Authorization': 'token ' + token,
+    })
+    r.raise_for_status()
+    model = r.json()
+    assert model['name'] == u.name
+
+    # invalid auth gets 403
+    r = yield api_request(app, 'user', headers={
+        'Authorization': 'token notvalid',
+    })
+    assert r.status_code == 403
+
+
 @mark.user
 @mark.gen_test
 def test_add_user(app):