Deprecate and remove some old auth bits

- remove long-deprecated `POST /api/authorizations/token` for creating tokens
- deprecate but do not remove `GET /api/authorizations/token/:token` in favor of GET /api/user
- remove shared-cookie auth for services from HubAuth, rely on OAuth for browser-auth instead
- use `/hub/api/user` to resolve user instead of `/authorizations/token` which is now deprecated
This commit is contained in:
Min RK
2021-04-23 14:02:50 +02:00
parent f45f1c250f
commit 863b4c7d50
12 changed files with 112 additions and 427 deletions

View File

@@ -203,8 +203,6 @@ To use HubAuth, you must set the `.api_token`, either programmatically when cons
or via the `JUPYTERHUB_API_TOKEN` environment variable.
Most of the logic for authentication implementation is found in the
[`HubAuth.user_for_cookie`][hubauth.user_for_cookie]
and in the
[`HubAuth.user_for_token`][hubauth.user_for_token]
methods, which makes a request of the Hub, and returns:
@@ -241,11 +239,11 @@ from urllib.parse import quote
from flask import Flask, redirect, request, Response
from jupyterhub.services.auth import HubAuth
from jupyterhub.services.auth import HubOAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubAuth(
auth = HubOAuth(
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
cache_max_age=60,
)
@@ -257,11 +255,8 @@ def authenticated(f):
"""Decorator for authenticating with the Hub"""
@wraps(f)
def decorated(*args, **kwargs):
cookie = request.cookies.get(auth.cookie_name)
token = request.headers.get(auth.auth_header_name)
if cookie:
user = auth.user_for_cookie(cookie)
elif token:
if token:
user = auth.user_for_token(token)
else:
user = None

View File

@@ -1,6 +1,6 @@
# Authenticating a flask service with JupyterHub
Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [flask][] application.
Uses `jupyterhub.services.HubOAuth` to authenticate requests with the Hub in a [flask][] application.
## Run
@@ -8,7 +8,7 @@ Uses `jupyterhub.services.HubAuth` to authenticate requests with the Hub in a [f
jupyterhub --ip=127.0.0.1
2. Visit http://127.0.0.1:8000/services/whoami/ or http://127.0.0.1:8000/services/whoami-oauth/
2. Visit http://127.0.0.1:8000/services/whoami/
After logging in with your local-system credentials, you should see a JSON dump of your user info:

View File

@@ -5,10 +5,12 @@ c.JupyterHub.services = [
'command': ['flask', 'run', '--port=10101'],
'environment': {'FLASK_APP': 'whoami-flask.py'},
},
{
'name': 'whoami-oauth',
'url': 'http://127.0.0.1:10201',
'command': ['flask', 'run', '--port=10201'],
'environment': {'FLASK_APP': 'whoami-oauth.py'},
},
]
# dummy auth and simple spawner for testing
# any username and password will work
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.authenticator_class = 'dummy'
# listen only on localhost while testing with wide-open auth
c.JupyterHub.ip = '127.0.0.1'

View File

@@ -4,42 +4,48 @@ whoami service authentication with the Hub
"""
import json
import os
import secrets
from functools import wraps
from urllib.parse import quote
from flask import Flask
from flask import make_response
from flask import redirect
from flask import request
from flask import Response
from flask import session
from jupyterhub.services.auth import HubAuth
from jupyterhub.services.auth import HubOAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
app = Flask(__name__)
# encryption key for session cookies
app.secret_key = secrets.token_bytes(32)
def authenticated(f):
"""Decorator for authenticating with the Hub"""
"""Decorator for authenticating with the Hub via OAuth"""
@wraps(f)
def decorated(*args, **kwargs):
cookie = request.cookies.get(auth.cookie_name)
token = request.headers.get(auth.auth_header_name)
if cookie:
user = auth.user_for_cookie(cookie)
elif token:
token = session.get("token")
if token:
user = auth.user_for_token(token)
else:
user = None
if user:
return f(user, *args, **kwargs)
else:
# redirect to login url on failed auth
return redirect(auth.login_url + '?next=%s' % quote(request.path))
state = auth.generate_state(next_url=request.path)
response = make_response(redirect(auth.login_url + '&state=%s' % state))
response.set_cookie(auth.state_cookie_name, state)
return response
return decorated
@@ -50,3 +56,24 @@ def whoami(user):
return Response(
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
)
@app.route(prefix + 'oauth_callback')
def oauth_callback():
code = request.args.get('code', None)
if code is None:
return 403
# validate state field
arg_state = request.args.get('state', None)
cookie_state = request.cookies.get(auth.state_cookie_name)
if arg_state is None or arg_state != cookie_state:
# state doesn't match
return 403
token = auth.token_for_code(code)
# store token in session cookie
session["token"] = token
next_url = auth.get_next_url(cookie_state) or prefix
response = make_response(redirect(next_url))
return response

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env python3
"""
whoami service authentication with the Hub
"""
import json
import os
from functools import wraps
from flask import Flask
from flask import make_response
from flask import redirect
from flask import request
from flask import Response
from jupyterhub.services.auth import HubOAuth
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
app = Flask(__name__)
def authenticated(f):
"""Decorator for authenticating with the Hub via OAuth"""
@wraps(f)
def decorated(*args, **kwargs):
token = request.cookies.get(auth.cookie_name)
if token:
user = auth.user_for_token(token)
else:
user = None
if user:
return f(user, *args, **kwargs)
else:
# redirect to login url on failed auth
state = auth.generate_state(next_url=request.path)
response = make_response(redirect(auth.login_url + '&state=%s' % state))
response.set_cookie(auth.state_cookie_name, state)
return response
return decorated
@app.route(prefix)
@authenticated
def whoami(user):
return Response(
json.dumps(user, indent=1, sort_keys=True), mimetype='application/json'
)
@app.route(prefix + 'oauth_callback')
def oauth_callback():
code = request.args.get('code', None)
if code is None:
return 403
# validate state field
arg_state = request.args.get('state', None)
cookie_state = request.cookies.get(auth.state_cookie_name)
if arg_state is None or arg_state != cookie_state:
# state doesn't match
return 403
token = auth.token_for_code(code)
next_url = auth.get_next_url(cookie_state) or prefix
response = make_response(redirect(next_url))
response.set_cookie(auth.cookie_name, token)
return response

View File

@@ -14,7 +14,6 @@ from tornado import web
from .. import orm
from .. import scopes
from ..user import User
from ..utils import token_authenticated
from .base import APIHandler
from .base import BaseHandler
@@ -24,7 +23,7 @@ class TokenAPIHandler(APIHandler):
@token_authenticated
def get(self, token):
# FIXME: deprecate this API for oauth token resolution, in favor of using /api/user
# TODO: require specific scope for this deprecated API, applied to oauth client secrets only?
# TODO: require specific scope for this deprecated API, applied to service tokens only?
self.log.warning(
"/authorizations/token/:token endpoint is deprecated in JupyterHub 2.0. Use /api/user"
)
@@ -55,53 +54,20 @@ class TokenAPIHandler(APIHandler):
self.write(json.dumps(model))
async def post(self):
warn_msg = (
"Using deprecated token creation endpoint %s."
" Use /hub/api/users/:user/tokens instead."
) % self.request.uri
self.log.warning(warn_msg)
requester = user = self.current_user
if user is None:
# allow requesting a token with username and password
# for authenticators where that's possible
data = self.get_json_body()
try:
requester = user = await self.login_user(data)
except Exception as e:
self.log.error("Failure trying to authenticate with form data: %s" % e)
user = None
if user is None:
raise web.HTTPError(403)
else:
data = self.get_json_body()
# admin users can request tokens for other users
if data and data.get('username'):
user = self.find_user(data['username'])
if user is not requester and not requester.admin:
raise web.HTTPError(
403, "Only admins can request tokens for other users."
)
if requester.admin and user is None:
raise web.HTTPError(400, "No such user '%s'" % data['username'])
note = (data or {}).get('note')
if not note:
note = "Requested via deprecated api"
if requester is not user:
kind = 'user' if isinstance(user, User) else 'service'
note += " by %s %s" % (kind, requester.name)
api_token = user.new_api_token(note=note)
self.write(
json.dumps(
{'token': api_token, 'warning': warn_msg, 'user': self.user_model(user)}
)
404,
"Deprecated endpoint /hub/api/authorizations/token is removed in JupyterHub 2.0."
" Use /hub/api/users/:user/tokens instead.",
)
class CookieAPIHandler(APIHandler):
@token_authenticated
def get(self, cookie_name, cookie_value=None):
self.log.warning(
"/authorizations/cookie endpoint is deprecated in JupyterHub 2.0. Use /api/user with OAuth tokens."
)
cookie_name = quote(cookie_name, safe='')
if cookie_value is None:
self.log.warning(

View File

@@ -44,6 +44,11 @@ class SelfAPIHandler(APIHandler):
self.raw_scopes.update(scopes.identify_scopes(user.orm_user))
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
model = self.user_model(user)
# validate return, should have at least kind and name,
# otherwise our filters did something wrong
for key in ("kind", "name"):
if key not in model:
raise ValueError(f"Missing identify model for {user}: {model}")
self.write(json.dumps(model))

View File

@@ -269,6 +269,9 @@ def identify_scopes(obj):
for field in {"name", "admin", "groups"}
}
elif isinstance(obj, orm.Service):
# FIXME: need sub-scopes for services
# until then, we have just one service scope:
return {f"read:services!service={obj.name}"}
return {
f"read:services:{field}!service={obj.name}" for field in {"name", "admin"}
}

View File

@@ -1,7 +1,7 @@
"""Authenticating services with JupyterHub.
Cookies are sent to the Hub for verification. The Hub replies with a JSON
model describing the authenticated user.
Tokens are sent to the Hub for verification.
The Hub replies with a JSON model describing the authenticated user.
``HubAuth`` can be used in any application, even outside tornado.
@@ -10,6 +10,7 @@ authenticate with the Hub.
"""
import base64
import hashlib
import json
import os
import random
@@ -20,7 +21,6 @@ import time
import uuid
import warnings
from unittest import mock
from urllib.parse import quote
from urllib.parse import urlencode
import requests
@@ -113,9 +113,15 @@ class HubAuth(SingletonConfigurable):
This can be used by any application.
Use this base class only for direct, token-authenticated applications
(web APIs).
For applications that support direct visits from browsers,
use HubOAuth to enable OAuth redirect-based authentication.
If using tornado, use via :class:`HubAuthenticated` mixin.
If using manually, use the ``.user_for_cookie(cookie_value)`` method
to identify the user corresponding to a given cookie value.
If using manually, use the ``.user_for_token(token_value)`` method
to identify the user owning a given token.
The following config must be set:
@@ -129,9 +135,6 @@ class HubAuth(SingletonConfigurable):
- cookie_cache_max_age: the number of seconds responses
from the Hub should be cached.
- login_url (the *public* ``/hub/login`` URL of the Hub).
- cookie_name: the name of the cookie I should be using,
if different from the default (unlikely).
"""
hub_host = Unicode(
@@ -239,10 +242,6 @@ class HubAuth(SingletonConfigurable):
""",
).tag(config=True)
cookie_name = Unicode(
'jupyterhub-services', help="""The name of the cookie I should be looking for"""
).tag(config=True)
cookie_options = Dict(
help="""Additional options to pass when setting cookies.
@@ -286,12 +285,12 @@ class HubAuth(SingletonConfigurable):
def _default_cache(self):
return _ExpiringDict(self.cache_max_age)
def _check_hub_authorization(self, url, cache_key=None, use_cache=True):
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
"""Identify a user with the Hub
Args:
url (str): The API URL to check the Hub for authorization
(e.g. http://127.0.0.1:8081/hub/api/authorizations/token/abc-def)
(e.g. http://127.0.0.1:8081/hub/api/user)
cache_key (str): The key for checking the cache
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
@@ -309,7 +308,12 @@ class HubAuth(SingletonConfigurable):
except KeyError:
app_log.debug("HubAuth cache miss: %s", cache_key)
data = self._api_request('GET', url, allow_404=True)
data = self._api_request(
'GET',
url,
headers={"Authorization": "token " + api_token},
allow_403=True,
)
if data is None:
app_log.warning("No Hub user identified for request")
else:
@@ -321,7 +325,7 @@ class HubAuth(SingletonConfigurable):
def _api_request(self, method, url, **kwargs):
"""Make an API request"""
allow_404 = kwargs.pop('allow_404', False)
allow_403 = kwargs.pop('allow_403', False)
headers = kwargs.setdefault('headers', {})
headers.setdefault('Authorization', 'token %s' % self.api_token)
if "cert" not in kwargs and self.certfile and self.keyfile:
@@ -345,7 +349,7 @@ class HubAuth(SingletonConfigurable):
raise HTTPError(500, msg)
data = None
if r.status_code == 404 and allow_404:
if r.status_code == 403 and allow_403:
pass
elif r.status_code == 403:
app_log.error(
@@ -389,26 +393,9 @@ class HubAuth(SingletonConfigurable):
return data
def user_for_cookie(self, encrypted_cookie, use_cache=True, session_id=''):
"""Ask the Hub to identify the user for a given cookie.
Args:
encrypted_cookie (str): the cookie value (not decrypted, the Hub will do that)
use_cache (bool): Specify use_cache=False to skip cached cookie values (default: True)
Returns:
user_model (dict): The user model, if a user is identified, None if authentication fails.
The 'name' field contains the user's name.
"""
return self._check_hub_authorization(
url=url_path_join(
self.api_url,
"authorizations/cookie",
self.cookie_name,
quote(encrypted_cookie, safe=''),
),
cache_key='cookie:{}:{}'.format(session_id, encrypted_cookie),
use_cache=use_cache,
"""Deprecated and removed. Use HubOAuth to authenticate browsers."""
raise RuntimeError(
"Identifying users by shared cookie is removed in JupyterHub 2.0. Use OAuth tokens."
)
def user_for_token(self, token, use_cache=True, session_id=''):
@@ -425,14 +412,19 @@ class HubAuth(SingletonConfigurable):
"""
return self._check_hub_authorization(
url=url_path_join(
self.api_url, "authorizations/token", quote(token, safe='')
self.api_url,
"user",
),
api_token=token,
cache_key='token:{}:{}'.format(
session_id,
hashlib.sha256(token.encode("utf8", "replace")).hexdigest(),
),
cache_key='token:{}:{}'.format(session_id, token),
use_cache=use_cache,
)
auth_header_name = 'Authorization'
auth_header_pat = re.compile(r'token\s+(.+)', re.IGNORECASE)
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
def get_token(self, handler):
"""Get the user token from a request
@@ -453,10 +445,8 @@ class HubAuth(SingletonConfigurable):
def _get_user_cookie(self, handler):
"""Get the user model from a cookie"""
encrypted_cookie = handler.get_cookie(self.cookie_name)
session_id = self.get_session_id(handler)
if encrypted_cookie:
return self.user_for_cookie(encrypted_cookie, session_id=session_id)
# overridden in HubOAuth to store the access token after oauth
return None
def get_session_id(self, handler):
"""Get the jupyterhub session id
@@ -509,6 +499,9 @@ class HubAuth(SingletonConfigurable):
class HubOAuth(HubAuth):
"""HubAuth using OAuth for login instead of cookies set by the Hub.
Use this class if you want users to be able to visit your service with a browser.
They will be authenticated via OAuth with the Hub.
.. versionadded: 0.8
"""

View File

@@ -141,7 +141,6 @@ class OAuthCallbackHandlerMixin(HubOAuthCallbackHandler):
aliases = {
'user': 'SingleUserNotebookApp.user',
'group': 'SingleUserNotebookApp.group',
'cookie-name': 'HubAuth.cookie_name',
'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
'hub-host': 'SingleUserNotebookApp.hub_host',
'hub-api-url': 'SingleUserNotebookApp.hub_api_url',

View File

@@ -1176,76 +1176,13 @@ async def test_check_token(app):
assert r.status_code == 404
@mark.parametrize("headers, status", [({}, 200), ({'Authorization': 'token bad'}, 403)])
@mark.parametrize("headers, status", [({}, 404), ({'Authorization': 'token bad'}, 404)])
async def test_get_new_token_deprecated(app, headers, status):
# request a new token
r = await api_request(
app, 'authorizations', 'token', method='post', headers=headers
)
assert r.status_code == status
if status != 200:
return
reply = r.json()
assert 'token' in reply
r = await api_request(app, 'authorizations', 'token', reply['token'])
r.raise_for_status()
reply = r.json()
assert reply['name'] == 'admin'
async def test_token_formdata_deprecated(app):
"""Create a token for a user with formdata and no auth header"""
data = {'username': 'fake', 'password': 'fake'}
r = await api_request(
app,
'authorizations',
'token',
method='post',
data=json.dumps(data) if data else None,
noauth=True,
)
assert r.status_code == 200
reply = r.json()
assert 'token' in reply
r = await api_request(app, 'authorizations', 'token', reply['token'])
r.raise_for_status()
reply = r.json()
assert reply['name'] == data['username']
@mark.parametrize(
"as_user, for_user, status",
[
('admin', 'other', 200),
('admin', 'missing', 400),
('user', 'other', 403),
('user', 'user', 200),
],
)
async def test_token_as_user_deprecated(app, as_user, for_user, status):
# ensure both users exist
u = add_user(app.db, app, name=as_user)
if for_user != 'missing':
for_user_obj = add_user(app.db, app, name=for_user)
data = {'username': for_user}
headers = {'Authorization': 'token %s' % u.new_api_token()}
r = await api_request(
app,
'authorizations',
'token',
method='post',
data=json.dumps(data),
headers=headers,
)
assert r.status_code == status
reply = r.json()
if status != 200:
return
assert 'token' in reply
r = await api_request(app, 'authorizations', 'token', reply['token'])
r.raise_for_status()
reply = r.json()
assert reply['name'] == data['username']
@mark.parametrize(

View File

@@ -26,8 +26,8 @@ from tornado.web import RequestHandler
from .. import orm
from ..services.auth import _ExpiringDict
from ..services.auth import HubAuth
from ..services.auth import HubAuthenticated
from ..services.auth import HubOAuth
from ..services.auth import HubOAuthenticated
from ..utils import url_path_join
from .mocking import public_host
from .mocking import public_url
@@ -76,178 +76,6 @@ def test_expiring_dict():
assert cache.get('key', 'default') == 'cached value'
def test_hub_auth():
auth = HubAuth(cookie_name='foo')
mock_model = {'name': 'onyxia'}
url = url_path_join(auth.api_url, "authorizations/cookie/foo/bar")
with requests_mock.Mocker() as m:
m.get(url, text=json.dumps(mock_model))
user_model = auth.user_for_cookie('bar')
assert user_model == mock_model
# check cache
user_model = auth.user_for_cookie('bar')
assert user_model == mock_model
with requests_mock.Mocker() as m:
m.get(url, status_code=404)
user_model = auth.user_for_cookie('bar', use_cache=False)
assert user_model is None
# invalidate cache with timer
mock_model = {'name': 'willow'}
with monotonic_future, requests_mock.Mocker() as m:
m.get(url, text=json.dumps(mock_model))
user_model = auth.user_for_cookie('bar')
assert user_model == mock_model
with requests_mock.Mocker() as m:
m.get(url, status_code=500)
with raises(HTTPError) as exc_info:
user_model = auth.user_for_cookie('bar', use_cache=False)
assert exc_info.value.status_code == 502
with requests_mock.Mocker() as m:
m.get(url, status_code=400)
with raises(HTTPError) as exc_info:
user_model = auth.user_for_cookie('bar', use_cache=False)
assert exc_info.value.status_code == 500
def test_hub_authenticated(request):
auth = HubAuth(cookie_name='jubal')
mock_model = {'name': 'jubalearly', 'groups': ['lions']}
cookie_url = url_path_join(auth.api_url, "authorizations/cookie", auth.cookie_name)
good_url = url_path_join(cookie_url, "early")
bad_url = url_path_join(cookie_url, "late")
class TestHandler(HubAuthenticated, RequestHandler):
hub_auth = auth
@authenticated
def get(self):
self.finish(self.get_current_user())
# start hub-authenticated service in a thread:
port = 50505
q = Queue()
def run():
asyncio.set_event_loop(asyncio.new_event_loop())
app = Application([('/*', TestHandler)], login_url=auth.login_url)
http_server = HTTPServer(app)
http_server.listen(port)
loop = IOLoop.current()
loop.add_callback(lambda: q.put(loop))
loop.start()
t = Thread(target=run)
t.start()
def finish_thread():
loop.add_callback(loop.stop)
t.join(timeout=30)
assert not t.is_alive()
request.addfinalizer(finish_thread)
# wait for thread to start
loop = q.get(timeout=10)
with requests_mock.Mocker(real_http=True) as m:
# no cookie
r = requests.get('http://127.0.0.1:%i' % port, allow_redirects=False)
r.raise_for_status()
assert r.status_code == 302
assert auth.login_url in r.headers['Location']
# wrong cookie
m.get(bad_url, status_code=404)
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'late'},
allow_redirects=False,
)
r.raise_for_status()
assert r.status_code == 302
assert auth.login_url in r.headers['Location']
# clear the cache because we are going to request
# the same url again with a different result
auth.cache.clear()
# upstream 403
m.get(bad_url, status_code=403)
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'late'},
allow_redirects=False,
)
assert r.status_code == 500
m.get(good_url, text=json.dumps(mock_model))
# no specific allowed user
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'early'},
allow_redirects=False,
)
r.raise_for_status()
assert r.status_code == 200
# pass allowed user
TestHandler.hub_users = {'jubalearly'}
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'early'},
allow_redirects=False,
)
r.raise_for_status()
assert r.status_code == 200
# no pass allowed ser
TestHandler.hub_users = {'kaylee'}
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'early'},
allow_redirects=False,
)
assert r.status_code == 403
# pass allowed group
TestHandler.hub_groups = {'lions'}
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'early'},
allow_redirects=False,
)
r.raise_for_status()
assert r.status_code == 200
# no pass allowed group
TestHandler.hub_groups = {'tigers'}
r = requests.get(
'http://127.0.0.1:%i' % port,
cookies={'jubal': 'early'},
allow_redirects=False,
)
assert r.status_code == 403
async def test_hubauth_cookie(app, mockservice_url):
"""Test HubAuthenticated service with user cookies"""
cookies = await app.login_user('badger')
r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/', cookies=cookies
)
r.raise_for_status()
print(r.text)
reply = r.json()
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
assert sub_reply == {'name': 'badger', 'admin': False}
async def test_hubauth_token(app, mockservice_url):
"""Test HubAuthenticated service with user API tokens"""
u = add_user(app.db, name='river')
@@ -295,8 +123,10 @@ async def test_hubauth_service_token(app, mockservice_url):
r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/',
headers={'Authorization': 'token %s' % token},
allow_redirects=False,
)
r.raise_for_status()
assert r.status_code == 200
reply = r.json()
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
assert not r.cookies