summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-08-04 11:33:39 +0300
committerLars Wirzenius <liw@liw.fi>2018-08-05 12:10:42 +0300
commit5416c57cd286ab614129a398fe4d2da681ecc8f4 (patch)
tree13c3c7a7c8fa827286cd7abc77e198122f0794d0
parent99ee63f8247a7c89ca1180838db1a4974812ed23 (diff)
downloadqvisqve-5416c57cd286ab614129a398fe4d2da681ecc8f4.tar.gz
Add: OIDC authorization code flow
-rw-r--r--qvisqve/__init__.py7
-rw-r--r--qvisqve/api.py7
-rw-r--r--qvisqve/auth_router.py108
-rw-r--r--qvisqve/authz_attempt.py120
-rw-r--r--qvisqve/authz_attempt_tests.py115
-rw-r--r--qvisqve/token.py4
-rw-r--r--qvisqve/token_router.py36
-rw-r--r--yarns/300-end-user-auth.yarn153
-rw-r--r--yarns/900-implements.yarn85
-rw-r--r--yarns/900-local.yarn8
-rw-r--r--yarns/lib.py5
11 files changed, 606 insertions, 42 deletions
diff --git a/qvisqve/__init__.py b/qvisqve/__init__.py
index 0627c7b..a596298 100644
--- a/qvisqve/__init__.py
+++ b/qvisqve/__init__.py
@@ -52,3 +52,10 @@ from .token_router import TokenRouter
from .api import API
from .app import create_app
+
+from .noncegen import NonceGenerator
+from .authz_attempt import (
+ AuthorizationAttempt,
+ AuthorizationAttemptError,
+ AuthorizationAttempts,
+)
diff --git a/qvisqve/api.py b/qvisqve/api.py
index b63bdb6..a08dd73 100644
--- a/qvisqve/api.py
+++ b/qvisqve/api.py
@@ -23,6 +23,7 @@ class API:
self._config = config
self._rs = None
self._routes = None
+ self._attempts = qvisqve.AuthorizationAttempts()
def find_missing_route(self, path):
qvisqve.log.log('info', msg_text='find_missing_route', path=path)
@@ -35,10 +36,12 @@ class API:
qvisqve.VersionRouter(),
qvisqve.ManagementRouter(storedir, baseurl),
qvisqve.TokenRouter(
- self._create_token_generator(), self._get_clients()),
+ self._create_token_generator(), self._get_clients(),
+ self._attempts),
qvisqve.LoginRouter(),
qvisqve.AuthRouter(
- self._get_applications(), self._get_users()),
+ self._get_applications(), self._get_users(),
+ self._attempts),
]
self._routes = []
diff --git a/qvisqve/auth_router.py b/qvisqve/auth_router.py
index a91661c..097b5e7 100644
--- a/qvisqve/auth_router.py
+++ b/qvisqve/auth_router.py
@@ -17,54 +17,134 @@
import urllib.parse
+import bottle
+
+
import qvisqve
+login_form = '''
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta name="description" content="Qvisqve Login">
+ <meta name="author" content="Qvarnlabs Ltd">
+ <title>Qvisqve Login</title>
+ </head>
+ <body>
+ <form action="/auth" method="POST">
+ <input name="attempt_id" value="{{attempt_id}}" type="hidden" />
+ User name: <input name="username" type="text" />
+ <br />
+ Password: <input name="password" type="password" />
+ <br />
+ <input type="submit" value="Login" />
+ </form>
+ </body>
+</html>
+'''
+
+
class AuthRouter(qvisqve.Router):
- def __init__(self, apps, users):
+ def __init__(self, apps, users, authz_attempts):
super().__init__()
self._apps = apps
self._users = users
+ self._attempts = authz_attempts
def get_routes(self):
return [
{
+ 'method': 'GET',
+ 'path': '/auth',
+ 'callback': self._start_authz_code_flow,
+ 'needs-authorization': False,
+ },
+ {
'method': 'POST',
'path': '/auth',
- 'callback': self._auth,
+ 'callback': self._check_user_creds,
'needs-authorization': False,
},
]
- def _auth(self, content_type, body, *args, **kwargs):
+ def _start_authz_code_flow(self, content_type, body, *args, **kwargs):
+ qvisqve.log.log(
+ 'trace', msg_text='_start_authz_code_flow', args=args,
+ kwargs=kwargs, content_type=content_type, body=body)
+ path = kwargs['raw_uri_path']
+ if '?' not in path:
+ return qvisqve.bad_request_response('Not like that')
+
+ path, qs = path.split('?', 1)
+ params = urllib.parse.parse_qs(qs)
+ cleaned = self._cleanup_params(params)
+ qvisqve.log.log(
+ 'trace', msg_text='params', path=path, qs=qs, params=params,
+ cleaned=cleaned)
+
+ aa = self._attempts.create_attempt(cleaned)
+ form = bottle.template(login_form, attempt_id=aa.get_attempt_id())
+ headers = {
+ 'Content-Type': 'text/html; charset=utf-8',
+ }
+ return qvisqve.ok_response(form, headers=headers)
+
+ def _cleanup_params(self, params):
+ return {
+ name: params[name][-1]
+ for name in params
+ }
+
+ def _check_user_creds(self, content_type, body, *args, **kwargs):
+ qvisqve.log.log(
+ 'trace', msg_text='_check_user_creds', args=args,
+ kwargs=kwargs, content_type=content_type, body=body)
+
if content_type != 'application/x-www-form-urlencoded':
return qvisqve.bad_request_response('Wrong content type')
params = self._get_form_params(body)
username = self._get_param(params, 'username')
password = self._get_param(params, 'password')
+ attempt_id = self._get_param(params, 'attempt_id')
+ qvisqve.log.log(
+ 'trace', msg_text='extracted form parameters', params=params,
+ username=username, password=password, attempt_id=attempt_id)
+ if None in (username, password, attempt_id):
+ return qvisqve.unauthorized_response('Access denied')
+
if not self._users.is_valid_secret(username, password):
return qvisqve.unauthorized_response('Access denied')
- # TODO:
- # - perform actual auth
- # - create and store auth code
- # - use callback url provided in request
+ aa = self._attempts.find_by_id(attempt_id)
+ if aa is None:
+ return qvisqve.unauthorized_response('Access denied')
- # FIXME use real app name here
- callbacks = self._apps.get_callbacks('facade')
- callback_url = callbacks[0]
+ aa.set_subject_id(username)
- params = urllib.parse.urlencode({'code': 123})
- url = '{}?{}'.format(callback_url, params)
+ gen = qvisqve.NonceGenerator()
+ code = gen.create_nonce()
+ aa.set_authorization_code(code)
- qvisqve.log.log('xxx', msg_text='Returning redirect', url=url)
+ params = {
+ 'code': code,
+ }
+ url = '{}?{}'.format(
+ aa.get_redirect_uri(),
+ urllib.parse.urlencode(params)
+ )
+ qvisqve.log.log('trace', msg_text='Returning redirect', url=url)
return qvisqve.found_response('Redirect to callback url', url)
def _get_param(self, params, name):
- return params[name][0]
+ values = params.get(name)
+ if not isinstance(values, list) or len(values) < 1:
+ return None
+ return values[0]
def _get_form_params(self, body):
body = body.decode('UTF-8')
diff --git a/qvisqve/authz_attempt.py b/qvisqve/authz_attempt.py
new file mode 100644
index 0000000..229c802
--- /dev/null
+++ b/qvisqve/authz_attempt.py
@@ -0,0 +1,120 @@
+# Copyright (C) 2018 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import qvisqve
+
+
+class AuthorizationAttempt:
+
+ def __init__(self):
+ self._attempt_id = None
+ self._client_id = None
+ self._subject_id = None
+ self._state = None
+ self._redirect_uri = None
+ self._scope = None
+ self._authorization_code = None
+
+ def set_client_id(self, client_id):
+ self._client_id = client_id
+
+ def get_client_id(self):
+ return self._client_id
+
+ def set_subject_id(self, subject_id):
+ self._subject_id = subject_id
+
+ def get_subject_id(self):
+ return self._subject_id
+
+ def set_state(self, state):
+ self._state = state
+
+ def get_state(self):
+ return self._state
+
+ def set_redirect_uri(self, uri):
+ self._redirect_uri = uri
+
+ def get_redirect_uri(self):
+ return self._redirect_uri
+
+ def set_scope(self, scope):
+ self._scope = scope
+
+ def get_scope(self):
+ return self._scope
+
+ def set_attempt_id(self, attempt_id):
+ required = [
+ '_client_id',
+ '_state',
+ '_redirect_uri',
+ '_scope',
+ ]
+ for attr in required:
+ if getattr(self, attr, None) is None:
+ raise AuthorizationAttemptError()
+
+ self._attempt_id = attempt_id
+
+ def get_attempt_id(self):
+ return self._attempt_id
+
+ def set_authorization_code(self, authorization_code):
+ self._authorization_code = authorization_code
+
+ def get_authorization_code(self):
+ return self._authorization_code
+
+
+class AuthorizationAttemptError(Exception):
+
+ pass
+
+
+class AuthorizationAttempts:
+
+ def __init__(self):
+ self._attempts = []
+
+ def create_attempt(self, urlparams):
+ gen = qvisqve.NonceGenerator()
+ attempt_id = gen.create_nonce()
+
+ aa = AuthorizationAttempt()
+
+ aa.set_client_id(urlparams['client_id'])
+ aa.set_state(urlparams['state'])
+ aa.set_redirect_uri(urlparams['redirect_uri'])
+ aa.set_scope(urlparams['scope'])
+
+ aa.set_attempt_id(attempt_id)
+
+ self._attempts.append(aa)
+ return aa
+
+ def find_by_id(self, attempt_id):
+ for aa in self._attempts:
+ if aa.get_attempt_id() == attempt_id:
+ return aa
+ return None
+
+ def find_by_code(self, code):
+ for aa in self._attempts:
+ if aa.get_authorization_code() == code:
+ return aa
+ return None
diff --git a/qvisqve/authz_attempt_tests.py b/qvisqve/authz_attempt_tests.py
new file mode 100644
index 0000000..d46660d
--- /dev/null
+++ b/qvisqve/authz_attempt_tests.py
@@ -0,0 +1,115 @@
+# Copyright (C) 2018 Lars Wirzenius
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+import qvisqve
+
+
+class AuthorizationAttemptTests(unittest.TestCase):
+
+ def test_raises_error_creating_attempt_id_before_all_fields_set(self):
+ gen = qvisqve.NonceGenerator()
+ attempt_id = gen.create_nonce()
+
+ aa = qvisqve.AuthorizationAttempt()
+ subject_id = 'subject_id'
+ client_id = 'client_id'
+ state = 'state'
+ uri = 'https://facade/callback'
+ scope = 'scope'
+
+ with self.assertRaises(qvisqve.AuthorizationAttemptError):
+ aa.set_attempt_id(attempt_id)
+
+ aa.set_client_id(client_id)
+ with self.assertRaises(qvisqve.AuthorizationAttemptError):
+ aa.set_attempt_id(attempt_id)
+
+ aa.set_state(state)
+ with self.assertRaises(qvisqve.AuthorizationAttemptError):
+ aa.set_attempt_id(attempt_id)
+
+ aa.set_redirect_uri(uri)
+ with self.assertRaises(qvisqve.AuthorizationAttemptError):
+ aa.set_attempt_id(attempt_id)
+
+ aa.set_scope(scope)
+ aa.set_subject_id(subject_id)
+ aa.set_attempt_id(attempt_id)
+
+ self.assertEqual(aa.get_subject_id(), subject_id)
+ self.assertEqual(aa.get_client_id(), client_id)
+ self.assertEqual(aa.get_state(), state)
+ self.assertEqual(aa.get_redirect_uri(), uri)
+ self.assertEqual(aa.get_scope(), scope)
+ self.assertEqual(aa.get_attempt_id(), attempt_id)
+
+ def test_has_not_authz_code_initially(self):
+ aa = qvisqve.AuthorizationAttempt()
+ self.assertEqual(aa.get_authorization_code(), None)
+
+ def test_sets_authz_code(self):
+ aa = qvisqve.AuthorizationAttempt()
+ code = '12765'
+ aa.set_authorization_code(code)
+ self.assertEqual(aa.get_authorization_code(), code)
+
+
+class AuthorizationAttemptsTests(unittest.TestCase):
+
+ def setUp(self):
+ self.urlparams = {
+ 'scope': 'openid read',
+ 'client_id': 'client_id',
+ 'state': 'RANDOM',
+ 'redirect_uri': 'https://facade',
+ }
+ self.aas = qvisqve.AuthorizationAttempts()
+
+ def test_creates_attempt(self):
+ aa = self.aas.create_attempt(self.urlparams)
+ attempt_id = aa.get_attempt_id()
+ self.assertNotEqual(attempt_id, None)
+
+ self.assertEqual(aa.get_scope(), self.urlparams['scope'])
+ self.assertEqual(aa.get_client_id(), self.urlparams['client_id'])
+ self.assertEqual(aa.get_state(), self.urlparams['state'])
+ self.assertEqual(aa.get_redirect_uri(), self.urlparams['redirect_uri'])
+
+ def test_finds_by_id(self):
+ aa = self.aas.create_attempt(self.urlparams)
+ attempt_id = aa.get_attempt_id()
+ self.assertEqual(aa, self.aas.find_by_id(attempt_id))
+
+ def test_returns_none_when_finding_by_a_non_existent_id(self):
+ aa = self.aas.create_attempt(self.urlparams)
+ attempt_id = aa.get_attempt_id()
+ nonexistent = attempt_id * 2
+ self.assertEqual(self.aas.find_by_id(nonexistent), None)
+
+ def test_finds_by_code(self):
+ aa = self.aas.create_attempt(self.urlparams)
+ code = 'xxx'
+ aa.set_authorization_code(code)
+ self.assertEqual(aa, self.aas.find_by_code(code))
+
+ def test_returns_none_when_finding_by_a_non_existent_code(self):
+ aa = self.aas.create_attempt(self.urlparams)
+ code = 'xxx'
+ aa.set_authorization_code(code)
+ nonexistent = 'yyy'
+ self.assertEqual(self.aas.find_by_code(nonexistent), None)
diff --git a/qvisqve/token.py b/qvisqve/token.py
index 3b343c2..edc62fd 100644
--- a/qvisqve/token.py
+++ b/qvisqve/token.py
@@ -50,7 +50,7 @@ class TokenGenerator:
'info', msg_text='Set signing key', key=self._key,
orig_key=key, imported_key=imported_key)
- def new_token(self, audience, scope):
+ def new_token(self, audience, scope, subject_id=None):
assert self._issuer is not None
assert self._lifetime is not None
assert self._key is not None
@@ -58,7 +58,7 @@ class TokenGenerator:
now = time.time()
claims = {
'iss': self._issuer,
- 'sub': '',
+ 'sub': subject_id or '',
'aud': audience,
'exp': now + self._lifetime,
'scope': scope,
diff --git a/qvisqve/token_router.py b/qvisqve/token_router.py
index 911e899..c510b8b 100644
--- a/qvisqve/token_router.py
+++ b/qvisqve/token_router.py
@@ -26,10 +26,10 @@ import qvisqve_secrets
class TokenRouter(qvisqve.Router):
- def __init__(self, token_generator, clients):
+ def __init__(self, token_generator, clients, authz_attempts):
qvisqve.log.log('debug', msg_text='TokenRouter init starts')
super().__init__()
- args = (clients, token_generator)
+ args = (clients, token_generator, authz_attempts)
self._grants = {
'client_credentials': ClientCredentialsGrant(*args),
'authorization_code': AuthorizationCodeGrant(*args),
@@ -75,9 +75,10 @@ class TokenRouter(qvisqve.Router):
class Grant:
- def __init__(self, clients, generator):
+ def __init__(self, clients, generator, authz_attempts):
self._clients = clients
self._generator = generator
+ self._attempts = authz_attempts
class ClientCredentialsGrant(Grant):
@@ -123,11 +124,32 @@ class ClientCredentialsGrant(Grant):
class AuthorizationCodeGrant(Grant):
def get_token(self, request, params):
+ client_id, client_secret = request.auth
+ if not self._clients.is_valid_secret(client_id, client_secret):
+ qvisqve.log.log('error', msg_text='Invalid client creds given')
+ return qvisqve.unauthorized_response('Access denied')
+
code = self._get_code(params)
- # FIXME
- if code is None or code != '123':
- return qvisqve.unauthorized_response('Unauthorized')
- empty_token = self._generator.new_token('', '')
+ if code is None:
+ qvisqve.log.log('error', msg_text='No code given')
+ return qvisqve.unauthorized_response('Access denied')
+
+ aa = self._attempts.find_by_code(code)
+ if aa is None:
+ qvisqve.log.log('error', msg_text='Unknown code given', code=code)
+ return qvisqve.unauthorized_response('Access denied')
+
+ subject_id = aa.get_subject_id()
+ scope = aa.get_scope()
+ allowed = self._clients.get_allowed_scopes(client_id)
+ scope = ' '.join(
+ s
+ for s in scope.split()
+ if s in allowed
+ )
+
+ empty_token = self._generator.new_token(
+ '', scope, subject_id=subject_id)
return qvisqve.ok_response({
'access_token': empty_token,
'token_type': 'Bearer',
diff --git a/yarns/300-end-user-auth.yarn b/yarns/300-end-user-auth.yarn
index 46d6236..e69ccad 100644
--- a/yarns/300-end-user-auth.yarn
+++ b/yarns/300-end-user-auth.yarn
@@ -7,39 +7,178 @@ subset of that. It's just enough for us to have some form of login, to
set up a continuous delivery pipeline for it, and to start building
the full thing.
-FIXME: Explain the login process here, with sequence diagram.
+OpenID Connect
+-----------------------------------------------------------------------------
+
+[OpenID Connect]: https://openid.net/specs/openid-connect-core-1_0.html
+[OAuth2]: https://tools.ietf.org/html/rfc6749
+
+[OpenID Connect][] (OIDC) is a standard protocol for authenticating
+end-users. It is an extension of the [OAuth2][] protocol, which
+provides authorization. The distinction is important: OAuth2 allows a
+user let an application access the user's data on some service, but
+does not itself provide a way to verify the user's identity. OIDC
+provides a way for the service to do the identity verification
+(authentication).
+
+There are, in the OIDC context, several entities involved:
+
+* The **end-user**, who owns some data on the resource server. Ownership
+ here is in the legal sense.
+
+* The end-user's user agent, also known as the browser. This is what
+ the end-user interacts with directly.
+
+* The **resource server**, where the actual data is. It is assumed the
+ data is sensitive, and needs to be protected, but that some access
+ should be allowed.
+
+* The **facade application**, which accesses data on the resource
+ server, and processes it in some way for the user's benefit.
+
+* The **OpenID provider** authenticates the user and produces tokens
+ for the facade application to access the user's data on the resource
+ server. This is Qvisqve.
+
+The OIDC authorization code flow works like this, at a very high
+level:
+
+* User initiates an authentication process. Typically by clicking on a
+ login link, but might also happen when the facade notices
+ authentication is needed, such as when its token expires. In either
+ case, there is an HTTP request from the browser to the facade.
+
+* The facade returns a 302 redirect to the browser. The redirect takes
+ the browser to the OpenID provider, and supplies a number of
+ parameters in the URL to identify the application, and what access
+ it wants.
+
+* The OpenID provider verifies the user's identify, such as by asking
+ them for their username and password.
+
+* Once the identity is verified, the OpenID provider redirects the
+ browser back to the facade, and gives a unique, single-use
+ authorization code as part of the redirected URL.
+
+* The facade extracs the authorization code, and requests an access
+ token from the OpenID provider. As part of that, the facade
+ authenticates itself to the OpenID provider.
+
+* The facade uses the access token to get data from the resource
+ server, and shows it to the user via the browser.
+
+Note that the access token never reaches the browser. Also, it is
+given only to an authenticated facade application. Further, the facade
+application never sees the user's login creentials. There's more
+details in the protocol to mitigate replay attack, cross-site
+forgeries and other shenanigans.
+
+Test scenario
+-----------------------------------------------------------------------------
+
+This scenario shows the steps of authenticating the end-user.
SCENARIO end-user interactive login
+We need to have a Qvisqve, and there needs to be a user account
+configured for it. Further, the facade application must also be
+registerd, before the login process starts.
+
GIVEN a Qvisqve configuration for "https://qvisqve"
AND Qvisqve configuration has user account tomjon with password hunter2
AND Qvisqve configuration has application facade
... with callback url https://facade/callback
+ ... and secret happydays
+ ... and allowed scopes read write
AND a running Qvisqve instance
-User goes to the login URL and gets a login page.
+User goes to the facade's login URL and gets a redirect to Qvisqve's
+/auth endpoint. We skip the request to the facade. The redirect to
+/auth includes important parameters. The parameters are:
+
+* `response_type=code` &mdash; identify this as the start of an
+ authorization code flow
+* `scope=openid+read` &mdash; space delimited list of scope names
+ required by the facade, plus `openid` to indicate we want OIDC
+* `client_id=facade` &mdash; identify the application; note that at
+ this point Qvisqve can't verify this
+* `state=RANDOM`&mdash; a unique, single-use, hard-to-guess value
+* `redirect_uri=https://facade/callback` &mdash; where the browser
+ should go when the user has been identified
- WHEN browser requests GET /login
+Since the request to Qvisqve is always done as a result of an HTTP 302
+redirect response from the facade, it's always a GET request, and the
+parameters are part of the URL. The response should be login form. The
+form will have a hidden field, with which Qvisqve will know which
+authentication attempt is happening. This field will have a new,
+unique, hard-to-guess value every time the user authenication starts
+anew. Note that this should probably be different from the `state`
+value from the facade (FIXME: why?).
+
+ WHEN browser requests GET /auth?response_type=code&scope=openid+read&client_id=facade&state=RANDOM&redirect_uri=https://facade/callback
THEN HTTP status code is 200 OK
AND Content-Type is text/html
AND body has an HTML form with field username
AND body has an HTML form with field password
+ AND body has an HTML form with field attempt_id
+ AND HTML form field attempt_id is saved as ATTEMPTID
+
+The user enters their login credentials, and presses submit. Qvisqve
+verifies the credentials.
WHEN browser requests POST /auth, with form values
- ... username=tomjon and password=wrong
+ ... username=tomjon and password=wrong and attempt_id=${ATTEMPTID}
THEN HTTP status code is 401 Unauthorized
+FIXME: a 401 error is unfriendly, the user should be given an
+opportunity to try again. Fix later. Anyway, let's say user can use
+the back button and tries again with the correct password.
+
+Qvisqve accepts the credentials this time, and generates an
+"authorization code". This is a unique, hard-to-predict value that can
+be used only once. The code is added to the facade callback URL, and
+the browser is redirected to that. This is how the facade gets the
+code. The browser also gets the code, but since using the code
+requires the facade's client credentials, the browser can't use the
+authorization code for anything, so it's acceptably safe to let the
+browser see it.
+
WHEN browser requests POST /auth, with form values
- ... username=tomjon and password=hunter2
+ ... username=tomjon and password=hunter2 and attempt_id=${ATTEMPTID}
THEN HTTP status code is 302 Found
- AND HTTP Location header is https://facade/callback?code=123
+ AND HTTP Location header starts with https://facade/callback?
+ AND HTTP Location header is saved as LOCATION
+ AND authorization code from LOCATION is saved as CODE
+
+The browser follows the redirect to the facade. The facade extracts
+the authorization code, and uses its own client credentials to
+retrieve the access token corresponding to the code.
+
+ WHEN facade requests POST /token, with
+ ... form values grant_type=authorization_code and code=${CODE}
+ ... using Basic Auth with username facade, password wrong
+ THEN HTTP status code is 401 Unauthorized
WHEN facade requests POST /token, with
- ... form values grant_type=authorization_code and code=123
+ ... form values grant_type=authorization_code and code=${CODE}
+ ... using Basic Auth with username facade, password happydays
+
+Qvisqve returns the access token. It has the requested scope, minus
+"openid".
+
+FIXME: The aud field should have the facade as one of the values, but
+the resource server needs to also be there. Or else the resource
+server needs to ignore aud, which seems fishy, or it needs to accept
+the facade as the aud, which seems tricky. I don't know how to handle
+this. Needs research and thinking.
+
THEN HTTP status code is 200 OK
AND Content-Type is application/json
AND JSON body has field access_token
AND JSON body has field token_type, with value Bearer
AND JSON body has field expires_in
+ AND access token has a scope field set to read
+ AND access token has a sub field set to tomjon
+
FINALLY Qvisqve is stopped
diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn
index 25a7e11..d63472c 100644
--- a/yarns/900-implements.yarn
+++ b/yarns/900-implements.yarn
@@ -86,21 +86,40 @@ This chapter shows the scenario step implementations.
path = get_next_match()
V['status_code'], V['headers'], V['body'] = get(V['API_URL'] + path, {})
- IMPLEMENTS WHEN (browser|facade) requests POST (\S+), with form values (\S+)=(\S+) and (\S+)=(\S+)
- who = get_next_match()
+ IMPLEMENTS WHEN browser requests POST (\S+), with form values (\S+)=(\S+) and (\S+)=(\S+) and (\S+)=(\S+)
path = get_next_match()
field1 = get_next_match()
- value1 = get_next_match()
+ value1 = expand_vars(get_next_match(), V)
field2 = get_next_match()
- value2 = get_next_match()
+ value2 = expand_vars(get_next_match(), V)
+ field3 = get_next_match()
+ value3 = expand_vars(get_next_match(), V)
headers = {}
body = {
field1: value1,
field2: value2,
+ field3: value3,
}
V['status_code'], V['headers'], V['body'] = post(
V['API_URL'] + path, headers=headers, body=body)
+ IMPLEMENTS WHEN facade requests POST (\S+), with form values (\S+)=(\S+) and (\S+)=(\S+) using Basic Auth with username (\S+), password (\S+)
+ path = get_next_match()
+ field1 = get_next_match()
+ value1 = expand_vars(get_next_match(), V)
+ field2 = get_next_match()
+ value2 = expand_vars(get_next_match(), V)
+ username = get_next_match()
+ password = get_next_match()
+ headers = {}
+ body = {
+ field1: value1,
+ field2: value2,
+ }
+ V['status_code'], V['headers'], V['body'] = post(
+ V['API_URL'] + path, headers=headers, body=body,
+ auth=(username, password))
+
## API access token creation
IMPLEMENTS WHEN client gets an authorization token with scope "(.+)"
@@ -122,10 +141,24 @@ This chapter shows the scenario step implementations.
expected = int(get_next_match())
assertEqual(V['status_code'], expected)
- IMPLEMENTS THEN HTTP (\S+) header is (.+)
+ IMPLEMENTS THEN HTTP (\S+) header starts with (.+)
+ header = get_next_match()
+ wanted = expand_vars(get_next_match(), V)
+ actual = V['headers'].get(header)
+ assertTrue(actual.startswith(wanted))
+
+ IMPLEMENTS THEN HTTP (\S+) header is saved as (.+)
header = get_next_match()
- value = expand_vars(get_next_match(), V)
- assertEqual(V['headers'].get(header), value)
+ name = get_next_match()
+ V[name] = V['headers'].get(header)
+
+ IMPLEMENTS THEN authorization code from (\S+) is saved as (\S+)
+ import urlparse
+ var1 = get_next_match()
+ var2 = get_next_match()
+ parts = urlparse.urlparse(V[var1])
+ params = urlparse.parse_qs(parts.query)
+ V[var2] = params['code'][0]
IMPLEMENTS THEN remember HTTP (\S+) header as (.+)
header = get_next_match()
@@ -178,6 +211,19 @@ This chapter shows the scenario step implementations.
pattern = '<input name="{}"'.format(field)
assertTrue(pattern in body)
+ IMPLEMENTS THEN HTML form field (.+) is saved as (\S+)
+ import re
+ field = get_next_match()
+ name = get_next_match()
+ body = V['body']
+ pattern = '<input name="{}" value="([^"]+)"'.format(field)
+ m = re.search(pattern, body, re.M)
+ print('body', repr(body))
+ print('pattern:', pattern)
+ print('m:', m)
+ print('m.groups():', m.groups())
+ V[name] = m.groups(0)[0]
+
IMPLEMENTS THEN Content-Type is (\S+)
wanted = get_next_match()
headers = V['headers']
@@ -222,3 +268,28 @@ This chapter shows the scenario step implementations.
body = V['body']
body = json.loads(body)
assertEqual(body.get(field), value)
+
+ IMPLEMENTS THEN access token has a (\S+) field set to (\S+)
+ field = get_next_match()
+ value = get_next_match()
+ body = V['body']
+ body = json.loads(body)
+ token = body['access_token']
+ claims = token_decode(token, V['pubkey'])
+ print('claims', claims)
+ print('value', value)
+ assertEqual(claims.get(field), value)
+
+ IMPLEMENTS THEN access token has an (\S+) field that is not empty
+ field = get_next_match()
+ value = get_next_match()
+ body = V['body']
+ body = json.loads(body)
+ token = body['access_token']
+ claims = token_decode(token)
+ tf = token.get(field)
+ assertTrue(tf is not None)
+ assertTrue(isinstance(tf, str))
+ assertTrue(tf != "")
+
+
diff --git a/yarns/900-local.yarn b/yarns/900-local.yarn
index 8c9fd1d..c9721bc 100644
--- a/yarns/900-local.yarn
+++ b/yarns/900-local.yarn
@@ -42,10 +42,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
password = get_next_match()
V['users'] = { username: password }
- IMPLEMENTS GIVEN Qvisqve configuration has application (\S+) with callback url (\S+)
+ IMPLEMENTS GIVEN Qvisqve configuration has application (\S+) with callback url (\S+) and secret (\S+) and allowed scopes (.+)
app = get_next_match()
callback = get_next_match()
+ secret = get_next_match()
+ scopestr = get_next_match()
+ # FIXME: store secret somewhere
V['applications'] = { app: callback }
+ V['client_id'] = app
+ V['client_secret'] = secret
+ V['allowed_scopes'] = scopestr.split()
## Authentication setup
diff --git a/yarns/lib.py b/yarns/lib.py
index 7d83c08..96c93ad 100644
--- a/yarns/lib.py
+++ b/yarns/lib.py
@@ -74,9 +74,10 @@ def get(url, headers=None):
return r.status_code, dict(r.headers), r.content
-def post(url, headers=None, body=None):
+def post(url, headers=None, body=None, auth=None):
r = requests.post(
- url, headers=headers, data=body, verify=False, allow_redirects=False)
+ url, headers=headers, data=body, auth=auth, verify=False,
+ allow_redirects=False)
return r.status_code, dict(r.headers), r.text