diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/_utils.py | 421 | ||||
-rw-r--r-- | tests/api.py | 252 | ||||
-rw-r--r-- | tests/conftest.py | 115 | ||||
-rw-r--r-- | tests/integration.rs | 73 | ||||
-rw-r--r-- | tests/smtp.py | 27 | ||||
-rw-r--r-- | tests/test_auth_account.py | 348 | ||||
-rw-r--r-- | tests/test_auth_device.py | 434 | ||||
-rw-r--r-- | tests/test_auth_email.py | 96 | ||||
-rw-r--r-- | tests/test_auth_oauth.py | 369 | ||||
-rw-r--r-- | tests/test_auth_password.py | 211 | ||||
-rw-r--r-- | tests/test_auth_session.py | 69 | ||||
-rw-r--r-- | tests/test_oauth.py | 97 | ||||
-rw-r--r-- | tests/test_profile.py | 134 | ||||
-rw-r--r-- | tests/test_push.py | 147 |
14 files changed, 2793 insertions, 0 deletions
diff --git a/tests/_utils.py b/tests/_utils.py new file mode 100644 index 0000000..6ccd99c --- /dev/null +++ b/tests/_utils.py @@ -0,0 +1,421 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +""" + +fxa._utils: miscellaneous low-level utilities for PyFxA + +This private-api stuff that will most likely change, move, refactor +etc as we go. So don't import any of it outside of this package. + +""" +from __future__ import absolute_import +import os +import time +import hashlib +import hmac +import six +from binascii import hexlify, unhexlify +from base64 import b64encode +try: + import cPickle as pickle +except ImportError: # pragma: no cover + import pickle + +from six import PY3 +from six.moves.urllib.parse import urlparse, urljoin + +import requests +import requests.auth +import requests.utils +import hawkauthlib + +import fxa +import fxa.errors +import fxa.crypto + + +# Send a custom user-agent header +# so we're easy to identify in server logs etc. + +USER_AGENT_HEADER = ' '.join(( + 'Mozilla/5.0 (Mobile; Firefox Accounts; rv:1.0)', + 'PyFxA/%s' % (fxa.__version__), + requests.utils.default_user_agent(), +)) + + +if not PY3: + hexstr = hexlify +else: # pragma: no cover + def hexstr(data): + """Like binascii.hexlify, but always returns a str instance.""" + return hexlify(data).decode("ascii") + + +def uniq(size=10): + """Generate a short random hex string.""" + return hexstr(os.urandom(size // 2 + 1))[:size] + + +def get_hmac(data, secret, algorithm=hashlib.sha256): + """Generate an hexdigest hmac for given data, secret and algorithm.""" + return hmac.new(secret.encode('utf-8'), + data.encode('utf-8'), + algorithm).hexdigest() + + +def scope_matches(provided, required): + """Check that required scopes match the ones provided. This is used during + token verification to raise errors if expected scopes are not met. + + :note: + + The rules for parsing and matching scopes in FxA are documented at + https://github.com/mozilla/fxa-oauth-server/blob/master/docs/scopes.md + + :param provided: list of scopes provided for the current token. + :param required: the scope required (e.g. by the application). + :returns: ``True`` if all required scopes are provided, ``False`` if not. + """ + if isinstance(provided, six.string_types): + raise ValueError("Provided scopes must be a list, not a single string") + + if not isinstance(required, (list, tuple)): + required = [required] + + for req in required: + if not any(_match_single_scope(prov, req) for prov in provided): + return False + + return True + + +def _match_single_scope(provided, required): + if provided.startswith('https:'): + return _match_url_scope(provided, required) + else: + return _match_shortname_scope(provided, required) + + +def _match_shortname_scope(provided, required): + if required.startswith('https:'): + return False + prov_names = provided.split(':') + req_names = required.split(':') + # If we require :write, it must be provided. + if req_names[-1] == 'write': + if prov_names[-1] != 'write': + return False + req_names.pop() + prov_names.pop() + elif prov_names[-1] == 'write': + prov_names.pop() + # Provided names must be a prefix of required names. + if len(prov_names) > len(req_names): + return False + for (p, r) in zip(prov_names, req_names): + if p != r: + return False + # It matches! + return True + + +def _match_url_scope(provided, required): + if not required.startswith('https:'): + return False + # Pop the hash fragments + (prov_url, prov_hash) = (provided.rsplit('#', 1) + [None])[:2] + (req_url, req_hash) = (required.rsplit('#', 1) + [None])[:2] + # Provided URL must be a prefix of required. + if req_url != prov_url: + if not (req_url.startswith(prov_url + '/')): + return False + # If hash is provided, it must match that required. + if prov_hash: + if not req_hash or req_hash != prov_hash: + return False + # It matches! + return True + + +class APIClient(object): + """A requests.Session wrapper specialized for FxA API access. + + An instance of this class should be used for making requests to an FxA + web API endpoint. It wraps a requests.Session instance and provides + a broadly similar interface, with some additional functionality that's + specific to Firefox Accounts: + + * default base server URL + * backoff protocol support + * sensible request timeouts + * timestamp skew tracking with automatic retry on clockskew error + + """ + + def __init__(self, server_url, session=None): + if session is None: + session = requests.Session() + # Properties that can be customized to change behaviour. + self.server_url = server_url + self.timeout = 30 + self.max_retry_after = None + # Internal state. + self._session = session + self._backoff_until = 0 + self._backoff_response = None + self._clockskew = None + + # Reflect useful properties of the wrapped Session object. + + @property + def headers(self): + return self._session.headers + + @headers.setter + def headers(self, value): + self._session.headers = value + + @property + def auth(self): + return self._session.auth + + @auth.setter + def auth(self, value): + if getattr(value, "apiclient", None) is None: + value.apiclient = self + self._session.auth = value + + @property + def hooks(self): + return self._session.hooks + + @hooks.setter + def hooks(self, value): + self._session.hooks = value + + @property + def verify(self): + return self._session.verify + + @verify.setter + def verify(self, value): + self._session.verify = value + + # Add some handy utility methods of our own. + + def client_curtime(self): + """Get the current timestamp, as seen by the client. + + This is a helper function that returns the current local time. + It's mostly here for symmetry with server_curtime() and to assist + in testability of this class. + """ + return time.time() + + def server_curtime(self): + """Get the current timestamp, as seen by the server. + + This is a helper function that automatically applies any detected + clock-skew, to report what the current timestamp is on the server + instead of on the client. + """ + return self.client_curtime() + (self._clockskew or 0) + + # The actual request-making stuff. + + def request(self, method, url, json=None, retry_auth_errors=True, **kwds): + """Make a request to the API and process the response. + + This method implements the low-level details of interacting with an + FxA Web API, stripping away most of the details of HTTP. It will + return the parsed JSON of a successful responses, or raise an exception + for an error response. It's also responsible for backoff handling + and clock-skew tracking. + """ + # Don't make requests if we're in backoff. + # Instead just synthesize a backoff response. + if self._backoff_response is not None: + if self._backoff_until >= self.client_curtime(): + resp = pickle.loads(self._backoff_response) + resp.request = None + resp.headers["Timestamp"] = str(int(self.server_curtime())) + return resp + else: + self._backoff_until = 0 + self._backoff_response = None + + # Apply defaults and perform the request. + while url.startswith("/"): + url = url[1:] + if self.server_url.endswith("/"): + url = urljoin(self.server_url, url) + else: + url = urljoin(self.server_url + "/", url) + if self.timeout is not None: + kwds.setdefault("timeout", self.timeout) + + # Configure the user agent + headers = kwds.get('headers', {}) + headers.setdefault('User-Agent', USER_AGENT_HEADER) + kwds['headers'] = headers + + resp = self._session.request(method, url, json=json, **kwds) + + # Everything should return a valid JSON response. Even errors. + content_type = resp.headers.get("content-type", "") + if not content_type.startswith("application/json"): + msg = "API responded with non-json content-type: {0}" + raise fxa.errors.OutOfProtocolError(msg.format(content_type)) + try: + body = resp.json() + except ValueError as e: + msg = "API responded with invalid json: {0}" + raise fxa.errors.OutOfProtocolError(msg.format(e)) + + # Check for backoff indicator from the server. + # If found, backoff up to the client-specified max time. + if resp.status_code in (429, 500, 503): + try: + retry_after = int(resp.headers["retry-after"]) + except (KeyError, ValueError): + pass + else: + if self.max_retry_after is not None: + retry_after = max(retry_after, self.max_retry_after) + self._backoff_until = self.client_curtime() + retry_after + self._backoff_response = pickle.dumps(resp) + + # If we get a 401 with "serverTime" field in the body, then we're + # probably out of sync with the server's clock. Check our skew, + # adjust if necessary and try again. + if retry_auth_errors: + if resp.status_code == 401 and "serverTime" in body: + try: + server_timestamp = int(body["serverTime"]) + except ValueError: + msg = "API responded with non-integer serverTime: {0}" + msg = msg.format(body["serverTime"]) + raise fxa.errors.OutOfProtocolError(msg) + # If our guestimate is more than 30 seconds out, try again. + # This assumes the auth hook will use the updated clockskew. + if abs(server_timestamp - self.server_curtime()) > 30: + self._clockskew = server_timestamp - self.client_curtime() + return self.request(method, url, json, False, **kwds) + + # See if we need to adjust for clock skew between client and server. + # We do this automatically once per session in the hopes of avoiding + # having to retry subsequent auth failures. We do it *after* the retry + # checking above, because it wrecks the "were we out of sync?" check. + if self._clockskew is None and "timestamp" in resp.headers: + try: + server_timestamp = int(resp.headers["timestamp"]) + except ValueError: + msg = "API responded with non-integer timestamp: {0}" + msg = msg.format(resp.headers["timestamp"]) + raise fxa.errors.OutOfProtocolError(msg) + else: + self._clockskew = server_timestamp - self.client_curtime() + + # Raise exceptions for any error responses. + # XXX TODO: hooks for raising error subclass based on errno. + if 400 <= resp.status_code < 500: + raise fxa.errors.ClientError(body) + if 500 <= resp.status_code < 600: + raise fxa.errors.ServerError(body) + if resp.status_code < 200 or resp.status_code >= 300: + msg = "API responded with unexpected status code: {0}" + raise fxa.errors.OutOfProtocolError(msg.format(resp.status_code)) + + # Return the parsed JSON body for successful responses. + return body + + def get(self, url, **kwds): + return self.request("GET", url, **kwds) + + def post(self, url, json=None, **kwds): + return self.request("POST", url, json, **kwds) + + def put(self, url, json=None, **kwds): + return self.request("PUT", url, json, **kwds) + + def delete(self, url, **kwds): + return self.request("DELETE", url, **kwds) + + +class HawkTokenAuth(requests.auth.AuthBase): + """A requests auth hook implementing token-based hawk auth. + + This auth hook implements the hkdf-derived-hawk-token auth scheme + as used by the Firefox Accounts auth server. It uses HKDF to derive + an id and secret key from a random 32-byte token, then signs the request + with those credentials using the Hawk request-signing scheme. + """ + + def __init__(self, token, tokentype, apiclient=None): + tokendata = unhexlify(token) + key_material = fxa.crypto.derive_key(tokendata, tokentype, 3*32) + self.id = hexstr(key_material[:32]) + self.auth_key = key_material[32:64] + self.bundle_key = key_material[64:] + self.apiclient = apiclient + + def __call__(self, req): + # Requests doesn't include the port in the Host header by default. + # Ensure a fully-correct value so that signatures work properly. + req.headers["Host"] = urlparse(req.url).netloc + params = {} + if req.body: + body = _encoded(req.body, 'utf-8') + hasher = hashlib.sha256() + hasher.update(b"hawk.1.payload\napplication/json\n") + hasher.update(body) + hasher.update(b"\n") + hash = b64encode(hasher.digest()) + if PY3: + hash = hash.decode("ascii") + params["hash"] = hash + if self.apiclient is not None: + params["ts"] = str(int(self.apiclient.server_curtime())) + hawkauthlib.sign_request(req, self.id, self.auth_key, params=params) + return req + + def bundle(self, namespace, payload): + """Bundle encrypted response data.""" + return fxa.crypto.bundle(self.bundle_key, namespace, payload) + + def unbundle(self, namespace, payload): + """Unbundle encrypted response data.""" + return fxa.crypto.unbundle(self.bundle_key, namespace, payload) + + +class BearerTokenAuth(requests.auth.AuthBase): + """A requests auth hook implementing OAuth bearer-token-based auth. + + This auth hook implements the simple "bearer token" auth scheme. + The provided token is passed directly in the Authorization header. + """ + + def __init__(self, token, apiclient=None): + self.token = token + + def __call__(self, req): + req.headers["Authorization"] = "Bearer {0}".format(self.token) + return req + + +def _decoded(value, encoding='utf-8'): + """Make sure the value is of type ``unicode`` in both PY2 and PY3.""" + value_type = type(value) + if value_type != six.text_type: + value = value.decode(encoding) + return value + + +def _encoded(value, encoding='utf-8'): + """Make sure the value is of type ``bytes`` in both PY2 and PY3.""" + value_type = type(value) + if value_type != six.binary_type: + return value.encode(encoding) + return value diff --git a/tests/api.py b/tests/api.py new file mode 100644 index 0000000..9d2f70d --- /dev/null +++ b/tests/api.py @@ -0,0 +1,252 @@ +import asyncio +import base64 +import binascii +import http.server +import http_ece +import json +import os +import queue +import quopri +import threading +from _utils import HawkTokenAuth, APIClient, hexstr +from aiosmtpd.controller import Controller +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from fxa.crypto import quick_stretch_password, derive_key, xor + +AUTH_URL = "http://localhost:8000/auth" +PROFILE_URL = "http://localhost:8000/profile" +OAUTH_URL = "http://localhost:8000/oauth" +INVITE_URL = "http://localhost:8000/_invite" +PUSH_PORT = 10264 +SMTP_PORT = 2525 + +def auth_pw(email, pw): + return derive_key(quick_stretch_password(email, pw), "authPW").hex() + +class AuthClient: + def __init__(self, /, email=None, session=None, bearer=None, props=None): + self.password = "" + self.client = APIClient(f"{AUTH_URL}/v1") + self.email = email + assert int(session is not None) + int(bearer is not None) < 2 + self.session = session + self.auth = HawkTokenAuth(session, "sessionToken", self.client) if session else None + self.bearer = bearer + self.headers = { 'authorization': f'bearer {bearer}' } if bearer else {} + self.props = props + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + kwds.setdefault('headers', {}) + kwds['headers'] |= self.headers + return self.client.post(url, json, auth=self.auth, **kwds) + + def get(self, url, **kwds): + return self.client.get(url, **kwds) + def get_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers'] |= self.headers + return self.client.get(url, auth=self.auth, **kwds) + + def delete(self, url, **kwds): + return self.client.delete(url, **kwds) + def delete_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers'] |= self.headers + return self.client.delete(url, auth=self.auth, **kwds) + + def create_account(self, email, pw, keys=None, invite=None, **kwds): + body = { + "email": email, + "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")), + "style": invite, + } + params = { 'keys': str(keys).lower() } if keys is not None else {} + resp = self.client.post("/account/create", body, params=params, **kwds) + return AuthClient(email=email, session=resp['sessionToken'], props=resp) + def destroy_account(self, email, pw, **kwds): + body = { "email": email, "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")) } + return self.client.post("/account/destroy", body) + + def fetch_keys(self, key_fetch_token, pw): + pw = quick_stretch_password(self.email, pw) + auth = HawkTokenAuth(key_fetch_token, "keyFetchToken", self.client) + resp = self.client.get("/account/keys", auth=auth) + bundle = binascii.unhexlify(resp["bundle"]) + keys = auth.unbundle("account/keys", bundle) + unwrap_key = derive_key(pw, "unwrapBkey") + return (keys[:32], xor(keys[32:], unwrap_key)) + + def login(self, email, pw, keys=None, **kwds): + body = { "email": email, "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")) } + params = { "keys": str(keys).lower() } if keys is not None else {} + resp = self.client.post("/account/login", body, params=params) + return AuthClient(email=email, session=resp['sessionToken'], props=resp) + def destroy_session(self, **kwds): + return self.post_a("/session/destroy", kwds) + + def profile(self): + token = self.post_a("/oauth/token", { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile:write", + }) + return Profile(token['access_token']) + +class Invite: + def __init__(self, token): + self.client = APIClient(f"{INVITE_URL}/v1") + self.auth = HawkTokenAuth(token, "sessionToken", self.client) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + return self.client.post(url, json, auth=self.auth, **kwds) + +class PasswordChange: + def __init__(self, client, token, hkdf='passwordChangeToken'): + self.client = client + self.auth = HawkTokenAuth(token, hkdf, self.client) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + return self.client.post(url, json, auth=self.auth, **kwds) + +class AccountReset: + def __init__(self, client, token): + self.client = client + self.auth = HawkTokenAuth(token, 'accountResetToken', self.client) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + return self.client.post(url, json, auth=self.auth, **kwds) + +class Profile: + def __init__(self, token): + self.client = APIClient(f"{PROFILE_URL}/v1") + self.token = token + + def get(self, url, **kwds): + return self.client.get(url, **kwds) + def get_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers']['authorization'] = f'bearer {self.token}' + return self.client.get(url, **kwds) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + kwds.setdefault('headers', {}) + kwds['headers']['authorization'] = f'bearer {self.token}' + return self.client.post(url, json, **kwds) + + def delete(self, url, **kwds): + return self.client.delete(url, **kwds) + def delete_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers']['authorization'] = f'bearer {self.token}' + return self.client.delete(url, **kwds) + +class Oauth: + def __init__(self): + self.client = APIClient(f"{OAUTH_URL}/v1") + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + +class Device: + def __init__(self, auth, name, type="desktop", commands={}, pcb=None): + self.auth = auth + dev = auth.post_a("/account/device", { + "name": name, + "type": type, + "availableCommands": commands, + } | self._mk_push(pcb)) + self.id = dev['id'] + self.props = dev + + def _mk_push(self, pcb): + if not pcb: + return {} + + self.priv = ec.generate_private_key(ec.SECP256R1, default_backend()) + self.public = self.priv.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint) + self.authkey = os.urandom(16) + return { + "pushCallback": pcb, + "pushPublicKey": base64.urlsafe_b64encode(self.public).decode('utf8'), + "pushAuthKey": base64.urlsafe_b64encode(self.authkey).decode('utf8'), + } + + def update_pcb(self, pcb): + self.props = self.auth.post_a("/account/device", { "id": self.id } | self._mk_push(pcb)) + + def decrypt(self, data): + raw = http_ece.decrypt(data, private_key=self.priv, auth_secret=self.authkey) + return json.loads(raw.decode('utf8')) + +class PushServer: + def __init__(self): + q = self.q = queue.Queue() + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + if self.path.startswith("/err/"): + self.send_response(410) + self.end_headers() + else: + self.send_response(200) + self.end_headers() + + q.put((self.path, self.headers, self.rfile.read(int(self.headers['content-length'])))) + + server = self.server = http.server.ThreadingHTTPServer(("localhost", PUSH_PORT), Handler) + threading.Thread(target=server.serve_forever).start() + + def wait(self, timeout=2): + return self.q.get(timeout=timeout) + def done(self, timeout=2): + try: + self.q.get(timeout=timeout) + return False + except queue.Empty: + return True + + def good(self, id): + return f"http://localhost:{PUSH_PORT}/{id}" + def bad(self, id): + return f"http://localhost:{PUSH_PORT}/err/{id}" + +class MailServer: + def __init__(self): + q = self.q = queue.Queue() + + class Handler: + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + envelope.rcpt_tos.append(address) + return '250 OK' + + async def handle_DATA(self, server, session, envelope): + headers, body = envelope.content.decode('utf8').split("\r\n\r\n", maxsplit=1) + if "Content-Transfer-Encoding: quoted-printable" in headers: + body = quopri.decodestring(body).decode('utf8') + q.put((envelope.rcpt_tos, body)) + return '250 Message accepted for delivery' + + self.controller = Controller(Handler(), hostname="localhost", port=SMTP_PORT) + self.controller.start() + + def stop(self): + self.controller.stop() + + def wait(self, timeout=2): + return self.q.get(timeout=timeout) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..15149cb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,115 @@ +import base64 +import json +import pytest +from fxa.errors import ClientError + +from api import * + +@pytest.fixture +def push_server(): + s = PushServer() + yield s + s.server.shutdown() + s.server.server_close() + +@pytest.fixture +def mail_server(): + s = MailServer() + yield s + s.stop() + +@pytest.fixture +def client(): + return AuthClient() + +def _login(client, email, mail_server): + # unverified accounts and unverified session behave the same, so we don't bother + # with dedicated unverified-session tests and just always verify. + c = client.login(email, "") + (to, body) = mail_server.wait() + assert to == [email] + c.post_a('/session/verify_code', { 'code': body.strip() }) + return c + +def _account(client, primary, email, mail_server): + s = client.create_account(email, "") + try: + (to, body) = mail_server.wait() + assert to == [email] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + if primary: + yield s + else: + c = _login(client, email, mail_server) + yield c + s.password = c.password + finally: + try: + s.destroy_account(email, s.password) + except ClientError as e: + # don't fail if the account was already deleted + if e.details['errno'] != 102: + raise + +@pytest.fixture(params=[True, False], ids=["primary", "secondary"]) +def account(client, request, mail_server): + for a in _account(client, request.param, "test.account@test-auth", mail_server): + yield a +@pytest.fixture(params=[True, False], ids=["primary", "secondary"]) +def account2(client, request, mail_server): + for a in _account(client, request.param, "test.account2@test-auth", mail_server): + yield a + +@pytest.fixture +def unverified_account(client, mail_server): + s = client.create_account("test.account@test-auth", "") + yield s + s.destroy_account("test.account@test-auth", "") + +@pytest.fixture +def login(client, mail_server): + return _login(client, "test.account@test-auth", mail_server) +@pytest.fixture +def login2(client, mail_server): + return _login(client, "test.account2@test-auth", mail_server) + +def _refresh_token(account, scope): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "offline", + "scope": scope, + } + yield account.post_a("/oauth/token", body) + +@pytest.fixture +def refresh_token(account): + for r in _refresh_token(account, "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"): + yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r) +@pytest.fixture +def narrow_refresh_token(account): + for r in _refresh_token(account, "profile https://identity.mozilla.com/tokens/session"): + yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r) + +def _account_or_rt(account, request, scope): + if request.param: + yield account + else: + for r in _refresh_token(account, scope): + yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r) + +@pytest.fixture(params=[True, False], ids=["session", "refresh_token"]) +def account_or_rt(account, request): + for r in _account_or_rt(account, request, "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"): + yield r + +@pytest.fixture +def forgot_token(account): + resp = account.post_a("/password/forgot/send_code", { 'email': account.email }) + assert 'passwordForgotToken' in resp + assert resp['ttl'] == 300 + assert resp['codeLength'] == 16 + assert resp['tries'] == 1 + return PasswordChange(account.client, resp['passwordForgotToken'], 'passwordForgotToken') diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..afded52 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,73 @@ +use std::env; + +use anyhow::Context; +use base64::URL_SAFE_NO_PAD; +use chrono::{Duration, Utc}; +use futures::channel::oneshot::channel; +use minor_skulk::{build, db::Db}; +use password_hash::rand_core::OsRng; +use rand::RngCore; +use rocket::{ + fairing::AdHoc, + tokio::{process::Command, spawn}, +}; + +#[macro_use] +extern crate rocket; +extern crate anyhow; + +async fn run_pytest(markers: &'static str, invite: bool) -> anyhow::Result<()> { + dotenv::dotenv().ok(); + // at this point this is only a test runner to be used by the nix build. + env::set_var("ROCKET_LOG_LEVEL", "off"); + + let (tx, rx) = channel(); + let rocket = build().await?.attach(AdHoc::on_liftoff("notify startup", move |rocket| { + Box::pin(async move { + // add an invite code as-if generated during startup and move it to an + // env var, emulating the user looking at the logs and copying the code. + // invite_only needs this to function. + if invite { + let db = rocket.state::<Db>().unwrap(); + let tx = db.begin().await.unwrap(); + let mut code = [0; 32]; + OsRng.fill_bytes(&mut code); + let code = base64::encode_config(code, URL_SAFE_NO_PAD); + tx.add_invite_code(&code, Utc::now() + Duration::minutes(5)).await.unwrap(); + tx.commit().await.unwrap(); + env::set_var("INVITE_CODE", code); + } + + tx.send(()).unwrap(); + }) + })); + + spawn(async move { rocket.launch().await }); + let test = spawn(async move { + rx.await.expect("test trigger failed"); + let mut child = Command::new("pytest") + .arg("-vvv") + .arg("-m") + .arg(markers) + .spawn() + .expect("failed to spawn"); + child.wait().await + }); + + assert!(test.await.context("starting pytest")?.context("running pytest")?.success()); + + Ok(()) +} + +#[async_test] +async fn open() -> anyhow::Result<()> { + env::set_var("ROCKET_INVITE_ONLY", "false"); + run_pytest("not invite", false).await +} + +#[async_test] +async fn invite_only() -> anyhow::Result<()> { + env::set_var("ROCKET_INVITE_ONLY", "true"); + env::set_var("ROCKET_INVITE_ADMIN_ADDRESS", "test.account@test-auth"); + run_pytest("invite", true).await +} diff --git a/tests/smtp.py b/tests/smtp.py new file mode 100644 index 0000000..fa6e16b --- /dev/null +++ b/tests/smtp.py @@ -0,0 +1,27 @@ +import asyncio +import quopri +from aiosmtpd.controller import Controller + +class PrintHandler: + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + envelope.rcpt_tos.append(address) + return '250 OK' + + async def handle_DATA(self, server, session, envelope): + print('Message from %s' % envelope.mail_from) + print('Message for %s' % envelope.rcpt_tos) + print('Message data:\n') + headers, body = envelope.content.decode('utf8').split("\r\n\r\n", maxsplit=1) + if "Content-Transfer-Encoding: quoted-printable" in headers: + body = quopri.decodestring(body).decode('utf8') + print(headers) + print() + print(body) + print() + print('End of message') + return '250 Message accepted for delivery' + +if __name__ == '__main__': + controller = Controller(PrintHandler(), hostname="localhost", port=2525) + controller.start() + input() diff --git a/tests/test_auth_account.py b/tests/test_auth_account.py new file mode 100644 index 0000000..68a407b --- /dev/null +++ b/tests/test_auth_account.py @@ -0,0 +1,348 @@ +import os +import pytest +from fxa.errors import ClientError + +from api import * + +def test_create_no_args(client): + with pytest.raises(ClientError) as e: + client.post("/account/create") + assert e.value.details == { + 'code': 400, + 'errno': 106, + 'error': 'Bad Request', + 'message': 'invalid json in request body' + } + +@pytest.mark.parametrize("args", [ + {"email": "", "authPW": "", "extra": ""}, + {"email": "", "authPW": "00"}, + {"email": "a" * 257, "authPW": "0" * 64}, + {"email": "a@test", "authPW": "00"}, + {"email": "a@test", "authPW": "00" * 32, "style": "foo"}, +]) +def test_create_invalid_args(client, args): + with pytest.raises(ClientError) as e: + client.post("/account/create", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_create_nomail(client): + with pytest.raises(ClientError) as e: + client.create_account("test.account@test-auth", "") + assert e.value.details == { + 'code': 422, + 'errno': 151, + 'error': 'Unprocessable Entity', + 'message': 'failed to send email' + } + +def test_create_exists(account): + with pytest.raises(ClientError) as e: + account.create_account(account.email, "") + assert e.value.details == { + 'code': 400, + 'errno': 101, + 'error': 'Bad Request', + 'message': 'account already exists' + } + +@pytest.mark.parametrize("keys", [None, False, True]) +@pytest.mark.parametrize("verify", [False, True]) +def test_create_destroy(client, keys, verify, mail_server): + s = client.create_account("test.create.destroy@test-auth", "", keys=keys) + try: + if verify: + (to, body) = mail_server.wait() + assert to == [s.email] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + if keys: + k = s.fetch_keys(s.props['keyFetchToken'], "") + with pytest.raises(ClientError) as e: + s.fetch_keys(s.props['keyFetchToken'], "") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + finally: + client.destroy_account("test.create.destroy@test-auth", "") + +def test_login_no_args(client): + with pytest.raises(ClientError) as e: + client.post("/account/login") + assert e.value.details == { + 'code': 400, + 'errno': 106, + 'error': 'Bad Request', + 'message': 'invalid json in request body' + } + +@pytest.mark.parametrize("args", [ + {"email": "", "authPW": "", "extra": ""}, + {"email": "", "authPW": "00"}, + {"email": "a" * 257, "authPW": "0" * 64}, + {"email": "a@test", "authPW": "00"}, +]) +def test_login_invalid_args(client, args): + with pytest.raises(ClientError) as e: + client.post("/account/login", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_login_noaccount(client): + with pytest.raises(ClientError) as e: + client.login("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + +def test_login_unverified(client, unverified_account): + with pytest.raises(ClientError) as e: + client.login(unverified_account.email, "") + assert e.value.details == { + 'code': 400, + 'errno': 104, + 'error': 'Bad Request', + 'message': 'unverified account' + } + +def test_login_badcase(account): + with pytest.raises(ClientError) as e: + account.login(account.email.upper(), "") + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_login_badpasswd(account): + with pytest.raises(ClientError) as e: + account.login(account.email, "ca0cb780-f114-405a-a5c2-1a4deb933a51") + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +@pytest.mark.parametrize("keys", [None, False, True]) +def test_login(client, account, keys): + s = client.login(account.email, "", keys=keys) + try: + if keys: + s.fetch_keys(s.props['keyFetchToken'], "") + with pytest.raises(ClientError) as e: + s.fetch_keys(s.props['keyFetchToken'], "") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + finally: + s.destroy_session() + +@pytest.mark.parametrize("args", [ + {"email": "", "authPW": "", "extra": ""}, + {"email": "", "authPW": "00"}, + {"email": "a" * 257, "authPW": "0" * 64}, + {"email": "a@test", "authPW": "00"}, +]) +def test_destroy_invalid_args(client, args): + with pytest.raises(ClientError) as e: + client.post("/account/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_destroy_noaccount(client): + with pytest.raises(ClientError) as e: + client.destroy_account("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + +def test_destroy_badcase(account): + with pytest.raises(ClientError) as e: + account.destroy_account(account.email.upper(), "") + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_destroy_badpasswd(account): + with pytest.raises(ClientError) as e: + account.destroy_account(account.email, "ca0cb780-f114-405a-a5c2-1a4deb933a51") + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +@pytest.mark.parametrize("auth", [ + {}, + {"authorization": "bearer invalid"}, + {"authorization": "hawk id=invalid"}, +]) +def test_keys_invalid(client, auth): + with pytest.raises(ClientError) as e: + client.get("/account/keys", headers=auth) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +# create and login do the remaining keyfetch tests. +# we don't check whether the bundle is actually *valid* because we'd have to look +# into the db to do a full test, so we'll trust it is correct if sync works. + +@pytest.fixture +def reset_token(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + assert account.email in to + resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + return AccountReset(account.client, resp['accountResetToken']) + +@pytest.mark.parametrize("args", [ + { 'authPW': '00', }, + { 'authPW': '00' * 32, 'extra': 0, }, +]) +def test_reset_invalid(reset_token, args): + with pytest.raises(ClientError) as e: + reset_token.post_a("/account/reset", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_reset(account, reset_token, mail_server, push_server): + dev = Device(account, "dev", pcb=push_server.good("d4105515-f4f0-4d26-85c1-f48c5c348edb")) + resp = reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") }) + assert resp == {} + (to, body) = mail_server.wait() + assert account.email in to + assert 'account has been reset' in body + p = push_server.wait() + assert p[0] == "/d4105515-f4f0-4d26-85c1-f48c5c348edb" + assert dev.decrypt(p[2]) == {'command': 'fxaccounts:password_reset', 'version': 1} + p = push_server.wait() + assert p[0] == "/d4105515-f4f0-4d26-85c1-f48c5c348edb" + assert dev.decrypt(p[2]) == { + 'command': 'fxaccounts:device_disconnected', + 'data': {'id': dev.id}, + 'version': 1 + } + assert push_server.done() + +def test_reset(account, reset_token, mail_server, push_server): + reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") }) + with pytest.raises(ClientError) as e: + reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") }) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.invite +def test_create_noinvite(client): + with pytest.raises(ClientError) as e: + client.create_account("test.create.destroy@test-auth", "") + assert e.value.details == { + 'code': 400, + 'errno': -1, + 'error': 'Bad Request', + 'message': 'invite code required' + } + +@pytest.mark.invite +def test_create_badinvite(client): + with pytest.raises(ClientError) as e: + client.create_account("test.create.destroy@test-auth", "", { 'style': '' }) + assert e.value.details == { + 'code': 400, + 'errno': -1, + 'error': 'Bad Request', + 'message': 'invite code required' + } + +@pytest.mark.invite +def test_create_invite(client, mail_server): + # all in one test because restarting the server from python would be a pain + c = client.create_account("test.account@test-auth", "", invite=os.environ['INVITE_CODE']) + c2 = None + try: + invite = Invite(c.props['sessionToken']) + # unverified sessions fail + with pytest.raises(ClientError) as e: + code = invite.post_a('/generate', { 'ttl_hours': 1 }) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + # verified session works + (to, body) = mail_server.wait() + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + c.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + code = invite.post_a('/generate', { 'ttl_hours': 1 }) + assert 'url' in code + code = code['url'].split('/')[-1] + # code allows registration + c2 = client.create_account("test.account2@test-auth", "", invite=code) + (to, body) = mail_server.wait() + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + c2.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + # token is consumed + with pytest.raises(ClientError) as e: + client.create_account("test.account3@test-auth", "", invite=code) + assert e.value.details == { + 'code': 400, + 'errno': -2, + 'error': 'Bad Request', + 'message': 'invite code not found' + } + # non-admin accounts can't generate tokens + with pytest.raises(ClientError) as e: + invite2 = Invite(c2.props['sessionToken']) + invite2.post_a('/generate', { 'ttl_hours': 1 }) + assert e.value.details == { + 'code': 401, + 'errno': 110, + 'error': 'Unauthorized', + 'message': 'invalid authentication token' + } + finally: + c.destroy_account(c.email, "") + if c2 is not None: + c2.destroy_account(c2.email, "") diff --git a/tests/test_auth_device.py b/tests/test_auth_device.py new file mode 100644 index 0000000..8504ba7 --- /dev/null +++ b/tests/test_auth_device.py @@ -0,0 +1,434 @@ +import copy +import pytest +import random +import time +from fxa.errors import ClientError + +from api import * + +device_data = [ + { "name": "testdev1", "type": "desktop", "availableCommands": { "a": "b" } }, + { "name": "testdev2", "type": "desktop", "availableCommands": { "a": "b" } }, +] + +@pytest.fixture +def populate_devices(account_or_rt, login): + devs = [] + for (account, dev) in [(account_or_rt, device_data[0]), (login, device_data[1])]: + dev = account.post_a("/account/device", dev) + devs.append(dev) + return devs + +def test_create_noauth(client): + with pytest.raises(ClientError) as e: + client.post_a("/account/device", device_data[0]) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({ 'name': 'dev', 'availableCommands': {'a':1} }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'name': 'dev', 'availableCommands': {'a':1}, 'extra': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({}, + 400, 108, 'Bad Request', 'missing parameter in request body'), + ({ 'id': '00' * 16, 'name': 'dev' }, + 400, 108, 'Bad Request', 'missing parameter in request body'), +]) +def test_create_invalid(account_or_rt, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/device", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_destroy_noauth(client, populate_devices): + with pytest.raises(ClientError) as e: + client.post_a("/account/device/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + with pytest.raises(ClientError) as e: + client.post_a("/account/device/destroy", {'id': populate_devices[0]['id']}) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({ 'id': '00' }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16, 'extra': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16 }, + 400, 123, 'Bad Request', 'unknown device'), +]) +def test_destroy_invalid(account_or_rt, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/device/destroy", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_create_destroy(account_or_rt): + dev = account_or_rt.post_a("/account/device", device_data[0]) + account_or_rt.post_a("/account/device/destroy", {'id': dev['id']}) + +def test_create_unverified(unverified_account): + unverified_account.post_a("/account/device", device_data[0]) + +def test_list_noauth(client): + with pytest.raises(ClientError) as e: + client.get("/account/devices") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_list_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.get_a("/account/devices") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_list_none(account_or_rt): + devs = account_or_rt.get_a("/account/devices") + assert devs == [] + +@pytest.mark.usefixtures("populate_devices") +def test_list(account_or_rt): + devs = account_or_rt.get_a("/account/devices") + assert len(devs) == 2 + (first, second) = (0, 1) if devs[0]['name'] == 'testdev1' else (1, 0) + assert devs[first]['isCurrentDevice'] + assert not devs[second]['isCurrentDevice'] + for (k, v) in device_data[first].items(): + assert devs[0][k] == v + for (k, v) in device_data[second].items(): + assert devs[1][k] == v + +def test_change(account_or_rt, populate_devices): + devs1 = populate_devices + devs = copy.deepcopy(devs1) + (devs[0]['name'], devs[1]['name']) = (devs[1]['name'], devs[0]['name']) + (devs[0]['pushCallback'], devs[1]['pushCallback']) = (devs[1]['pushCallback'] or "", devs[0]['pushCallback'] or "") + (devs[0]['pushPublicKey'], devs[1]['pushPublicKey']) = (devs[1]['pushPublicKey'] or "", devs[0]['pushPublicKey'] or "") + (devs[0]['pushAuthKey'], devs[1]['pushAuthKey']) = (devs[1]['pushAuthKey'] or "", devs[0]['pushAuthKey'] or "") + for dev in devs: + del dev['isCurrentDevice'] + del dev['lastAccessTime'] + del dev['pushEndpointExpired'] + account_or_rt.post_a("/account/device", dev) + devs2 = account_or_rt.get_a("/account/devices") + mdevs1 = { + devs1[0]['id']: devs1[0], + devs1[1]['id']: devs1[1], + } + mdevs2 = { + devs2[0]['id']: devs2[0], + devs2[1]['id']: devs2[1], + } + (id1, id2) = mdevs1.keys() + for (i1, i2) in [(id1, id2), (id2, id1)]: + assert mdevs1[i1]['name'] == mdevs2[i2]['name'] + assert mdevs1[i1]['pushCallback'] or '' == mdevs2[i2]['pushCallback'] or '' + assert mdevs1[i1]['pushPublicKey'] or '' == mdevs2[i2]['pushPublicKey'] or '' + assert mdevs1[i1]['pushAuthKey'] or '' == mdevs2[i2]['pushAuthKey'] or '' + +def test_invoke_noauth(client): + body = {"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + client.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_invoke_unverified(unverified_account): + body = {"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + unverified_account.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({"target": "00", "command": "foo", "payload": {}, "ttl": 10}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"target": "00" * 16, "command": "foo", "payload": {}, "ttl": 10, 'extra': 0}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10}, + 400, 123, 'Bad Request', 'unknown device'), +]) +def test_invoke_invalid(account_or_rt, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/devices/invoke_command", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_invoke_nocmd(account_or_rt, populate_devices): + body = {"target": populate_devices[0]['id'], "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 400, + 'errno': 157, + 'error': 'Bad Request', + 'message': 'unavailable device command' + } + +def test_invoke_other_account(account_or_rt, account2): + dev = account2.post_a("/account/device", device_data[0]) + body = {"target": dev['id'], "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +@pytest.mark.parametrize("ttl", [None, 1, 60, 999999999]) +@pytest.mark.parametrize("has_sender", [False, True]) +def test_invoke(account_or_rt, login, ttl, has_sender): + sender = account_or_rt.post_a("/account/device", device_data[1])['id'] if has_sender else None + dev = login.post_a("/account/device", device_data[0]) + + body = { + "target": dev['id'], + "command": "a", + "payload": { "data": str(random.random()), }, + "ttl": ttl, + } + resp = account_or_rt.post_a("/account/devices/invoke_command", body) + assert resp['enqueued'] + assert not resp['notified'] + assert resp['notifyError'] == "no push callback" + + cmd = login.get_a("/account/device/commands?index=0&limit=11") + assert cmd['last'] + assert len(cmd['messages']) == 1 + assert cmd['messages'][0]['data']['command'] == 'a' + assert cmd['messages'][0]['data']['payload'] == body['payload'] + assert cmd['messages'][0]['data']['sender'] == sender + +def test_commands_noauth(client): + with pytest.raises(ClientError) as e: + client.get_a("/account/device/commands?index=1") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_commands_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.get_a("/account/device/commands?index=1") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_commands_nodev(account_or_rt): + with pytest.raises(ClientError) as e: + account_or_rt.get_a("/account/device/commands?index=1") + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +@pytest.mark.parametrize('n_cmds,offset,limit', [ + (1, 0, 0), + (1, 0, 1), + (1, 0, 100), + (1, 1, 0), + (1, 1, 1), + (1, 1, 100), + (2, 0, 0), + (2, 0, 1), + (2, 0, 100), + (2, 1, 0), + (2, 1, 1), + (2, 1, 100), + (101, 0, 100), + (101, 10, 100), +]) +def test_device_commands_list(account_or_rt, login, n_cmds, offset, limit): + account_or_rt.post_a("/account/device", device_data[1])['id'] + dev = login.post_a("/account/device", device_data[0]) + + bodies = [ { + "target": dev['id'], + "command": "a", + "payload": { "data": str(i), }, + } for i in range(0, n_cmds) ] + + for b in bodies: + resp = account_or_rt.post_a("/account/devices/invoke_command", b) + assert resp['enqueued'] + assert not resp['notified'] + assert resp['notifyError'] == "no push callback" + + cmd = login.get_a("/account/device/commands", params={ "index": 0, "limit": 1 }) + assert cmd == login.get_a("/account/device/commands", params={ "index": 0, "limit": 1 }) + + first_index = cmd['index'] + cmds = login.get_a("/account/device/commands", params={ "limit": limit, "index": first_index + offset }) + assert cmds['last'] == (offset + limit >= n_cmds) + assert len(cmds['messages']) == min(max(n_cmds - offset, 0), limit) + +def test_attached_clients_unauth(client): + with pytest.raises(ClientError) as e: + client.get_a("/account/attached_clients") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_attached_clients_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.get_a("/account/attached_clients") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_attached_clients_badauth(refresh_token): + with pytest.raises(ClientError) as e: + refresh_token.get_a("/account/attached_clients") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_attached_clients(account, refresh_token, push_server): + dev1 = Device(account, "dev1") + dev2 = Device(refresh_token, "dev2", "mobile") + # filter potential login-only sessions + devs = [ d for d in account.get_a("/account/attached_clients") if d['name'] != None ] + assert len(devs) == 2 + devs = { + devs[0]['name']: devs[0], + devs[1]['name']: devs[1], + } + assert devs['dev1']['deviceId'] == dev1.id + assert devs['dev1']['sessionTokenId'] != None + assert devs['dev1']['refreshTokenId'] == None + assert devs['dev1']['isCurrentSession'] + assert devs['dev1']['deviceType'] == "desktop" + assert (time.time() - devs['dev1']['createdTime']) < 10 + assert (time.time() - devs['dev1']['lastAccessTime']) < 10 + assert devs['dev1']['scope'] == None + # + assert devs['dev2']['deviceId'] == dev2.id + assert devs['dev2']['sessionTokenId'] != None + assert devs['dev2']['refreshTokenId'] != None + assert not devs['dev2']['isCurrentSession'] + assert devs['dev2']['deviceType'] == "mobile" + assert (time.time() - devs['dev2']['createdTime']) < 10 + assert (time.time() - devs['dev2']['lastAccessTime']) < 10 + assert devs['dev2']['scope'] == "profile https://identity.mozilla.com/apps/oldsync" + +def test_attached_client_destroy_unauth(client): + with pytest.raises(ClientError) as e: + client.post_a("/account/attached_client/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_attached_client_destroy_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/account/attached_client/destroy") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_attached_client_destroy_badauth(refresh_token): + with pytest.raises(ClientError) as e: + refresh_token.post_a("/account/attached_client/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({"sessionTokenId": "0"}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"refreshTokenId": "0"}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"deviceId": "0"}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"sessionTokenId": "00" * 16, 'extra': 0}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"sessionTokenId": "00" * 16, 'refreshTokenId': "00" * 16}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"sessionTokenId": "00" * 16, 'refreshTokenId': "00" * 16, 'deviceId': "00" * 16}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({'refreshTokenId': "00" * 16, 'deviceId': "00" * 16}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), +]) +def test_attached_client_destroy_invalid(account, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account.post_a("/account/attached_client/destroy", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +@pytest.mark.parametrize("fn", [ + (lambda d: {'sessionTokenId': d['sessionTokenId']}), + (lambda d: {'refreshTokenId': d['refreshTokenId']}), + (lambda d: {'deviceId': d['deviceId']}), +], ids=["session", "refresh", "device"]) +def test_attached_client_destroy(account, refresh_token, fn): + Device(refresh_token, "dev2") + devs = account.get_a("/account/attached_clients") + account.post_a("/account/attached_client/destroy", fn(devs[0])) + devs2 = account.get_a("/account/attached_clients") + assert len(devs2) == len(devs) - 1 + +def test_attached_client_destroy_push(account, refresh_token, push_server): + dev = Device(account, "dev") + dev2 = Device(refresh_token, "dev2") + dev.update_pcb(push_server.good("4ed8d1d3-e756-4866-9169-aafe612eb1e9")) + account.post_a("/account/attached_client/destroy", { 'deviceId': dev2.id }) + p = push_server.wait() + assert p[0] == "/4ed8d1d3-e756-4866-9169-aafe612eb1e9" + assert dev.decrypt(p[2]) == { + 'command': 'fxaccounts:device_disconnected', + 'data': {'id': dev2.id}, + 'version': 1 + } + assert push_server.done() diff --git a/tests/test_auth_email.py b/tests/test_auth_email.py new file mode 100644 index 0000000..319f2d4 --- /dev/null +++ b/tests/test_auth_email.py @@ -0,0 +1,96 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +def test_status_noauth(client, refresh_token): + with pytest.raises(ClientError) as e: + client.post_a("/recovery_email/status") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + with pytest.raises(ClientError) as e: + refresh_token.post_a("/recovery_email/status") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_status_unverified(unverified_account): + resp = unverified_account.get_a("/recovery_email/status") + assert resp == { + 'email': unverified_account.email, + 'emailVerified': False, + 'sessionVerified': False, + 'verified': False + } + +def test_status_verified(account): + resp = account.get_a("/recovery_email/status") + assert resp == { + 'email': account.email, + 'emailVerified': True, + 'sessionVerified': True, + 'verified': True + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({ 'uid': '00', 'code': "" }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16, 'code': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16, 'code': "", 'extra': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), +]) +def test_verify_code_invalid(unverified_account, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/recovery_email/verify_code", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_verify_code(account): + # fixture does all the work + pass + +def test_verify_code_reuse(client, mail_server): + s = client.create_account("test@test", "") + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + with pytest.raises(ClientError) as e: + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + s.destroy_account("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 105, + 'error': 'Bad Request', + 'message': 'invalid verification code' + } + +def test_resend_code(client, mail_server): + s = client.create_account("test@test", "") + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/resend_code', {}) + (to2, body2) = mail_server.wait() + assert to == to2 + assert body == body2 + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + with pytest.raises(ClientError) as e: + s.post_a('/recovery_email/resend_code', {}) + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.destroy_account("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 105, + 'error': 'Bad Request', + 'message': 'invalid verification code' + } diff --git a/tests/test_auth_oauth.py b/tests/test_auth_oauth.py new file mode 100644 index 0000000..f8ad201 --- /dev/null +++ b/tests/test_auth_oauth.py @@ -0,0 +1,369 @@ +import copy +import pytest +import time +from fxa.errors import ClientError + +@pytest.mark.parametrize("client_id,scopes", [ + # firefox + ("5882386c6d801776", "profile:write https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"), + # fenix + ("a2270f727f45f648", "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"), +]) +def test_oauth_client_scopes(account, client_id, scopes): + body = { + "client_id": client_id, + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": scopes, + } + s = account.post_a("/oauth/token", body)['access_token'] + account.post_a("/oauth/destroy", { "client_id": client_id, "token": s }) + +def test_oauth_authorization_noauth(account): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile", + } + with pytest.raises(ClientError) as e: + account.post("/oauth/authorization", body) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_oauth_authorization_unverified(unverified_account): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile", + } + with pytest.raises(ClientError) as e: + unverified_account.post_a("/oauth/authorization", body) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +@pytest.mark.parametrize("args", [ + { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "invalid", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" }, + { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "online", + "code_challenge": "", "code_challenge_method": "invalid", "response_type": "code" }, + { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "online", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "invalid" }, +]) +def test_oauth_authorization_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/oauth/authorization", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_authorization_badclientid(account): + args = { "client_id": "invalid", "state": "", "scope": "profile", "access_type": "online", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/authorization", args) + assert e.value.details == { + 'code': 400, + 'errno': 162, + 'error': 'Bad Request', + 'message': 'unknown client_id' + } + +def test_oauth_authorization_badscope(account): + args = { "client_id": "5882386c6d801776", "state": "", "scope": "invalid", "access_type": "online", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/authorization", args) + assert e.value.details == { + 'code': 400, + 'errno': 169, + 'error': 'Bad Request', + 'message': 'requested scopes not allowed' + } + +# see below for combined /authorization + unauthed /token + +def test_oauth_destroy_notoken(account): + args = { "client_id": "5882386c6d801776", "token": "00" * 32 } + with pytest.raises(ClientError) as e: + account.post("/oauth/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_destroy_badclient(account, refresh_token): + args = { "client_id": "other", "token": refresh_token.bearer } + with pytest.raises(ClientError) as e: + account.post("/oauth/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_scoped_keys_badclient(account): + with pytest.raises(ClientError) as e: + account.post_a("/account/scoped-key-data", { + "client_id": "invalid", + "scope": "https://identity.mozilla.com/apps/oldsync" + }) + assert e.value.details == { + 'code': 400, + 'errno': 162, + 'error': 'Bad Request', + 'message': 'unknown client_id' + } + +def test_oauth_scoped_keys_badscope(account): + with pytest.raises(ClientError) as e: + account.post_a("/account/scoped-key-data", { + "client_id": "5882386c6d801776", + "scope": "scope" + }) + assert e.value.details == { + 'code': 400, + 'errno': 169, + 'error': 'Bad Request', + 'message': 'requested scopes not allowed' + } + +def test_oauth_scoped_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/account/scoped-key-data", { + "client_id": "5882386c6d801776", + "scope": "https://identity.mozilla.com/apps/oldsync" + }) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_oauth_scoped_keys(account): + resp = account.post_a("/account/scoped-key-data", { + "client_id": "5882386c6d801776", + "scope": "https://identity.mozilla.com/apps/oldsync" + }) + assert resp == { + "https://identity.mozilla.com/apps/oldsync": { + "identifier": "https://identity.mozilla.com/apps/oldsync", + "keyRotationSecret": "00" * 32, + "keyRotationTimestamp": 0, + }, + } + +@pytest.mark.parametrize("access_type", ["online", "offline"]) +def test_oauth_token_fxa_badclient(account, access_type): + body = { "client_id": "invalid", "ttl": 60, "grant_type": "fxa-credentials", + "access_type": access_type, "scope": "profile" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 162, + 'error': 'Bad Request', + 'message': 'unknown client_id' + } + +@pytest.mark.parametrize("access_type", ["online", "offline"]) +def test_oauth_token_fxa_badscope(account, access_type): + body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials", + "access_type": access_type, "scope": "scope" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 169, + 'error': 'Bad Request', + 'message': 'requested scopes not allowed' + } + +@pytest.mark.parametrize("grant_type,access_type", [ + ("authorization_code", "online"), + ("refresh_token", "online"), + ("fxa-credentials", "foo"), +]) +def test_oauth_token_fxa_invalid(account, grant_type, access_type): + body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": grant_type, + "access_type": access_type, "scope": "scope" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_token_unverified(unverified_account): + body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials", + "access_type": "online", "scope": "profile" } + with pytest.raises(ClientError) as e: + unverified_account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +@pytest.fixture +def auth_code(account): + body = { + "client_id": "5882386c6d801776", + "state": "test", + "scope": "profile", + "access_type": "online", + "code_challenge": "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", # "test" + "code_challenge_method": "S256", + "response_type": "code", + } + return account.post_a("/oauth/authorization", body)['code'] + +@pytest.mark.parametrize("args,code,error,errno,message", [ + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code": "invalid", "code_verifier": "test" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code_verifier": "test", "extra": 0 }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code": "00" * 32, "code_verifier": "test" }, + 401, 'Unauthorized', 110, 'invalid authentication token'), + ({ "client_id": "invalid", "ttl": 60, "grant_type": "authorization_code", + "code_verifier": "test" }, + 400, 'Bad Request', 162, 'unknown client_id'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code_verifier": "invalid" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), +]) +def test_oauth_token_other_invalidcode(account, args, code, error, errno, message, auth_code): + args = copy.deepcopy(args) + if 'code' not in args: args['code'] = auth_code + with pytest.raises(ClientError) as e: + account.post("/oauth/token", args) + assert e.value.details == { 'code': code, 'errno': errno, 'error': error, 'message': message } + +@pytest.mark.parametrize("args,code,error,errno,message", [ + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials", + "scope": "profile", "access_type": "online" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "refresh_token": "invalid", "code_verifier": "test" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "scope": "profile", "extra": 0 }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "invalid", "ttl": 60, "grant_type": "refresh_token", + "scope": "profile" }, + 400, 'Bad Request', 162, 'unknown client_id'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "scope": "foo" }, + 400, 'Bad Request', 169, 'requested scopes not allowed'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "scope": "profile:write" }, + 400, 'Bad Request', 169, 'requested scopes not allowed'), +]) +def test_oauth_token_other_invalidrefresh(account, args, code, error, errno, message, refresh_token): + args = copy.deepcopy(args) + if 'refresh_token' not in args: args['refresh_token'] = refresh_token.bearer + with pytest.raises(ClientError) as e: + account.post("/oauth/token", args) + assert e.value.details == { 'code': code, 'errno': errno, 'error': error, 'message': message } + +@pytest.mark.parametrize("refresh", [False, True]) +def test_oauth_fxa(account, refresh): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "offline" if refresh else "online", + "scope": "profile", + } + resp = account.post_a("/oauth/token", body) + + assert 'access_token' in resp + assert ('refresh_token' in resp) == refresh + assert 'session_token' not in resp + assert resp['scope'] == 'profile' + assert resp['token_type'] == 'bearer' + assert resp['expires_in'] <= 60 + assert (resp['auth_at'] - time.time()) < 10 + assert 'keys_jwe' not in resp + +@pytest.mark.parametrize("keys_jwe", [None, "keyskeyskeys"]) +@pytest.mark.parametrize("refresh,session", [ + (False, False), + (True, False), + (True, True), +]) +def test_oauth_auth_code(account, keys_jwe, refresh, session): + scope = "profile" + (" https://identity.mozilla.com/tokens/session" if session else "") + body = { + "client_id": "5882386c6d801776", + "state": "test", + "keys_jwe": keys_jwe, + "scope": scope, + "access_type": "offline" if refresh else "online", + "code_challenge": "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", # "test" + "code_challenge_method": "S256", + "response_type": "code", + } + resp = account.post_a("/oauth/authorization", body) + assert resp['state'] == "test" + + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "authorization_code", + "code": resp['code'], + "code_verifier": "test", + } + resp = account.post("/oauth/token", body) + assert 'access_token' in resp + assert ('refresh_token' in resp) == refresh + assert ('session_token' in resp) == session + assert resp['scope'] == 'profile' + assert resp['token_type'] == 'bearer' + assert resp['expires_in'] <= 60 + assert (resp['auth_at'] - time.time()) < 10 + assert keys_jwe is None or (resp['keys_jwe'] == keys_jwe) + +def test_oauth_refresh(account, refresh_token): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "refresh_token", + "refresh_token": refresh_token.bearer, + "scope": "profile", + } + resp = account.post("/oauth/token", body) + + assert 'access_token' in resp + assert 'refresh_token' not in resp + assert 'session_token' not in resp + assert resp['scope'] == 'profile' + assert resp['token_type'] == 'bearer' + assert resp['expires_in'] <= 60 + assert (resp['auth_at'] - time.time()) < 10 + assert 'keys_jwe' not in resp diff --git a/tests/test_auth_password.py b/tests/test_auth_password.py new file mode 100644 index 0000000..7c2064a --- /dev/null +++ b/tests/test_auth_password.py @@ -0,0 +1,211 @@ +import pytest +from fxa.crypto import derive_key, quick_stretch_password +from fxa.errors import ClientError + +from api import * + +@pytest.mark.parametrize("args", [ + { 'email': "", 'oldAuthPW': '00' * 32 }, + { 'email': "test0@test", 'oldAuthPW': '00' }, + { 'email': "test0@test", 'oldAuthPW': '00' * 32, 'extra': 0 }, +]) +def test_change_start_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_change_start_badaccount(account): + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", { 'email': "test0@test", 'oldAuthPW': '00' * 32 }) + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", { 'email': account.email.upper(), 'oldAuthPW': '00' * 32 }) + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_change_start_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/password/change/start", { + 'email': unverified_account.email, + 'oldAuthPW': '00' * 32 + }) + assert e.value.details == { + 'code': 400, + 'errno': 104, + 'error': 'Bad Request', + 'message': 'unverified account' + } + +def test_change_start_badpw(account): + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", { 'email': account.email, 'oldAuthPW': '00' * 32 }) + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +@pytest.fixture +def change_token(account): + pw = auth_pw(account.email, "") + resp = account.post_a("/password/change/start", { 'email': account.email, 'oldAuthPW': pw }) + assert 'keyFetchToken' in resp + return PasswordChange(account.client, resp['passwordChangeToken']) + +@pytest.mark.parametrize("args", [ + { 'authPW': '00', 'wrapKb': '00' * 32, 'sessionToken': '00' * 32, }, + { 'authPW': '00' * 32, 'wrapKb': '00', 'sessionToken': '00' * 32, }, + { 'authPW': '00' * 32, 'wrapKb': '00' * 32, 'sessionToken': '00', }, +]) +def test_change_finish_invalid(change_token, args): + with pytest.raises(ClientError) as e: + change_token.post_a("/password/change/finish", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_change_finish(account, change_token, mail_server): + pw = auth_pw(account.email, "new") + change_token.post_a("/password/change/finish", { + 'authPW': pw, + 'wrapKb': '00' * 32, + }) + account.password = "new" # for fixture teardown + (to, body) = mail_server.wait() + assert account.email in to + assert 'password has been changed' in body + + # just do a login test to see that the password was really changed + account.login(account.email, "new") + with pytest.raises(ClientError) as e: + account.login(account.email, "") + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +def test_change_finish_twice(account, change_token, mail_server): + pw = auth_pw(account.email, "new") + change_token.post_a("/password/change/finish", { + 'authPW': pw, + 'wrapKb': '00' * 32, + }) + account.password = "new" # for fixture teardown + + with pytest.raises(ClientError) as e: + change_token.post_a("/password/change/finish", { + 'authPW': pw, + 'wrapKb': '00' * 32, + }) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args", [ + { 'email': "" }, + { 'email': "test0@test", 'extra': 0 }, +]) +def test_forgot_start_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/password/forgot/send_code", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_change_forgot_badaccount(account): + with pytest.raises(ClientError) as e: + account.post_a("/password/forgot/send_code", { 'email': "test0@test" }) + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + with pytest.raises(ClientError) as e: + account.post_a("/password/forgot/send_code", { 'email': account.email.upper() }) + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_change_forgot_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/password/forgot/send_code", { 'email': unverified_account.email }) + assert e.value.details == { + 'code': 400, + 'errno': 104, + 'error': 'Bad Request', + 'message': 'unverified account' + } + +@pytest.mark.parametrize("args", [ + { 'code': '', 'extra': 0, }, +]) +def test_forgot_finish_invalid(change_token, args): + with pytest.raises(ClientError) as e: + change_token.post_a("/password/forgot/send_code", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_forgot_finish_badcode(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + assert account.email in to + with pytest.raises(ClientError) as e: + resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': '' }) + assert e.value.details == { + 'code': 400, + 'errno': 105, + 'error': 'Bad Request', + 'message': 'invalid verification code' + } + +def test_forgot_finish(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + assert account.email in to + resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + assert 'accountResetToken' in resp + +def test_forgot_finish_twice(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + + with pytest.raises(ClientError) as e: + forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } diff --git a/tests/test_auth_session.py b/tests/test_auth_session.py new file mode 100644 index 0000000..3a6e7c4 --- /dev/null +++ b/tests/test_auth_session.py @@ -0,0 +1,69 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +def test_session_loggedout(client): + with pytest.raises(ClientError) as e: + client.post("/session/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_status(account): + resp = account.get_a("/session/status") + assert resp == { 'state': '', 'uid': account.props['uid'] } + +def test_resend(account, mail_server): + c = account.login(account.email, "") + (to, body) = mail_server.wait() + assert to == [account.email] + c.post_a("/session/resend_code", {}) + (to2, body2) = mail_server.wait() + assert to == to2 + assert body == body2 + +@pytest.mark.parametrize("args", [ + { 'custom_session_id': '00' }, + { 'extra': '00' }, +]) +def test_session_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/session/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_session_noid(account): + with pytest.raises(ClientError) as e: + account.post_a("/session/destroy", { 'custom_session_id': '0' * 64 }) + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +def test_session_destroy_other(account, account2): + with pytest.raises(ClientError) as e: + account.post_a("/session/destroy", { 'custom_session_id': account2.auth.id }) + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +def test_session_destroy_unverified(unverified_account): + unverified_account.destroy_session() + unverified_account.destroy_session = lambda *args: None + +def test_session_destroy(account): + s = account.login(account.email, "") + s.destroy_session() diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..3eb32ac --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,97 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +@pytest.fixture +def oauth(): + return Oauth() + +@pytest.fixture +def access_token(account): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile", + } + resp = account.post_a("/oauth/token", body) + return resp['access_token'] + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({"access_token": "0"}, + 400, 109, 'Bad Request', 'invalid request parameter'), + ({"refresh_token": "0"}, + 400, 109, 'Bad Request', 'invalid request parameter'), + ({"token": "0"}, + 400, 109, 'Bad Request', 'invalid request parameter'), +]) +def test_destroy_invalid(oauth, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + oauth.post("/destroy", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_destroy_access(oauth, access_token): + oauth.post("/verify", {'token': access_token}) + oauth.post("/destroy", {'access_token': access_token}) + with pytest.raises(ClientError) as e: + oauth.post("/verify", {'token': access_token}) + assert e.value.details == { + 'code': 400, + 'errno': 109, + 'error': 'Bad Request', + 'message': 'invalid request parameter' + } + +def test_destroy_refresh(oauth, refresh_token): + refresh_token.get_a("/account/devices") + oauth.post("/destroy", {'refresh_token': refresh_token.bearer}) + with pytest.raises(ClientError) as e: + refresh_token.get_a("/account/devices") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_destroy_any(oauth, access_token, refresh_token): + oauth.post("/verify", {'token': access_token}) + oauth.post("/destroy", {'token': access_token}) + with pytest.raises(ClientError) as e: + oauth.post("/verify", {'token': access_token}) + assert e.value.details == { + 'code': 400, + 'errno': 109, + 'error': 'Bad Request', + 'message': 'invalid request parameter' + } + + refresh_token.get_a("/account/devices") + oauth.post("/destroy", {'token': refresh_token.bearer}) + with pytest.raises(ClientError) as e: + refresh_token.get_a("/account/devices") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_oauth_verify(account, oauth, access_token): + assert oauth.post("/verify", {'token': access_token}) == { + 'user': account.props['uid'], + 'client_id': "5882386c6d801776", + 'scope': ['profile'], + } + +def test_oauth_verify_refresh(oauth, refresh_token): + with pytest.raises(ClientError) as e: + oauth.post("/verify", {'token': refresh_token.bearer}) + assert e.value.details == { + 'code': 400, + 'errno': 109, + 'error': 'Bad Request', + 'message': 'invalid request parameter' + } diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..5e7308a --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,134 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +@pytest.fixture +def profile(account): + return account.profile() + +def test_profile_noauth(profile): + with pytest.raises(ClientError) as e: + profile.get("/profile") + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_display_name_noauth(profile): + with pytest.raises(ClientError) as e: + profile.post("/display_name", {'displayName': 'foo'}) + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_avatar_upload_noauth(profile): + with pytest.raises(ClientError) as e: + profile.post("/avatar/upload", "foo", headers={'content-type': 'image/png'}) + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_avatar_delete_noauth(profile): + with pytest.raises(ClientError) as e: + profile.delete("/avatar/00000000000000000000000000000000") + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_profile(account, profile): + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': None, + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } + +def test_display_name(account, profile): + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': None, + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } + profile.post_a("/display_name", {'displayName': 'foo'}) + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': 'foo', + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } + +def test_avatar(account, profile): + resp = profile.get_a("/avatar") + assert resp == { + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'id': '00000000000000000000000000000000' + } + +def test_avatar_upload(account, profile): + # server does not parse the bytes + profile.post_a("/avatar/upload", "foo", headers={'content-type': 'image/png'}) + resp = profile.get_a("/avatar") + new_id = resp['id'] + assert resp['avatar'] != 'http://localhost:8000/avatars/00000000000000000000000000000000' + assert not resp['avatarDefault'] + assert resp['id'] != '00000000000000000000000000000000' + resp = profile.get_a("/profile") + assert resp['avatar'] != 'http://localhost:8000/avatars/00000000000000000000000000000000' + assert not resp['avatarDefault'] + +def test_avatar_delete(account, profile): + # server does not parse the bytes + profile.post_a("/avatar/upload", "foo", headers={'content-type': 'image/png'}) + resp = profile.get_a("/avatar") + new_id = resp['id'] + profile.delete_a(f"/avatar/{new_id}") + resp = profile.get_a("/avatar") + assert resp == { + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'id': '00000000000000000000000000000000' + } + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': None, + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } diff --git a/tests/test_push.py b/tests/test_push.py new file mode 100644 index 0000000..e6d732a --- /dev/null +++ b/tests/test_push.py @@ -0,0 +1,147 @@ +import pytest + +from api import * + +def test_account_destroy(account, push_server): + dev = Device(account, "dev", pcb=push_server.good("09d0114e-3b23-4ba3-8474-efac7433e3ba")) + account.destroy_account(account.email, "") + p = push_server.wait() + assert p[0] == "/09d0114e-3b23-4ba3-8474-efac7433e3ba" + assert dev.decrypt(p[2]) == { + 'command': 'fxaccounts:account_destroyed', + 'data': {'uid': account.props['uid']}, + 'version': 1, + } + assert push_server.done() + +def test_account_verify(client, push_server, mail_server): + account = client.create_account("test@test", "") + try: + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + dev = Device(account, "dev", pcb=push_server.good("8accbe08-4040-44c2-8fd9-cf2669b56cb1")) + account.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + p = push_server.wait() + assert p[0] == "/8accbe08-4040-44c2-8fd9-cf2669b56cb1" + assert p[2] == b'' + assert push_server.done() + finally: + account.destroy_account(account.email, "") + +def test_session_destroy(client, account, push_server): + dev1 = Device(account, "dev1") + session = client.login(account.email, "") + dev2 = Device(session, "dev2") + dev1.update_pcb(push_server.good("e6d21a00-9e5e-4d21-92bc-90860cba836e")) + session.destroy_session() + p = push_server.wait() + assert p[0] == "/e6d21a00-9e5e-4d21-92bc-90860cba836e" + assert dev1.decrypt(p[2]) == { + 'command': 'fxaccounts:device_disconnected', + 'data': {'id': dev2.id}, + 'version': 1, + } + assert push_server.done() + +def test_device_connected(client, account, push_server): + dev1 = Device(account, "dev1", pcb=push_server.good("236b8205-daee-4879-b911-64b1f4fd8fd7")) + session = client.login(account.email, "") + dev2 = Device(session, "dev2") + p = push_server.wait() + assert p[0] == "/236b8205-daee-4879-b911-64b1f4fd8fd7" + assert dev1.decrypt(p[2]) == { + 'command': 'fxaccounts:device_connected', + 'data': {'deviceName': 'dev2'}, + 'version': 1, + } + assert push_server.done() + +def test_device_invoke(account, login, push_server): + dev = Device(account, "dev1", commands={'a':'a'}, pcb=push_server.good("3610b071-e2ef-4daa-a4e3-eaa74e50f2a0")) + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + p = push_server.wait() + assert p[0] == "/3610b071-e2ef-4daa-a4e3-eaa74e50f2a0" + msg = dev.decrypt(p[2]) + # NOTE needed because index is unpredictable due to pg sequence use + del msg['data']['index'] + del msg['data']['url'] + assert msg == { + 'command': 'fxaccounts:command_received', + 'data': {'command': 'a', 'sender': dev.id}, + 'version': 1, + } + assert push_server.done() + +def test_expiry(account, login, push_server): + dev = Device(account, "dev1", commands={'a':'a'}, pcb=push_server.bad("59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e")) + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + p = push_server.wait() + assert p[0] == "/err/59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e" + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + with pytest.raises(queue.Empty): + push_server.wait() + dev.update_pcb(push_server.good("59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e")) + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + p = push_server.wait() + assert p[0] == "/59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e" + assert push_server.done() + +def test_device_notify(account, login, push_server): + dev1 = Device(account, "dev1") + dev2 = Device(login, "dev2") + dev1.update_pcb(push_server.good("738ac7e3-96ef-461c-880c-0af20e311354")) + dev2.update_pcb(push_server.good("85a98191-9486-46e2-877a-1152e3d4af4e")) + account.post_a("/account/devices/notify", { + "to": "all", + "_endpointAction": "accountVerify", + "excluded": [dev2.id], + "payload": {'a':1}, + "TTL": 0, + }) + p = push_server.wait() + assert p[0] == "/738ac7e3-96ef-461c-880c-0af20e311354" + assert dev1.decrypt(p[2]) == {'a':1} + account.post_a("/account/devices/notify", { + "to": [dev2.id], + "_endpointAction": "accountVerify", + "payload": {'a':2}, + "TTL": 0, + }) + p = push_server.wait() + assert p[0] == "/85a98191-9486-46e2-877a-1152e3d4af4e" + assert dev2.decrypt(p[2]) == {'a':2} + assert push_server.done() + +def test_profile(account, push_server): + dev = Device(account, "dev", pcb=push_server.good("12608154-8942-4f1c-9de2-a56465d27d6e")) + profile = account.profile() + profile.post_a("/display_name", {"displayName": "foo"}) + p = push_server.wait() + assert p[0] == "/12608154-8942-4f1c-9de2-a56465d27d6e" + assert dev.decrypt(p[2]) == {'command': 'fxaccounts:profile_updated', 'version': 1} + profile.post_a("/avatar/upload", "doesn't parse the image", headers={'content-type': 'image/png'}) + p = push_server.wait() + assert p[0] == "/12608154-8942-4f1c-9de2-a56465d27d6e" + assert dev.decrypt(p[2]) == {'command': 'fxaccounts:profile_updated', 'version': 1} + assert push_server.done() |