Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 78 additions & 80 deletions fxa/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <hex>`) 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")
Expand Down Expand Up @@ -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

"""
Expand All @@ -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.

Expand Down Expand Up @@ -216,38 +228,26 @@ 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.
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
Expand Down Expand Up @@ -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:
Expand All @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this error? Seems like a no go to not send these of TLS.

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 <prefix>_<id>``, where ``<prefix>``
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):
Expand All @@ -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.

Expand All @@ -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}'")
18 changes: 9 additions & 9 deletions fxa/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from fxa.errors import ClientError
from fxa._utils import (
APIClient,
HawkTokenAuth,
FxATokenBearerAuth,
exactly_one_of,
hexstr
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -351,20 +351,20 @@ 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):
body = {
"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):
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions fxa/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading