summaryrefslogtreecommitdiff
path: root/yarns
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-01-30 13:13:48 +0200
committerLars Wirzenius <liw@liw.fi>2018-01-30 13:13:48 +0200
commitece4826c3b5c9654f66f7a466aea67866208403b (patch)
treeead9f12998c2c66e85f1ffe89cdb22822663a8d3 /yarns
parentf30b8d9f54996e399452973ec67679c36d26bb4b (diff)
downloadqvisqve-ece4826c3b5c9654f66f7a466aea67866208403b.tar.gz
Add: client creds scenario, rework implements a lot
Diffstat (limited to 'yarns')
-rw-r--r--yarns/100-version.yarn3
-rw-r--r--yarns/200-client-creds.yarn92
-rw-r--r--yarns/900-implements.yarn134
-rw-r--r--yarns/900-local.yarn69
-rw-r--r--yarns/900-remote.yarn11
-rw-r--r--yarns/lib.py89
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