From 5416c57cd286ab614129a398fe4d2da681ecc8f4 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 4 Aug 2018 11:33:39 +0300 Subject: Add: OIDC authorization code flow --- qvisqve/__init__.py | 7 ++ qvisqve/api.py | 7 +- qvisqve/auth_router.py | 108 +++++++++++++++++++++++++---- qvisqve/authz_attempt.py | 120 ++++++++++++++++++++++++++++++++ qvisqve/authz_attempt_tests.py | 115 +++++++++++++++++++++++++++++++ qvisqve/token.py | 4 +- qvisqve/token_router.py | 36 ++++++++-- yarns/300-end-user-auth.yarn | 153 +++++++++++++++++++++++++++++++++++++++-- yarns/900-implements.yarn | 85 +++++++++++++++++++++-- yarns/900-local.yarn | 8 ++- yarns/lib.py | 5 +- 11 files changed, 606 insertions(+), 42 deletions(-) create mode 100644 qvisqve/authz_attempt.py create mode 100644 qvisqve/authz_attempt_tests.py 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 = ''' + + + + + + + Qvisqve Login + + +
+ + User name: +
+ Password: +
+ +
+ + +''' + + 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 . + + +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 . + + +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` — identify this as the start of an + authorization code flow +* `scope=openid+read` — space delimited list of scope names + required by the facade, plus `openid` to indicate we want OIDC +* `client_id=facade` — identify the application; note that at + this point Qvisqve can't verify this +* `state=RANDOM`— a unique, single-use, hard-to-guess value +* `redirect_uri=https://facade/callback` — 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 = '. 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 -- cgit v1.2.1