From b6d87b7214a2d7cf54563aa0d357539c6e3b863b Mon Sep 17 00:00:00 2001 From: Ethan Donowitz <8703826+ethowitz@users.noreply.github.com> Date: Thu, 14 Apr 2022 12:58:33 -0400 Subject: [PATCH] test: Add BrowserId support to Tokenserver load tests (#1219) Closes #1213 --- tools/tokenserver/loadtests/README.md | 24 +- tools/tokenserver/loadtests/locustfile.py | 143 +++++++++-- .../loadtests/mock-fxa-server/main.py | 20 ++ .../tokenserver/loadtests/mock-oauth-cfn.yaml | 231 ------------------ tools/tokenserver/loadtests/requirements.txt | 1 + 5 files changed, 156 insertions(+), 263 deletions(-) create mode 100644 tools/tokenserver/loadtests/mock-fxa-server/main.py delete mode 100644 tools/tokenserver/loadtests/mock-oauth-cfn.yaml diff --git a/tools/tokenserver/loadtests/README.md b/tools/tokenserver/loadtests/README.md index f6de183bef..c15ce0ca65 100644 --- a/tools/tokenserver/loadtests/README.md +++ b/tools/tokenserver/loadtests/README.md @@ -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 diff --git a/tools/tokenserver/loadtests/locustfile.py b/tools/tokenserver/loadtests/locustfile.py index d1b81772aa..3d0b73ef2c 100644 --- a/tools/tokenserver/loadtests/locustfile.py +++ b/tools/tokenserver/loadtests/locustfile.py @@ -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' @@ -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 @@ -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, @@ -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, diff --git a/tools/tokenserver/loadtests/mock-fxa-server/main.py b/tools/tokenserver/loadtests/mock-fxa-server/main.py new file mode 100644 index 0000000000..38c90232cb --- /dev/null +++ b/tools/tokenserver/loadtests/mock-fxa-server/main.py @@ -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) diff --git a/tools/tokenserver/loadtests/mock-oauth-cfn.yaml b/tools/tokenserver/loadtests/mock-oauth-cfn.yaml deleted file mode 100644 index b880eaf9af..0000000000 --- a/tools/tokenserver/loadtests/mock-oauth-cfn.yaml +++ /dev/null @@ -1,231 +0,0 @@ -# -# This is a CloudFormation script to deploy a tiny lambda+API-gateway -# service that can mock out FxA OAuth verification responses. -# -# When deployed, this API will proxy all HTTP requests through to a live -# FxA OAuth server except that `POST /v1/verify` will attempt to parse -# the submitted token as JSON. If it succeeds, then it will use the `status` -# and `body` fields from that JSON to return a mocked response. -# -# The idea is to let this API be used as a stand-in for the real FxA OAuth -# server, and have it function correctly for manual testing with real accounts, -# but then also to be able to make fake OAuth tokens during a loadtest, like -# this: -# -# requests.get("https://mock-oauth-stage.dev.lcip.org", json={ -# "token": json.dumps({ -# "status": 200, -# "body": { -# "user": "loadtest123456", -# "scope": ["myscope"], -# "client_id": "my_client_id", -# } -# }) -# }) -# -# Or to be able to simulate OAuth token failures like this: -# -# requests.get("https://mock-oauth-stage.dev.lcip.org", json={ -# "token": json.dumps({ -# "status": 400, -# "body": { -# "errno": "108", -# "message": "invalid token", -# } -# }) -# }) -# -# You'll notice that there's some javascript written inline in this yaml file. -# That does make it a little bit annoying to edit, but that's outweighed by -# the advantage of have a single file that can be deployed with a single -# command with no pre-processing palaver. -# -Parameters: - ProxyTarget: - Type: "String" - Default: "oauth.stage.mozaws.net" - Description: "The live OAuth server to which un-mocked requests should be proxied" - MockIssuer: - Type: "String" - Default: "mockmyid.s3-us-west-2.amazonaws.com" - Description: "The issuer domain to use for mock tokens" - DomainName: - Type: "String" - Default: "mock-oauth-stage.dev.lcip.org" - Description: "The domain name at which to expose the API" - CertificateArn: - Type: "String" - Default: "arn:aws:acm:us-east-1:927034868273:certificate/675e0ac8-23af-4153-8295-acb28ccc9f0f" - Description: "The certificate to use with $DomainName" - HostedZoneName: - Type: "String" - Default: "lcip.org" - Description: "The hosted zone in which to create a DNS record" - Owner: - Type: "String" - Default: "rfkelly@mozilla.com" - Description: "Email address of owner to tag resources with" - -Resources: - Handler: - Type: "AWS::Lambda::Function" - Properties: - Description: "Mock FxA OAuth verifier" - Handler: "index.handler" - Role: !GetAtt HandlerRole.Arn - Tags: - - Key: "Owner" - Value: !Ref Owner - Runtime: "nodejs6.10" - Code: - ZipFile: !Sub |- - - const https = require('https'); - const url = require('url'); - - function proxy(event, context, callback) { - const output = [] - const req = https.request({ - hostname: "${ProxyTarget}", - post: 443, - path: url.format({ - pathname: event.path, - query: event.queryStringParameters - }), - method: event.httpMethod, - }, res => { - res.setEncoding('utf8'); - res.on('data', d => { - output.push(d); - }) - res.on('end', () => { - callback(null, { - statusCode: res.statusCode, - headers: res.headers, - body: output.join('') - }); - }) - }); - req.on('error', e => { - callback(e); - }) - if (event.body) { - req.write(event.body, 'utf8'); - } - req.end(); - } - - const HANDLERS = { - 'POST:/v1/verify': function(event, context, callback) { - try { - const token = JSON.parse(event.body).token; - const mockResponse = JSON.parse(token); - const mockStatus = mockResponse.status || 200; - const mockBody = mockResponse.body || {}; - // Ensure that successful responses always claim to be from - // the mock issuer. Otherwise you could use a mock token to - // any account, even accounts backed by accounts.firefox.com! - if (mockStatus < 400) { - mockBody.issuer = "${MockIssuer}"; - } - // Return the mocked response from the token. - return callback(null, { - statusCode: mockStatus, - headers: { - "content-type": "application/json" - }, - body: JSON.stringify(mockBody) - }); - } catch (e) { - // If it's not a mock token, forward to real server. - return proxy(event, context, callback); - } - } - } - - exports.handler = (event, context, callback) => { - const h = HANDLERS[event.httpMethod + ':' + event.path] || proxy; - return h(event, context, callback); - }; - - HandlerRole: - Type: "AWS::IAM::Role" - Properties: - AssumeRolePolicyDocument: - Version: "2012-10-17" - Statement: - - Effect: "Allow" - Principal: - Service: - - "lambda.amazonaws.com" - Action: - - "sts:AssumeRole" - ManagedPolicyArns: - - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" - - HandlerPermission: - Type: "AWS::Lambda::Permission" - Properties: - Action: "lambda:invokeFunction" - FunctionName: !GetAtt Handler.Arn - Principal: "apigateway.amazonaws.com" - SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${API}/*" - - API: - Type: "AWS::ApiGateway::RestApi" - Properties: - Description: "Mock FxA OAuth API" - Name: !Sub "${AWS::StackName}-mock-fxa-oauth" - FailOnWarnings: true - - APIResource: - Type: "AWS::ApiGateway::Resource" - Properties: - RestApiId: !Ref API - ParentId: !GetAtt API.RootResourceId - PathPart: "{proxy+}" - - APIMethod: - Type: "AWS::ApiGateway::Method" - DependsOn: - - HandlerPermission - Properties: - AuthorizationType: "NONE" - HttpMethod: "ANY" - ResourceId: !Ref APIResource - RestApiId: !Ref API - Integration: - Type: "AWS_PROXY" - IntegrationHttpMethod: "POST" - Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${Handler.Arn}/invocations" - - APIDeployment: - Type: "AWS::ApiGateway::Deployment" - DependsOn: - - APIMethod - Properties: - RestApiId: !Ref API - StageName: "main" - - APIDomainName: - Type: "AWS::ApiGateway::DomainName" - Properties: - DomainName: !Ref DomainName - CertificateArn: !Ref CertificateArn - - APIDomainMapping: - Type: "AWS::ApiGateway::BasePathMapping" - Properties: - DomainName: !Ref APIDomainName - RestApiId: !Ref API - Stage: "main" - - APIDNSRecord: - Type : "AWS::Route53::RecordSet" - Properties : - HostedZoneName : !Sub "${HostedZoneName}." - Name : !Sub "${DomainName}." - Type : "A" - AliasTarget: - DNSName: !GetAtt APIDomainName.DistributionDomainName - HostedZoneId: "Z2FDTNDATAQYW2" # Published ZoneId for CloudFront diff --git a/tools/tokenserver/loadtests/requirements.txt b/tools/tokenserver/loadtests/requirements.txt index 5e7567e1f1..b060e25f21 100644 --- a/tools/tokenserver/loadtests/requirements.txt +++ b/tools/tokenserver/loadtests/requirements.txt @@ -1,2 +1,3 @@ locust +pybrowserid sqlalchemy