diff --git a/fxa/_utils.py b/fxa/_utils.py index 7daeace..546d56e 100644 --- a/fxa/_utils.py +++ b/fxa/_utils.py @@ -13,19 +13,18 @@ import time import hashlib import hmac +import warnings from binascii import hexlify, unhexlify -from base64 import b64encode try: import cPickle as pickle except ImportError: # pragma: no cover import pickle -from urllib.parse import urlparse, urljoin +from urllib.parse import urljoin, urlparse import requests import requests.auth import requests.utils -import hawkauthlib from requests.adapters import HTTPAdapter from urllib3.util import Retry @@ -44,6 +43,21 @@ )) +# Typed Bearer-token prefix per FxA token kind. Must stay in sync with the +# auth-server table in `lib/routes/auth-schemes/bearer-fxa-token.js` and the +# auth-client `lib/bearer.ts`. The prefix keeps these tokens disjoint from the +# OAuth refresh-token scheme (plain `Bearer `) and from legacy Hawk on +# routes that accept more than one. See ADR-0022 / ADR-0050 and +# https://mozilla.github.io/ecosystem-platform/reference/authentication-schemes +TOKEN_PREFIXES = { + "sessionToken": "fxs", + "keyFetchToken": "fxk", + "accountResetToken": "fxar", + "passwordForgotToken": "fxpf", + "passwordChangeToken": "fxpc", +} + + def hexstr(data): """Like binascii.hexlify, but always returns a str instance.""" return hexlify(data).decode("ascii") @@ -146,7 +160,6 @@ class APIClient: * default base server URL * backoff protocol support * sensible request timeouts - * timestamp skew tracking with automatic retry on clockskew error * CI WAF bypass header injection """ @@ -172,7 +185,6 @@ def __init__(self, server_url, session=None): self._session = session self._backoff_until = 0 self._backoff_response = None - self._clockskew = None # Reflect useful properties of the wrapped Session object. @@ -216,30 +228,19 @@ 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. + It's mostly here 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): + def request(self, method, url, json=None, **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. + for an error response. It's also responsible for backoff handling. """ # Don't make requests if we're in backoff. # Instead just synthesize a backoff response. @@ -247,7 +248,6 @@ def request(self, method, url, json=None, retry_auth_errors=True, **kwds): 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 @@ -294,37 +294,6 @@ def request(self, method, url, json=None, retry_auth_errors=True, **kwds): 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: @@ -351,40 +320,57 @@ 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. +_LOOPBACK_HOSTS = frozenset(("localhost", "127.0.0.1", "0.0.0.0", "::1")) + + +def _reject_insecure_token_transport(url): + """Refuse to send a bearer credential over plaintext HTTP. - 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. + Unlike the old Hawk signature, the Bearer header is a replayable + credential, so its confidentiality depends entirely on TLS. Loopback hosts + are exempt so local development against an http auth-server still works. + """ + parsed = urlparse(url) + if parsed.scheme == "http" and parsed.hostname not in _LOOPBACK_HOSTS: + raise fxa.errors.TrustError( + "Refusing to send an FxA token over a non-HTTPS connection to " + f"{parsed.hostname}: the Bearer header is a replayable credential " + "and must only be sent over https." + ) + + +class FxATokenBearerAuth(requests.auth.AuthBase): + """A requests auth hook for FxA tokens delivered as prefixed Bearer tokens. + + This auth hook implements the prefixed-Bearer scheme that replaced Hawk on + the Firefox Accounts auth server (ADR-0022). It uses HKDF to derive an id + and bundle key from a random 32-byte token, then sends the id in the + Authorization header as ``Bearer _``, where ```` + identifies the token kind (see ``TOKEN_PREFIXES``). + + The HKDF derivation is scheme-neutral: the same id is what the legacy Hawk + strategy looked up server-side, and the bundle key is still used to + unbundle encrypted ``account/keys`` responses. """ def __init__(self, token, tokentype, apiclient=None): + try: + self.prefix = TOKEN_PREFIXES[tokentype] + except KeyError: + raise ValueError(f"unknown token kind: {tokentype!r}") from None tokendata = unhexlify(token) + # 96 bytes keeps id ([:32]) and bundle_key ([64:]) at server offsets; + # the middle 32 (old Hawk auth key) are unused. 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:] + # Unused by the Bearer scheme (Hawk read it for the request timestamp); + # retained for signature back-compat with callers and the auth setter. 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()) - 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) + _reject_insecure_token_transport(req.url) + req.headers["Authorization"] = f"Bearer {self.prefix}_{self.id}" return req def bundle(self, namespace, payload): @@ -396,6 +382,24 @@ def unbundle(self, namespace, payload): return fxa.crypto.unbundle(self.bundle_key, namespace, payload) +class HawkTokenAuth(FxATokenBearerAuth): + """Deprecated alias for :class:`FxATokenBearerAuth`. + + Hawk signing was removed in the Bearer migration (ADR-0022); this name now + emits a prefixed Bearer header. Kept so existing imports keep working, but + it warns so callers know to switch to ``FxATokenBearerAuth``. + """ + + def __init__(self, *args, **kwds): + warnings.warn( + "HawkTokenAuth is deprecated and no longer uses Hawk; " + "use FxATokenBearerAuth instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwds) + + class BearerTokenAuth(requests.auth.AuthBase): """A requests auth hook implementing OAuth bearer-token-based auth. @@ -418,12 +422,6 @@ def _decoded(value, encoding='utf-8'): return value -def _encoded(value, encoding='utf-8'): - if not isinstance(value, bytes): - return value.encode(encoding) - return value - - def exactly_one_of(p1_val, p1_name, p2_val, p2_name): if p1_val and p2_val or not p1_val and not p2_val: raise ValueError(f"must specify exactly one of '{p1_name}' or '{p2_name}'") diff --git a/fxa/core.py b/fxa/core.py index 2f970f6..8ca3dd3 100644 --- a/fxa/core.py +++ b/fxa/core.py @@ -9,7 +9,7 @@ from fxa.errors import ClientError from fxa._utils import ( APIClient, - HawkTokenAuth, + FxATokenBearerAuth, exactly_one_of, hexstr ) @@ -224,7 +224,7 @@ def get_random_bytes(self): def fetch_keys(self, key_fetch_token, stretchpwd): url = "/account/keys" - auth = HawkTokenAuth(key_fetch_token, "keyFetchToken", self.apiclient) + auth = FxATokenBearerAuth(key_fetch_token, "keyFetchToken", self.apiclient) resp = self.apiclient.get(url, auth=auth) bundle = unhexlify(resp["bundle"]) keys = auth.unbundle("account/keys", bundle) @@ -273,7 +273,7 @@ def finish_password_change(self, token, stretchpwd, wrapkb): "authPW": hexstr(derive_auth_pw(stretchpwd)), "wrapKb": hexstr(wrapkb), } - auth = HawkTokenAuth(token, "passwordChangeToken", self.apiclient) + auth = FxATokenBearerAuth(token, "passwordChangeToken", self.apiclient) self.apiclient.post("/password/change/finish", body, auth=auth) def finish_password_change_v2(self, token, spwd, kb): @@ -284,7 +284,7 @@ def finish_password_change_v2(self, token, spwd, kb): "wrapKbVersion2": spwd.get_wrapkb_v2(kb), "clientSalt": spwd.v2_salt, } - auth = HawkTokenAuth(token, "passwordChangeToken", self.apiclient) + auth = FxATokenBearerAuth(token, "passwordChangeToken", self.apiclient) return self.apiclient.post("/password/change/finish", body, auth=auth) @@ -317,7 +317,7 @@ def reset_account(self, email, token, password=None, stretchpwd=None): } url = "/account/reset" - auth = HawkTokenAuth(token, "accountResetToken", self.apiclient) + auth = FxATokenBearerAuth(token, "accountResetToken", self.apiclient) self.apiclient.post(url, body, auth=auth) def send_reset_code(self, email, **kwds): @@ -351,7 +351,7 @@ def resend_reset_code(self, email, token, **kwds): msg = f"Unexpected keyword argument: {extra}" raise TypeError(msg) url = "/password/forgot/resend_code" - auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient) + auth = FxATokenBearerAuth(token, "passwordForgotToken", self.apiclient) return self.apiclient.post(url, body, auth=auth) def verify_reset_code(self, token, code): @@ -359,12 +359,12 @@ def verify_reset_code(self, token, code): "code": code, } url = "/password/forgot/verify_code" - auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient) + auth = FxATokenBearerAuth(token, "passwordForgotToken", self.apiclient) return self.apiclient.post(url, body, auth=auth) def get_reset_code_status(self, token): url = "/password/forgot/status" - auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient) + auth = FxATokenBearerAuth(token, "passwordForgotToken", self.apiclient) return self.apiclient.get(url, auth=auth) def verify_email_code(self, uid, code): @@ -424,7 +424,7 @@ def __init__(self, client, email, stretchpwd, uid, token, self.verificationMethod = verificationMethod self.auth_timestamp = auth_timestamp self.keys = None - self._auth = HawkTokenAuth(token, "sessionToken", self.apiclient) + self._auth = FxATokenBearerAuth(token, "sessionToken", self.apiclient) self._key_fetch_token = key_fetch_token # Quick validation on stretchpwd diff --git a/fxa/oauth.py b/fxa/oauth.py index bb0aa18..3bf1b2f 100644 --- a/fxa/oauth.py +++ b/fxa/oauth.py @@ -12,7 +12,7 @@ from fxa.cache import MemoryCache, DEFAULT_CACHE_EXPIRY from fxa.constants import PRODUCTION_URLS from fxa.errors import OutOfProtocolError, ScopeMismatchError, TrustError -from fxa._utils import APIClient, scope_matches, get_hmac, HawkTokenAuth +from fxa._utils import APIClient, scope_matches, get_hmac, FxATokenBearerAuth DEFAULT_SERVER_URL = PRODUCTION_URLS['oauth'] VERSION_SUFFIXES = ("/v1",) @@ -135,7 +135,7 @@ def authorize_code(self, session, scope=None, client_id=None, :param code_challenge: optional PKCE code challenge. :param code_challenge_method: optional PKCE code challenge method. """ - auth = HawkTokenAuth(session.token, "sessionToken", self.apiclient) + auth = FxATokenBearerAuth(session.token, "sessionToken", self.apiclient) if client_id is None: client_id = self.client_id diff --git a/fxa/tests/test_core.py b/fxa/tests/test_core.py index 979b98c..e829618 100644 --- a/fxa/tests/test_core.py +++ b/fxa/tests/test_core.py @@ -9,10 +9,11 @@ import pyotp import pytest import requests +import responses from parameterized import parameterized_class import fxa.errors -from fxa.core import Client, StretchedPassword +from fxa.core import Client, Session, StretchedPassword from fxa._utils import APIClient from fxa.tests.utils import ( @@ -424,6 +425,38 @@ def test_waf_header_set_on_caller_supplied_session(self): self.assertEqual(supplied.headers.get("fxa-ci"), "sekrit") +class TestCoreBearerAuthHeaders(unittest.TestCase): + """Mocked coverage that the migrated call sites send a prefixed Bearer + header with the right per-kind prefix (live tests are gated behind + FXA_RUN_LIVE_TESTS, so this is what guards the wire format in CI). + """ + + server_url = "https://server/v1" + + def setUp(self): + self.client = Client(self.server_url) + + @responses.activate + def test_session_token_call_site_sends_fxs_bearer(self): + responses.add(responses.GET, self.server_url + "/session/status", + json={"uid": "abc123"}, content_type="application/json") + session = Session( + client=self.client, email="test@example.com", + stretchpwd=b"\x00" * 32, uid="abc123", token="1234", + ) + session.check_session_status() + authz = responses.calls[0].request.headers["Authorization"] + self.assertRegex(authz, r"^Bearer fxs_[0-9a-f]{64}$") + + @responses.activate + def test_password_forgot_token_call_site_sends_fxpf_bearer(self): + responses.add(responses.GET, self.server_url + "/password/forgot/status", + json={}, content_type="application/json") + self.client.get_reset_code_status("1234") + authz = responses.calls[0].request.headers["Authorization"] + self.assertRegex(authz, r"^Bearer fxpf_[0-9a-f]{64}$") + + # helpers def verify_account(acct, client): def wait_for_email(m): diff --git a/fxa/tests/test_oauth.py b/fxa/tests/test_oauth.py index 99445dc..34b70e2 100644 --- a/fxa/tests/test_oauth.py +++ b/fxa/tests/test_oauth.py @@ -306,6 +306,9 @@ def test_authorize_code_with_default_arguments(self): "client_id": self.client.client_id, "state": AnyStringValue(), }) + # The sessionToken is sent as a prefixed Bearer token, not Hawk-signed. + authz = responses.calls[0].request.headers["Authorization"] + self.assertRegex(authz, r"^Bearer fxs_[0-9a-f]{64}$") @responses.activate def test_authorize_code_with_explicit_scope(self): diff --git a/fxa/tests/test_utils.py b/fxa/tests/test_utils.py new file mode 100644 index 0000000..9ec9f77 --- /dev/null +++ b/fxa/tests/test_utils.py @@ -0,0 +1,104 @@ +# 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/. +import warnings + +from fxa.tests.utils import unittest +from fxa._utils import FxATokenBearerAuth, HawkTokenAuth, TOKEN_PREFIXES +from fxa.errors import TrustError + + +# Test vectors pinned against the HKDF derivation, shared with the +# fxa-auth-client `test/bearer.ts` vectors so the on-the-wire header format +# stays in lockstep with the auth-server parser. EXPECTED_IDS pins the derived +# id for every token kind so a regression that derived ids with the wrong HKDF +# namespace (e.g. using "sessionToken" for all kinds) would be caught. +SESSION_TOKEN = ( + "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" +) +EXPECTED_IDS = { + "sessionToken": "c0a29dcf46174973da1378696e4c82ae10f723cf4f4d9f75e39f4ae3851595ab", + "keyFetchToken": "70db599cec9c040b10c790418f93fe77711fdea352a59e9b02d2336136d39f68", + "accountResetToken": "920fd5fc6cb03cabd2e6854c92d2976112e7b08728825acad1b7227874201f1f", + "passwordForgotToken": "f108185451329f7c94aa569f9efa09c8aeaef71029d341c83839e6820b870b88", + "passwordChangeToken": "9469deecfe3182a9573c7516650522e0d3032c467e909b64dd0d93f4d1253bde", +} + + +class Request: + def __init__(self, url="https://api.example.com/v1/session/status"): + self.method = "GET" + self.body = "" + self.url = url + self.headers = {"Content-Type": "application/json"} + + +class TestFxATokenBearerAuth(unittest.TestCase): + + def test_prefix_map_matches_server_side(self): + self.assertEqual(TOKEN_PREFIXES["sessionToken"], "fxs") + self.assertEqual(TOKEN_PREFIXES["keyFetchToken"], "fxk") + self.assertEqual(TOKEN_PREFIXES["accountResetToken"], "fxar") + self.assertEqual(TOKEN_PREFIXES["passwordForgotToken"], "fxpf") + self.assertEqual(TOKEN_PREFIXES["passwordChangeToken"], "fxpc") + + def test_emits_prefixed_bearer_header_for_session_token(self): + auth = FxATokenBearerAuth(SESSION_TOKEN, "sessionToken") + req = auth(Request()) + self.assertEqual( + req.headers["Authorization"], + f"Bearer fxs_{EXPECTED_IDS['sessionToken']}", + ) + + def test_derives_kind_specific_id_and_prefix_for_each_kind(self): + for kind, prefix in TOKEN_PREFIXES.items(): + auth = FxATokenBearerAuth(SESSION_TOKEN, kind) + header = auth(Request()).headers["Authorization"] + # Full header pinned per kind, so a wrong-namespace derivation fails. + self.assertEqual(header, f"Bearer {prefix}_{EXPECTED_IDS[kind]}") + + def test_does_not_sign_or_mutate_other_headers(self): + auth = FxATokenBearerAuth(SESSION_TOKEN, "sessionToken") + req = auth(Request()) + # Bearer is stateless: no Hawk-style Host/payload-hash munging. + self.assertNotIn("Host", req.headers) + + def test_unknown_token_kind_raises(self): + with self.assertRaises(ValueError): + FxATokenBearerAuth(SESSION_TOKEN, "notARealKind") + + def test_retains_bundle_key_for_key_fetch_unbundling(self): + # fetch_keys relies on the derived bundle_key to unbundle account/keys. + auth = FxATokenBearerAuth(SESSION_TOKEN, "keyFetchToken") + self.assertTrue(hasattr(auth, "bundle")) + self.assertTrue(hasattr(auth, "unbundle")) + self.assertEqual(len(auth.bundle_key), 32) + + def test_raises_when_token_sent_over_plaintext_http(self): + auth = FxATokenBearerAuth(SESSION_TOKEN, "sessionToken") + with self.assertRaises(TrustError): + auth(Request(url="http://accounts.example.com/v1/session/status")) + + def test_no_error_over_https_or_loopback(self): + auth = FxATokenBearerAuth(SESSION_TOKEN, "sessionToken") + # https and loopback hosts must not raise, so local http dev works. + auth(Request(url="https://accounts.example.com/v1/session/status")) + auth(Request(url="http://localhost:9000/v1/session/status")) + auth(Request(url="http://127.0.0.1:9000/v1/session/status")) + auth(Request(url="http://0.0.0.0:9000/v1/session/status")) + + +class TestHawkTokenAuthAlias(unittest.TestCase): + + def test_is_a_subclass_of_the_bearer_auth(self): + self.assertTrue(issubclass(HawkTokenAuth, FxATokenBearerAuth)) + + def test_emits_deprecation_warning_but_still_works(self): + with self.assertWarns(DeprecationWarning): + auth = HawkTokenAuth(SESSION_TOKEN, "sessionToken") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + header = auth(Request()).headers["Authorization"] + self.assertEqual( + header, f"Bearer fxs_{EXPECTED_IDS['sessionToken']}" + ) diff --git a/pyproject.toml b/pyproject.toml index ab792a9..7e9ed13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ classifiers = [ dynamic = [ "version" ] dependencies = [ "cryptography", - "hawkauthlib", "pyjwt", "requests>=2.4.2", ]