Skip to content

Commit

Permalink
test: Add BrowserId support to Tokenserver load tests (#1219)
Browse files Browse the repository at this point in the history
Closes #1213
  • Loading branch information
ethowitz authored Apr 14, 2022
1 parent c4bca39 commit b6d87b7
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 263 deletions.
24 changes: 16 additions & 8 deletions tools/tokenserver/loadtests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,24 @@ This directory contains everything needed to run the suite of load tests for Tok
```sh
pip3 install -r requirements.txt
```
2. Set up a mock FxA service, with which Tokenserver will verify OAuth tokens. This directory includes a `mock-oauth-cfn.yaml` file, which contains the AWS CloudFormation template needed to deploy the mock service. A service with this template has been preconfigured at [https://mock-oauth-stage.dev.lcip.org](), but you can deploy your own with the following command:
2. Set up a mock OAuth verifier, with which Tokenserver will verify OAuth tokens. The subdirectory [mock-fxa-server/](./mock-fxa-server) includes code deployable as a GCP Cloud Function that acts as a mock FxA server, "verifying" OAuth tokens. You can deploy your own Cloud Function by running the following command in this directory:
```sh
aws cloudformation deploy \
--template-file=mock-oauth-cfn.yml \
--stack-name my-mock-oauth-stack \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
DomainName=my-mock-oauth.dev.lcip.org
gcloud functions deploy mock_fxa_server --runtime=python39 --trigger-http --source=mock-fxa-server
```
You can stand up a local copy of the Cloud Function by running the following in this directory:
```sh
functions-framework --target mock_fxa_server --debug
```
Note that you'll need to install `functions-framework` via `pip3 install functions-framework`. The load tests will use FxA stage to verify BrowserID assertions.
3. Configure Tokenserver to verify OAuth tokens through the mock FxA service and BrowserID assertions through FxA stage. This is done by setting the following environment variables:
```
SYNC_TOKENSERVER__FXA_BROWSERID_AUDIENCE=https://token.stage.mozaws.net
SYNC_TOKENSERVER__FXA_BROWSERID_ISSUER=api-accounts.stage.mozaws.net
SYNC_TOKENSERVER__BROWSERID_VERIFIER_URL=https://verifier.stage.mozaws.net/v2
# This variable should be set to point to the host and port of the moack OAuth verifier created in step 2
SYNC_TOKENSERVER__FXA_OAUTH_SERVER_URL=http://localhost:6000
```
3. Configure Tokenserver to verify tokens through the mock FxA service. This is done by setting the `tokenserver.fxa_oauth_server_url` setting or `SYNC_TOKENSERVER__FXA_OAUTH_SERVER_URL` environment variable to the URL of the desired mock service.
4. Tokenserver uses [locust](https://locust.io/) for load testing. To run the load tests, simply run the following command in this directory:
```sh
locust
Expand Down
143 changes: 119 additions & 24 deletions tools/tokenserver/loadtests/locustfile.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import json
from base64 import urlsafe_b64encode as b64encode
import binascii
import json
import time

import browserid
import browserid.jwt
from browserid.tests.support import make_assertion
from locust import HttpUser, task, between

DEFAULT_OAUTH_SCOPE = 'https://identity.mozilla.com/apps/oldsync'
# We use a custom mockmyid site to synthesize valid assertions.
# It's hosted in a static S3 bucket so we don't swamp the live mockmyid server.
MOCKMYID_DOMAIN = "mockmyid.s3-us-west-2.amazonaws.com"
MOCKMYID_PRIVATE_KEY = browserid.jwt.DS128Key({
"algorithm": "DS",
"x": "385cb3509f086e110c5e24bdd395a84b335a09ae",
"y": "738ec929b559b604a232a9b55a5295afc368063bb9c20fac4e53a74970a4db795"
"6d48e4c7ed523405f629b4cc83062f13029c4d615bbacb8b97f5e56f0c7ac9bc1"
"d4e23809889fa061425c984061fca1826040c399715ce7ed385c4dd0d40225691"
"2451e03452d3c961614eb458f188e3e8d2782916c43dbe2e571251ce38262",
"p": "ff600483db6abfc5b45eab78594b3533d550d9f1bf2a992a7a8daa6dc34f8045a"
"d4e6e0c429d334eeeaaefd7e23d4810be00e4cc1492cba325ba81ff2d5a5b305a"
"8d17eb3bf4a06a349d392e00d329744a5179380344e82a18c47933438f891e22a"
"eef812d69c8f75e326cb70ea000c3f776dfdbd604638c2ef717fc26d02e17",
"q": "e21e04f911d1ed7991008ecaab3bf775984309c3",
"g": "c52a4a0ff3b7e61fdf1867ce84138369a6154f4afa92966e3c827e25cfa6cf508b"
"90e5de419e1337e07a2e9e2a3cd5dea704d175f8ebf6af397d69e110b96afb17c7"
"a03259329e4829b0d03bbc7896b15b4ade53e130858cc34d96269aa89041f40913"
"6c7242a38895c9d5bccad4f389af1d7a4bd1398bd072dffa896233397a",
})
ONE_YEAR = 60 * 60 * 24 * 365
TOKENSERVER_PATH = '/1.0/sync/1.5'


Expand All @@ -19,50 +45,92 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Keep track of this user's generation number.
self.generation_counter = 0
self.x_key_id = self._make_x_key_id_header()
self.client_state = binascii.hexlify(
self.generation_counter.to_bytes(16, 'big')).decode('utf8')
# Locust spawns a new instance of this class for each user. Using the
# object ID as the FxA UID guarantees uniqueness.
self.fxa_uid = id(self)
self.email = "loadtest-%s@%s" % (self.fxa_uid, MOCKMYID_DOMAIN)

@task(1000)
def test_success(self):
@task(3000)
def test_oauth_success(self):
token = self._make_oauth_token(self.email)

self._do_token_exchange(token)
self._do_token_exchange_via_oauth(token)

@task(100)
def test_invalid_oauth(self):
token = self._make_oauth_token(status=400)

@task(5)
def test_invalid_scope(self):
self._do_token_exchange_via_oauth(token, status=401)

@task(100)
def test_invalid_oauth_scope(self):
token = self._make_oauth_token(
user=str(self.fxa_uid),
scope=["unrelated", "scopes"],
)

self._do_token_exchange(token, status=401)

@task(5)
def test_invalid_token(self):
token = self._make_oauth_token(status=400, errno=108)
self._do_token_exchange_via_oauth(token, status=401)

self._do_token_exchange(token, status=401)

@task(5)
@task(20)
def test_encryption_key_change(self):
# When a user's encryption keys change, the generation number and
# keys_changed_at for the user both increase.
self.generation_counter += 1
self.x_key_id = self._make_x_key_id_header()
self.client_state = binascii.hexlify(
self.generation_counter.to_bytes(16, 'big')).decode('utf8')
token = self._make_oauth_token(self.email)

self._do_token_exchange(token)
self._do_token_exchange_via_oauth(token)

@task(5)
@task(20)
def test_password_change(self):
# When a user's password changes, the generation number increases.
self.generation_counter += 1
token = self._make_oauth_token(self.email)

self._do_token_exchange(token)
self._do_token_exchange_via_oauth(token)

@task(100)
def test_browserid_success(self):
assertion = self._make_browserid_assertion(self.email)

self._do_token_exchange_via_browserid(assertion)

@task(3)
def test_expired_browserid_assertion(self):
assertion = self._make_browserid_assertion(
self.email,
exp=int(time.time() - ONE_YEAR) * 1000
)

self._do_token_exchange_via_browserid(assertion, status=401)

@task(3)
def test_browserid_email_issuer_mismatch(self):
email = "loadtest-%s@%s" % (self.fxa_uid, "hotmail.com")
assertion = self._make_browserid_assertion(email)

self._do_token_exchange_via_browserid(assertion, status=401)

@task(3)
def test_browserid_invalid_audience(self):
assertion = self._make_browserid_assertion(
self.email,
audience="http://123done.org"
)

self._do_token_exchange_via_browserid(assertion, status=401)

@task(3)
def test_browserid_invalid_issuer_priv_key(self):
assertion = self._make_browserid_assertion(
self.email,
issuer="api.accounts.firefox.com"
)

self._do_token_exchange_via_browserid(assertion, status=401)

def _make_oauth_token(self, user=None, status=200, **fields):
# For mock oauth tokens, we bundle the desired status code
Expand All @@ -83,7 +151,7 @@ def _make_oauth_token(self, user=None, status=200, **fields):
else:
body["user"] = parts[0]
body["issuer"] = parts[1]
body['generation'] = self.generation_counter
body['fxa-generation'] = self.generation_counter
body.update(fields)
return json.dumps({
"status": status,
Expand All @@ -95,15 +163,42 @@ def _make_x_key_id_header(self):
# the same, but for our purposes, making this assumption is sufficient:
# the accuracy of the load test is unaffected.
keys_changed_at = self.generation_counter
client_state = b64encode(
str(keys_changed_at).encode('utf8')).strip(b'=').decode('utf-8')
raw_client_state = binascii.unhexlify(self.client_state)
client_state = b64encode(raw_client_state).strip(b'=').decode('utf-8')

return '%s-%s' % (keys_changed_at, client_state)

def _do_token_exchange(self, token, status=200):
def _make_browserid_assertion(self, email, **kwds):
if "audience" not in kwds:
kwds["audience"] = self.client.base_url
if "exp" not in kwds:
kwds["exp"] = int((time.time() + ONE_YEAR) * 1000)
if "issuer" not in kwds:
kwds["issuer"] = MOCKMYID_DOMAIN
if "issuer_keypair" not in kwds:
kwds["issuer_keypair"] = (None, MOCKMYID_PRIVATE_KEY)
kwds["idp_claims"] = {
'fxa-generation': self.generation_counter,
'fxa-keysChangedAt': self.generation_counter,
}
return make_assertion(email, **kwds)

def _do_token_exchange_via_oauth(self, token, status=200):
headers = {
'Authorization': 'Bearer %s' % token,
'X-KeyID': self.x_key_id,
'X-KeyID': self._make_x_key_id_header(),
}

with self.client.get(TOKENSERVER_PATH,
catch_response=True,
headers=headers) as res:
if res.status_code == status:
res.success()

def _do_token_exchange_via_browserid(self, assertion, status=200):
headers = {
'Authorization': 'BrowserID %s' % assertion,
'X-Client-State': self.client_state
}

with self.client.get(TOKENSERVER_PATH,
Expand Down
20 changes: 20 additions & 0 deletions tools/tokenserver/loadtests/mock-fxa-server/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import functions_framework
import json

from flask import abort, Response


# GCP doesn't allow us to define multiple routes in a single Cloud Function,
# so we handle routing here.
@functions_framework.http
def mock_fxa_server(request):
if request.path == '/v1/verify':
body = json.loads(request.json['token'])
response = json.dumps(body['body'])

return Response(response=response, content_type='application/json',
status=body['status'])
elif request.path == '/v1/jwks':
return {'keys': [{'fake': 'RSA key'}]}
else:
abort(404)
Loading

0 comments on commit b6d87b7

Please sign in to comment.