diff options
author | Lars Wirzenius <liw@liw.fi> | 2018-01-30 13:13:48 +0200 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2018-01-30 13:13:48 +0200 |
commit | ece4826c3b5c9654f66f7a466aea67866208403b (patch) | |
tree | ead9f12998c2c66e85f1ffe89cdb22822663a8d3 /yarns | |
parent | f30b8d9f54996e399452973ec67679c36d26bb4b (diff) | |
download | qvisqve-ece4826c3b5c9654f66f7a466aea67866208403b.tar.gz |
Add: client creds scenario, rework implements a lot
Diffstat (limited to 'yarns')
-rw-r--r-- | yarns/100-version.yarn | 3 | ||||
-rw-r--r-- | yarns/200-client-creds.yarn | 92 | ||||
-rw-r--r-- | yarns/900-implements.yarn | 134 | ||||
-rw-r--r-- | yarns/900-local.yarn | 69 | ||||
-rw-r--r-- | yarns/900-remote.yarn | 11 | ||||
-rw-r--r-- | yarns/lib.py | 89 |
6 files changed, 292 insertions, 106 deletions
diff --git a/yarns/100-version.yarn b/yarns/100-version.yarn index 323b8a3..7786ee0 100644 --- a/yarns/100-version.yarn +++ b/yarns/100-version.yarn @@ -18,7 +18,8 @@ that all the request routing works, and so on. SCENARIO Salami reports its version - GIVEN a running salami instance + GIVEN a Salami configuration for "https://salami.example.com" + AND a running salami instance WHEN client requests GET /version without token THEN HTTP status code is 200 OK diff --git a/yarns/200-client-creds.yarn b/yarns/200-client-creds.yarn new file mode 100644 index 0000000..07bbf37 --- /dev/null +++ b/yarns/200-client-creds.yarn @@ -0,0 +1,92 @@ +OAuth2 client credentials grant +============================================================================= + +See [RFC8252][] for a description of the client credentials grant. + +[RFC8252]: https://tools.ietf.org/html/rfc8252 + +In the client credentials grant flow, the API client makes the +following request to the authentication server (Salami): + + EXAMPLE client credentials access token request + POST /token HTTP/1.1 + Authorization: Basic USERPASS + Content-Type: application/x-www-form-urlencoded + + grant_type=client_credentials&scope=foo+bar+foobar + +The `USERPASS` has the client id and secret encoded as is usual for +[HTTP Basic authentication][]. + +[HTTP Basic authentication]: https://en.wikipedia.org/wiki/Basic_access_authentication + +Salami checks the `grant_type` parameter, and extracts `USERPASS` to +get the client id and secret. It compares them against a static list +of clients, which it reads at startup from its configuration file: + + EXAMPLE Salami configuration file in YAML + config: + issuer: https://salami.example.com + lifetime: 3600 + signing_key: | + -----BEGIN RSA PRIVATE KEY----- + MIIJKAIBAAKCAgEAwWfX31I6DECIPv8FCffNKCF2BaxbPaSFQR+V+mEQxaxZmlmS + ... deleted from example + LkLFQC7Y66OYjna457hU545hfF99j7nxdseXQEhV96E4RUIub+6vS8TYDEk= + -----END RSA PRIVATE KEY----- + clients: + test_api: + client_secret: hunter2 + allowed_scopes: + - foo + - bar + +Salami checks that the client id given by the client is found, and +that the offered client secret matches what's in the configuration +file for the client id. It also takes the list of requested scopes, +and drops any requested scopes that are not in the list of allowed +scopes (in the example, it drops `foobar`). + +If all these checks pass, Salami will create a JWT with the following +claims: + + EXAMPLE sample access token claims + { + "iss": "https://salami.example.com", + "sub": "", + "aud": "test-api", + "exp": 123456, + "scope": "foo bar" + } + +Note that there is no end user involved in the client-credentials +flow, and so that `sub` field is always the empty string. The `iss` +field comes from the configuration, `aud` is the client id in the +request, `exp` is current time plus the lifetime specified in the +configuration. `scope` is from the request filtered by the allowed +scopes, as described above. + + SCENARIO get token using client credentials + + GIVEN an API client "bigco" + AND API client has secret "secrit" + AND API client has allowed scopes "read write" + + AND a Salami configuration for "https://salami.example.com" + AND Salami configuration has a token lifetime of 3600 + AND a running Salami instance + + WHEN client requests POST /token + ... with client_id "bigco", client_secret "secrit", and + ... scopes "read write delete" + + THEN HTTP status code is 200 OK + AND Content-Type is application/json + AND body is a correctly signed JWT token + AND token has claim iss as "https://salami.example.com" + AND token has claim sub as "" + AND token has claim aud as "bigco" + AND token has claim scope as "read write" + AND token expires in an hour + + FINALLY Salami is stopped diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn index c04b6a5..c121298 100644 --- a/yarns/900-implements.yarn +++ b/yarns/900-implements.yarn @@ -6,142 +6,176 @@ This chapter shows the scenario step implementations. IMPLEMENTS WHEN client requests GET (/.+) without token path = get_next_match() - path = expand_vars(path, vars) - vars['status_code'], vars['headers'], vars['body'] = get(vars['API_URL'] + path) + path = expand_vars(path, V) + V['status_code'], V['headers'], V['body'] = get(V['API_URL'] + path) IMPLEMENTS WHEN client requests GET (/.+) using token path = get_next_match() - path = expand_vars(path, vars) + path = expand_vars(path, V) headers = { - 'Authorization': 'Bearer {}'.format(vars['token']), + 'Authorization': 'Bearer {}'.format(V['token']), } - vars['status_code'], vars['headers'], vars['body'] = get( - vars['API_URL'] + path, headers) + V['status_code'], V['headers'], V['body'] = get( + V['API_URL'] + path, headers) IMPLEMENTS WHEN client requests POST (/.+) with token and body (.+) path = get_next_match() body = get_next_match() headers = { - 'Authorization': 'Bearer {}'.format(vars['token']), + 'Authorization': 'Bearer {}'.format(V['token']), 'Content-Type': 'application/json', } - vars['status_code'], vars['headers'], vars['body'] = post( - vars['API_URL'] + path, headers=headers, body=body) + V['status_code'], V['headers'], V['body'] = post( + V['API_URL'] + path, headers=headers, body=body) + + IMPLEMENTS WHEN client requests POST /token with client_id "(.+)", client_secret "(.+)", and scopes "(.+)" + client_id = get_next_match() + client_secret = get_next_match() + scopes = get_next_match().split() + V['status_code'], V['headers'], V['body'] = get_token( + client_id, client_secret, scopes) IMPLEMENTS WHEN client requests PUT (/.+) with token and body (.+) path = get_next_match() - path = expand_vars(path, vars) + path = expand_vars(path, V) body = get_next_match() - body = expand_vars(body, vars) + body = expand_vars(body, V) headers = { - 'Authorization': 'Bearer {}'.format(vars['token']), + 'Authorization': 'Bearer {}'.format(V['token']), 'Content-Type': 'application/json', } - vars['status_code'], vars['headers'], vars['body'] = put( - vars['API_URL'] + path, headers=headers, body=body) + V['status_code'], V['headers'], V['body'] = put( + V['API_URL'] + path, headers=headers, body=body) IMPLEMENTS WHEN client requests PUT (/[a-z0-9/${}]+) with token, revision (\S+), content-type (\S+), and empty body - path = expand_vars(get_next_match(), vars) - revision = expand_vars(get_next_match(), vars) - ctype = expand_vars(get_next_match(), vars) + path = expand_vars(get_next_match(), V) + revision = expand_vars(get_next_match(), V) + ctype = expand_vars(get_next_match(), V) body = '' headers = { - 'Authorization': 'Bearer {}'.format(vars['token']), + 'Authorization': 'Bearer {}'.format(V['token']), 'Revision': revision, 'Content-Type': ctype, } - vars['status_code'], vars['headers'], vars['body'] = put( - vars['API_URL'] + path, headers=headers, body=body) + V['status_code'], V['headers'], V['body'] = put( + V['API_URL'] + path, headers=headers, body=body) IMPLEMENTS WHEN client requests PUT (/[a-z0-9/${}]+) with token, revision (\S+), content-type (\S+), and body "(.+)" - path = expand_vars(get_next_match(), vars) - revision = expand_vars(get_next_match(), vars) - ctype = expand_vars(get_next_match(), vars) - body = unescape(expand_vars(get_next_match(), vars)) + path = expand_vars(get_next_match(), V) + revision = expand_vars(get_next_match(), V) + ctype = expand_vars(get_next_match(), V) + body = unescape(expand_vars(get_next_match(), V)) headers = { - 'Authorization': 'Bearer {}'.format(vars['token']), + 'Authorization': 'Bearer {}'.format(V['token']), 'Revision': revision, 'Content-Type': ctype, } - vars['status_code'], vars['headers'], vars['body'] = put( - vars['API_URL'] + path, headers=headers, body=body) + V['status_code'], V['headers'], V['body'] = put( + V['API_URL'] + path, headers=headers, body=body) IMPLEMENTS WHEN client requests DELETE (/.+) with token path = get_next_match() - path = expand_vars(path, vars) + path = expand_vars(path, V) headers = { - 'Authorization': 'Bearer {}'.format(vars['token']), + 'Authorization': 'Bearer {}'.format(V['token']), } - vars['status_code'], vars['headers'], vars['body'] = delete( - vars['API_URL'] + path, headers=headers) + V['status_code'], V['headers'], V['body'] = delete( + V['API_URL'] + path, headers=headers) ## API access token creation IMPLEMENTS WHEN client gets an authorization token with scope "(.+)" scopes = get_next_match() - print 'privkey', repr(vars['privkey']) - assert vars['privkey'] - vars['token'] = create_token(vars['privkey'], vars['iss'], vars['aud'], scopes) + print 'privkey', repr(V['privkey']) + assert V['privkey'] + V['token'] = create_token(V['privkey'], V['iss'], V['aud'], scopes) ## UUID creation IMPLEMENTS GIVEN unique random identifier (\S+) import uuid name = get_next_match() - vars[name] = str(uuid.uuid4()) + V[name] = str(uuid.uuid4()) ## API request result checking IMPLEMENTS THEN HTTP status code is (\d+) (.*) expected = int(get_next_match()) - assertEqual(vars['status_code'], expected) + assertEqual(V['status_code'], expected) IMPLEMENTS THEN HTTP (\S+) header is (.+) header = get_next_match() - value = expand_vars(get_next_match(), vars) - assertEqual(vars['headers'].get(header), value) + value = expand_vars(get_next_match(), V) + assertEqual(V['headers'].get(header), value) IMPLEMENTS THEN remember HTTP (\S+) header as (.+) header = get_next_match() name = get_next_match() - vars[name] = vars['headers'].get(header) + V[name] = V['headers'].get(header) IMPLEMENTS THEN resource id is (\S+) import json name = get_next_match() - print 'body:', repr(vars['body']) - body = json.loads(vars['body']) - vars[name] = body['id'] + print 'body:', repr(V['body']) + body = json.loads(V['body']) + V[name] = body['id'] IMPLEMENTS THEN revision is (\S+) import json name = get_next_match() - body = json.loads(vars['body']) - vars[name] = body['revision'] + body = json.loads(V['body']) + V[name] = body['revision'] IMPLEMENTS THEN revisions (\S+) and (\S+) are different rev1 = get_next_match() rev2 = get_next_match() - assertNotEqual(vars[rev1], vars[rev2]) + assertNotEqual(V[rev1], V[rev2]) IMPLEMENTS THEN revisions (\S+) and (\S+) match rev1 = get_next_match() rev2 = get_next_match() - assertEqual(vars[rev1], vars[rev2]) + assertEqual(V[rev1], V[rev2]) IMPLEMENTS THEN JSON body matches (.+) import json wanted = get_next_match() print 'wanted1', repr(wanted) - wanted = expand_vars(wanted, vars) + wanted = expand_vars(wanted, V) print 'wanted2', repr(wanted) wanted = json.loads(wanted) - actual = json.loads(vars['body']) + actual = json.loads(V['body']) print 'actual ', repr(actual) print 'wanted3', repr(wanted) assertTrue(values_match(wanted, actual)) IMPLEMENTS THEN body is "(.+)" - wanted = unescape(expand_vars(get_next_match(), vars)) - body = vars['body'] + wanted = unescape(expand_vars(get_next_match(), V)) + body = V['body'] assertTrue(values_match(wanted, body)) + + IMPLEMENTS THEN Content-Type is (\S+) + wanted = get_next_match() + headers = V['headers'] + assertEqual(headers['Content-Type'], wanted) + + IMPLEMENTS THEN body is a correctly signed JWT token + resp = json.loads(V['body']) + assertIn('access_token', resp) + assertIn('token_type', resp) + assertEqual(resp['token_type'], 'bearer') + token = resp['access_token'] + claims = token_decode(token, V['pubkey']) + assertNotEqual(claims, None) + V['claims'] = claims + + IMPLEMENTS THEN token has claim (\S+) as "(.*)" + claim = get_next_match() + value = get_next_match() + claims = V['claims'] + assertEqual(claims[claim], value) + + IMPLEMENTS THEN token expires in an hour + claims = V['claims'] + expires = claims['exp'] + remains = expires - time.time() + assertTrue(3400 < remains < 3700) diff --git a/yarns/900-local.yarn b/yarns/900-local.yarn index e1d62a1..64ed4c0 100644 --- a/yarns/900-local.yarn +++ b/yarns/900-local.yarn @@ -19,6 +19,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. # Scenario step implementations for locally managed Salami +## Configure Salami and its API client + + IMPLEMENTS GIVEN an API client "(\S+)" + V['client_id'] = get_next_match() + + IMPLEMENTS GIVEN API client has secret "(.+)" + V['client_secret'] = get_next_match() + + IMPLEMENTS GIVEN API client has allowed scopes "(.+)" + scopes = get_next_match() + V['allowed_scopes'] = scopes.split() + + IMPLEMENTS GIVEN a Salami configuration for "(.+)" + V['iss'] = get_next_match() + + IMPLEMENTS GIVEN Salami configuration has a token lifetime of (\d+) + V['lifetime'] = int(get_next_match()) + + ## Authentication setup IMPLEMENTS GIVEN an RSA key pair for token signing @@ -38,57 +57,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. ] token = cliapp.runcmd(argv, feed_stdin=key) store_token(user, token) - vars['issuer'] = 'localhost' - vars['audience'] = 'localhost' ## Start Salami IMPLEMENTS GIVEN a running salami instance - import os, time, cliapp, yaml, yarnutils - privkey, pubkey = create_token_signing_key_pair() - open('key', 'w').write(privkey) - vars['aud'] = 'http://api.test.example.com' - vars['iss'] = 'salami.yarn' - vars['privkey'] = privkey - vars['pubkey'] = pubkey - vars['api.log'] = 'salami.log' - vars['gunicorn3.log'] = 'gunicorn3.log' - vars['pid-file'] = 'salami.pid' - vars['port'] = cliapp.runcmd([os.path.join(srcdir, 'randport' )]).strip() - vars['API_URL'] = 'http://127.0.0.1:{}'.format(vars['port']) - config = { - 'log': [ - { - 'filename': vars['api.log'], - }, - ], - 'token-public-key': vars['pubkey'], - 'token-issuer': vars['iss'], - 'token-audience': vars['aud'], - } - config = add_postgres_config(config) - env = dict(os.environ) - env['SALAMI_CONFIG'] = os.path.join(datadir, 'salami.yaml') - yaml.safe_dump(config, open(env['SALAMI_CONFIG'], 'w')) - argv = [ - os.path.join(srcdir, 'start_salami'), - 'debug', - 'token.jwt', - vars['pid-file'], - str(vars['port']), - '--daemon', - ] - cliapp.runcmd(argv, env=env, stdout=None, stderr=None) - until = time.time() + 2.0 - while time.time() < until and not os.path.exists(vars['pid-file']): - time.sleep(0.01) - assert os.path.exists(vars['pid-file']) + start_salami() + print(repr(start_salami)) + assert V['API_URL'] is not None ## Stop a Salami we started IMPLEMENTS FINALLY salami is stopped - import os, signal, yarnutils - filename = vars['pid-file'] - if os.path.exists(filename): - pid = int(cat(filename)) - os.kill(pid, signal.SIGTERM) + stop_salami() diff --git a/yarns/900-remote.yarn b/yarns/900-remote.yarn index bf226ee..e4d8852 100644 --- a/yarns/900-remote.yarn +++ b/yarns/900-remote.yarn @@ -22,26 +22,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. ## Authentication setup IMPLEMENTS GIVEN an RSA key pair for token signing - vars['private_key_file'] = os.environ['ICK_PRIVATE_KEY'] - assertTrue(os.path.exists(vars['private_key_file'])) + V['private_key_file'] = os.environ['ICK_PRIVATE_KEY'] + assertTrue(os.path.exists(V['private_key_file'])) IMPLEMENTS GIVEN an access token for (\S+) with scopes (.+) user = get_next_match() scopes = get_next_match() - key = open(vars['private_key_file']).read() + key = open(V['private_key_file']).read() argv = [ os.path.join(srcdir, 'create-token'), scopes, ] token = cliapp.runcmd(argv, feed_stdin=key) store_token(user, token) - vars['issuer'] = 'localhost' - vars['audience'] = 'localhost' ## Start and stop Salami IMPLEMENTS GIVEN a running salami instance - vars['API_URL'] = os.environ['API_URL'] + V['API_URL'] = os.environ['API_URL'] + assert 0 IMPLEMENTS FINALLY salami is stopped pass diff --git a/yarns/lib.py b/yarns/lib.py index 0c60203..b2e197b 100644 --- a/yarns/lib.py +++ b/yarns/lib.py @@ -17,11 +17,15 @@ import json import os import re +import signal +import sys import tempfile +import time import cliapp import Crypto.PublicKey.RSA +import jwt import requests import yaml @@ -33,7 +37,7 @@ srcdir = os.environ['SRCDIR'] datadir = os.environ['DATADIR'] -vars = Variables(datadir) +V = Variables(datadir) def hexdigit(c): @@ -113,7 +117,7 @@ def write_temp(data): return filename -def expand_vars(text, vars): +def expand_vars(text, variables): result = '' while text: m = re.search(r'\${(?P<name>[^}]+)}', text) @@ -122,7 +126,7 @@ def expand_vars(text, vars): break name = m.group('name') print('expanding ', name) - result += text[:m.start()] + vars[name] + result += text[:m.start()] + variables[name] text = text[m.end():] return result @@ -155,3 +159,82 @@ def values_match(wanted, actual): return False return True + + +def start_salami(): + privkey, pubkey = create_token_signing_key_pair() + open('key', 'w').write(privkey) + V['aud'] = 'http://api.test.example.com' + V['privkey'] = privkey + V['pubkey'] = pubkey + V['api.log'] = 'salami.log' + V['gunicorn3.log'] = 'gunicorn3.log' + V['pid-file'] = 'salami.pid' + V['port'] = cliapp.runcmd([os.path.join(srcdir, 'randport' )]).strip() + V['API_URL'] = 'http://127.0.0.1:{}'.format(V['port']) + config = { + 'log': [ + { + 'filename': V['api.log'], + }, + ], + 'token-private-key': V['privkey'], + 'token-public-key': V['pubkey'], + 'token-issuer': V['iss'], + 'token-audience': V['aud'], + 'token-lifetime': 3600, + 'clients': { + V['client_id']: { + 'client_secret': V['client_secret'], + 'allowed_scopes': V['allowed_scopes'], + }, + }, + } + env = dict(os.environ) + env['SALAMI_CONFIG'] = os.path.join(datadir, 'salami.yaml') + yaml.safe_dump(config, open(env['SALAMI_CONFIG'], 'w')) + argv = [ + os.path.join(srcdir, 'start_salami'), + 'debug', + 'token.jwt', + V['pid-file'], + str(V['port']), + '--daemon', + ] + cliapp.runcmd(argv, env=env, stdout=None, stderr=None) + until = time.time() + 2.0 + while time.time() < until and not os.path.exists(V['pid-file']): + time.sleep(0.01) + assert os.path.exists(V['pid-file']) + + +def stop_salami(): + filename = V['pid-file'] + if os.path.exists(filename): + pid = int(cat(filename)) + os.kill(pid, signal.SIGTERM) + + +def get_token(client_id, client_secret, scopes): + url = '{}/token'.format(V['API_URL']) + auth = (client_id, client_secret) + data = { + 'grant_type': 'client_credentials', + 'scope': ' '.join(scopes), + } + + r = requests.post(url, auth=auth, data=data, verify=False) + return r.status_code, dict(r.headers), r.text + + +def token_decode(token, pubkey): + key = Crypto.PublicKey.RSA.importKey(pubkey) + audience = V['aud'] + print('audience', repr(audience)) + try: + return jwt.decode( + token, key=key.exportKey('OpenSSH'), audience=audience, + options={'verify_aud': False}) + except jwt.exceptions.InvalidTokenError as e: + print('invalid token error', str(e)) + return None |