Example #1
0
class Client(object):
    """Client for talking to the Firefox Accounts Profile server"""
    def __init__(self, server_url=DEFAULT_SERVER_URL):
        if not isinstance(server_url, string_types):
            self.apiclient = server_url
        else:
            server_url = server_url.rstrip('/')
            if not server_url.endswith(VERSION_SUFFIXES):
                server_url += VERSION_SUFFIXES[0]
            self.apiclient = APIClient(server_url)

    @property
    def server_url(self):
        return self.apiclient.server_url

    def get_profile(self, token):
        """Get all profile data for the user associated with this token."""
        url = '/profile'
        resp = self.apiclient.get(url, auth=BearerTokenAuth(token))

        for field in ("uid", "email", "avatar"):
            if field not in resp:
                resp[field] = None

        return resp

    def get_email(self, token):
        """Get the email address for the user associated with this token."""
        url = '/email'
        resp = self.apiclient.get(url, auth=BearerTokenAuth(token))
        try:
            return resp["email"]
        except KeyError:
            error_msg = "email missing in profile response"
            raise OutOfProtocolError(error_msg)

    def get_uid(self, token):
        """Get the account uid for the user associated with this token."""
        url = '/uid'
        resp = self.apiclient.get(url, auth=BearerTokenAuth(token))
        try:
            return resp["uid"]
        except KeyError:
            error_msg = "uid missing in profile response"
            raise OutOfProtocolError(error_msg)

    def get_avatar_url(self, token):
        """Get the url for a user's avatar picture."""
        url = '/avatar'
        resp = self.apiclient.get(url, auth=BearerTokenAuth(token))
        try:
            return resp["url"]
        except KeyError:
            error_msg = "url missing in profile response"
            raise OutOfProtocolError(error_msg)
Example #2
0
class Client(object):
    """Client for talking to the Firefox Accounts OAuth server"""

    def __init__(self, client_id=None, client_secret=None, server_url=None,
                 cache=True, ttl=DEFAULT_CACHE_EXPIRY):
        self.client_id = client_id
        self.client_secret = client_secret
        if server_url is None:
            server_url = DEFAULT_SERVER_URL
        server_url = server_url.rstrip('/')
        if not server_url.endswith(VERSION_SUFFIXES):
            server_url += VERSION_SUFFIXES[0]
        if isinstance(server_url, string_types):
            self.apiclient = APIClient(server_url)
        else:
            self.apiclient = server_url

        self.cache = cache
        if self.cache is True:
            self.cache = MemoryCache(ttl)

    @property
    def server_url(self):
        return self.apiclient.server_url

    def _get_identity_assertion(self, sessionOrAssertion, client_id=None):
        if isinstance(sessionOrAssertion, string_types):
            return sessionOrAssertion
        if client_id is None:
            client_id = self.client_id
        return sessionOrAssertion.get_identity_assertion(
            audience=self.server_url,
            service=client_id
        )

    def get_client_metadata(self, client_id=None):
        """Get the OAuth client metadata for a given client_id."""
        if client_id is None:
            client_id = self.client_id
        return self.apiclient.get("/client/{0}".format(client_id))

    def get_redirect_url(self, state="", redirect_uri=None, scope=None,
                         action=None, email=None, client_id=None,
                         code_challenge=None, code_challenge_method=None,
                         access_type=None, keys_jwk=None):
        """Get the URL to redirect to to initiate the oauth flow."""
        if client_id is None:
            client_id = self.client_id
        params = {
            "client_id": client_id,
            "state": state,
        }
        if redirect_uri is not None:
            params["redirect_uri"] = redirect_uri
        if scope is not None:
            params["scope"] = scope
        if action is not None:
            params["action"] = action
        if email is not None:
            params["email"] = email
        if code_challenge is not None:
            params["code_challenge"] = code_challenge
        if code_challenge_method is not None:
            params["code_challenge_method"] = code_challenge_method
        if keys_jwk is not None:
            params["keys_jwk"] = keys_jwk
        if access_type is not None:
            params["access_type"] = access_type
        query_str = urlencode(params)
        authorization_url = urlparse(self.server_url + "/authorization")
        return urlunparse(authorization_url._replace(query=query_str))

    def trade_code(self, code, client_id=None, client_secret=None,
                   code_verifier=None, ttl=None):
        """Trade the authentication code for a longer lived token.

        :param code: the authentication code from the oauth redirect dance.
        :param client_id: the string generated during FxA client registration.
        :param client_secret: the related secret string.
        :param code_verifier: optional PKCE code verifier.
        :param ttl: optional ttl in seconds, the access token is valid for.
        :returns: a dict with user id and authorized scopes for this token.
        """
        if client_id is None:
            client_id = self.client_id
        if client_secret is None:
            client_secret = self.client_secret
        url = '/token'
        body = {
            'code': code,
            'client_id': client_id,
        }
        if client_secret is not None:
            body["client_secret"] = client_secret
        if code_verifier is not None:
            body["code_verifier"] = code_verifier
        if ttl is not None:
            body["ttl"] = ttl
        resp = self.apiclient.post(url, body)

        if 'access_token' not in resp:
            error_msg = 'access_token missing in OAuth response'
            raise OutOfProtocolError(error_msg)

        return resp

    def authorize_code(self, sessionOrAssertion, scope=None, client_id=None,
                       code_challenge=None, code_challenge_method=None):
        """Trade an identity assertion for an oauth authorization code.

        This method takes an identity assertion for a user and uses it to
        generate an oauth authentication code.  This code can in turn be
        traded for a full-blown oauth token.

        Note that the authorize_token() method does the same thing but skips
        the intermediate step of using a short-lived code.  You should prefer
        that method if the registered OAuth client_id has `canGrant` permission.

        :param sessionOrAssertion: an identity assertion for the target user,
                                   or an auth session to use to make one.
        :param scope: optional scope to be provided by the token.
        :param client_id: the string generated during FxA client registration.
        :param code_challenge: optional PKCE code challenge.
        """
        if client_id is None:
            client_id = self.client_id
        assertion = self._get_identity_assertion(sessionOrAssertion, client_id)
        url = "/authorization"

        # Although not relevant in this scenario from a security perspective,
        # we generate a random 'state' and check the returned redirect URL
        # for completeness.
        state = base64.urlsafe_b64encode(os.urandom(24)).decode('utf-8')

        body = {
            "client_id": client_id,
            "assertion": assertion,
            "state": state
        }
        if scope is not None:
            body["scope"] = scope
        if code_challenge is not None:
            body["code_challenge"] = code_challenge
            body["code_challenge_method"] = code_challenge_method or "S256"
        resp = self.apiclient.post(url, body)

        if "redirect" not in resp:
            error_msg = "redirect missing in OAuth response"
            raise OutOfProtocolError(error_msg)

        # This flow is designed for web-based redirects.
        # In order to get the code we must parse it from the redirect url.
        query_params = parse_qs(urlparse(resp["redirect"]).query)

        # Check that the 'state' parameter is present and the same we provided
        if "state" not in query_params:
            error_msg = "state missing in OAuth response"
            raise OutOfProtocolError(error_msg)

        if state != query_params["state"][0]:
            error_msg = "state mismatch in OAuth response (wanted: '{}', got: '{}')".format(
                state, query_params["state"][0])
            raise OutOfProtocolError(error_msg)

        try:
            return query_params["code"][0]
        except (KeyError, IndexError, ValueError):
            error_msg = "code missing in OAuth redirect url"
            raise OutOfProtocolError(error_msg)

    def authorize_token(self, sessionOrAssertion, scope=None, client_id=None):
        """Trade an identity assertion for an oauth token.

        This method takes an identity assertion for a user and uses it to
        generate an oauth token. The client_id must have implicit grant
        privileges.

        :param sessionOrAssertion: an identity assertion for the target user,
                                   or an auth session to use to make one.
        :param scope: optional scope to be provided by the token.
        :param client_id: the string generated during FxA client registration.
        """
        if client_id is None:
            client_id = self.client_id
        assertion = self._get_identity_assertion(sessionOrAssertion, client_id)
        url = "/authorization"
        body = {
            "client_id": client_id,
            "assertion": assertion,
            "response_type": "token",
            "state": "x",  # state is required, but we don't use it
        }
        if scope is not None:
            body["scope"] = scope
        resp = self.apiclient.post(url, body)

        if 'access_token' not in resp:
            error_msg = 'access_token missing in OAuth response'
            raise OutOfProtocolError(error_msg)

        return resp['access_token']

    def _verify_jwt_token(self, key, token):
        pubkey = jwt.algorithms.RSAAlgorithm.from_jwk(key)
        # The FxA OAuth ecosystem currently doesn't make good use of aud, and
        # instead relies on scope for restricting which services can accept
        # which tokens. So there's no value in checking it here, and in fact if
        # we check it here, it fails because the right audience isn't being
        # requested.
        decoded = jwt.decode(
            token, pubkey, algorithms=['RS256'], options={'verify_aud': False}
        )
        if jwt.get_unverified_header(token).get('typ') != 'at+jwt':
            raise TrustError
        return {
            'user': decoded.get('sub'),
            'client_id': decoded.get('client_id'),
            'scope': decoded.get('scope'),
            'generation': decoded.get('fxa-generation'),
            'profile_changed_at': decoded.get('fxa-profileChangedAt')
        }

    def verify_token(self, token, scope=None):
        """Verify an OAuth token, and retrieve user id and scopes.

        :param token: the string to verify.
        :param scope: optional scope expected to be provided for this token.
        :returns: a dict with user id and authorized scopes for this token.
        :raises fxa.errors.ClientError: if the provided token is invalid.
        :raises fxa.errors.TrustError: if the token scopes do not match.
        """
        key = 'fxa.oauth.verify_token:%s:%s' % (
            get_hmac(token, TOKEN_HMAC_SECRET), scope)
        if self.cache is not None:
            resp = self.cache.get(key)
        else:
            resp = None

        if resp is None:
            # We want to fetch
            # https://oauth.accounts.firefox.com/.well-known/openid-configuration
            # and then get the jwks_uri key to get the /jwks url, but we'll
            # just hardcodes it like this for now; our /jwks url will never
            # change.
            # https://github.com/mozilla/PyFxA/issues/81 is an issue about
            # getting the jwks url out of the openid-configuration.

            keys = self.apiclient.get('/jwks').get('keys', [])
            resp = None
            try:
                for k in keys:
                    try:
                        resp = self._verify_jwt_token(json.dumps(k), token)
                        break
                    except jwt.exceptions.InvalidSignatureError:
                        # It's only worth trying other keys in the event of
                        # `InvalidSignature`; if it was invalid for other reasons
                        # (e.g. it's expired) then using a different key won't
                        # help.
                        continue
                else:
                    # It's a well-formed JWT, but not signed by any of the advertized keys.
                    # We can immediately surface this as an error.
                    if len(keys) > 0:
                        raise TrustError({"error": "invalid signature"})
            except (jwt.exceptions.DecodeError, jwt.exceptions.InvalidKeyError):
                # It wasn't a JWT at all, or it was signed using a key type we
                # don't support. Fall back to asking the FxA server to verify.
                pass
            except jwt.exceptions.PyJWTError as e:
                # Any other JWT-related failure (e.g. expired token) can
                # immediately surface as a trust error.
                raise TrustError({"error": str(e)})
            if resp is None:
                resp = self.apiclient.post('/verify', {'token': token})
            missing_attrs = ", ".join([
                k for k in ('user', 'scope', 'client_id')
                if resp.get(k) is None
            ])
            if missing_attrs:
                error_msg = '{0} missing in OAuth response'.format(
                    missing_attrs)
                raise OutOfProtocolError(error_msg)

            if scope is not None:
                authorized_scope = resp['scope']
                if not scope_matches(authorized_scope, scope):
                    raise ScopeMismatchError(authorized_scope, scope)

            if self.cache is not None:
                self.cache.set(key, json.dumps(resp))
        else:
            resp = json.loads(resp)

        return resp

    def destroy_token(self, token):
        """Destroy an OAuth token

        :param token: the token to destroy.
        :raises fxa.errors.ClientError: if the provided token is invalid.
        """
        url = '/destroy'
        body = {
            'token': token
        }
        self.apiclient.post(url, body)

    def generate_pkce_challenge(self):
        """Ramdomly generate parameters for a PKCE challenge.

        This method returns a two-tuple (challenge, response) where the first
        item contains request parameters for a PKCE challenge, and the second
        item contains the corresponding parameters for a verification.
        """
        code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip("=")
        raw_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
        code_challenge = base64.urlsafe_b64encode(raw_challenge).decode('utf-8').rstrip("=")
        return ({
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
        }, {
            "code_verifier": code_verifier,
        })
Example #3
0
class Client(object):
    """Client for talking to the Firefox Accounts auth server."""
    def __init__(self, server_url=None):
        if server_url is None:
            server_url = DEFAULT_SERVER_URL
        if not isinstance(server_url, string_types):
            self.apiclient = server_url
            self.server_url = self.apiclient.server_url
        else:
            server_url = server_url.rstrip("/")
            if not server_url.endswith(VERSION_SUFFIXES):
                server_url += VERSION_SUFFIXES[0]
            self.server_url = server_url
            self.apiclient = APIClient(server_url)

    def create_account(self, email, password=None, stretchpwd=None, **kwds):
        keys = kwds.pop("keys", False)
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        EXTRA_KEYS = ("service", "redirectTo", "resume", "preVerifyToken",
                      "preVerified")
        for extra in kwds:
            if extra in EXTRA_KEYS:
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/account/create"
        if keys:
            url += "?keys=true"
        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=False,
            auth_timestamp=resp["authAt"],
        )

    def login(self, email, password=None, stretchpwd=None, keys=False):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        url = "/account/login"
        if keys:
            url += "?keys=true"
        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=resp["verified"],
            auth_timestamp=resp["authAt"],
        )

    def _get_stretched_password(self, email, password=None, stretchpwd=None):
        if password is not None:
            if stretchpwd is not None:
                msg = "must specify exactly one of 'password' or 'stretchpwd'"
                raise ValueError(msg)
            stretchpwd = quick_stretch_password(email, password)
        elif stretchpwd is None:
            raise ValueError("must specify one of 'password' or 'stretchpwd'")
        return stretchpwd

    def get_account_status(self, uid):
        return self.apiclient.get("/account/status?uid=" + uid)

    def destroy_account(self, email, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        url = "/account/destroy"
        self.apiclient.post(url, body)

    def get_random_bytes(self):
        # XXX TODO: sanity-check the schema of the returned response
        return unhexlify(self.apiclient.post("/get_random_bytes")["data"])

    def fetch_keys(self, key_fetch_token, stretchpwd):
        url = "/account/keys"
        auth = HawkTokenAuth(key_fetch_token, "keyFetchToken", self.apiclient)
        resp = self.apiclient.get(url, auth=auth)
        bundle = unhexlify(resp["bundle"])
        keys = auth.unbundle("account/keys", bundle)
        unwrap_key = derive_key(stretchpwd, "unwrapBkey")
        return (keys[:32], xor(keys[32:], unwrap_key))

    def change_password(self,
                        email,
                        oldpwd=None,
                        newpwd=None,
                        oldstretchpwd=None,
                        newstretchpwd=None):
        oldstretchpwd = self._get_stretched_password(email, oldpwd,
                                                     oldstretchpwd)
        newstretchpwd = self._get_stretched_password(email, newpwd,
                                                     newstretchpwd)
        resp = self.start_password_change(email, oldstretchpwd)
        keys = self.fetch_keys(resp["keyFetchToken"], oldstretchpwd)
        token = resp["passwordChangeToken"]
        new_wrapkb = xor(keys[1], derive_key(newstretchpwd, "unwrapBkey"))
        self.finish_password_change(token, newstretchpwd, new_wrapkb)

    def start_password_change(self, email, stretchpwd):
        body = {
            "email": email,
            "oldAuthPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        return self.apiclient.post("/password/change/start", body)

    def finish_password_change(self, token, stretchpwd, wrapkb):
        body = {
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
            "wrapKb": hexstr(wrapkb),
        }
        auth = HawkTokenAuth(token, "passwordChangeToken", self.apiclient)
        self.apiclient.post("/password/change/finish", body, auth=auth)

    def reset_account(self, email, token, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        url = "/account/reset"
        auth = HawkTokenAuth(token, "accountResetToken", self.apiclient)
        self.apiclient.post(url, body, auth=auth)

    def send_reset_code(self, email, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/password/forgot/send_code"
        resp = self.apiclient.post(url, body)
        return PasswordForgotToken(
            self,
            email,
            resp["passwordForgotToken"],
            resp["ttl"],
            resp["codeLength"],
            resp["tries"],
        )

    def resend_reset_code(self, email, token, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/password/forgot/resend_code"
        auth = HawkTokenAuth(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)
        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)
        return self.apiclient.get(url, auth=auth)

    def verify_email_code(self, uid, code):
        body = {
            "uid": uid,
            "code": code,
        }
        url = "/recovery_email/verify_code"
        return self.apiclient.post(url, body)
Example #4
0
class Client(object):
    """Client for talking to the Firefox Accounts OAuth server"""

    def __init__(self, client_id=None, client_secret=None, server_url=None,
                 cache=True, ttl=DEFAULT_CACHE_EXPIRY):
        self.client_id = client_id
        self.client_secret = client_secret
        if server_url is None:
            server_url = DEFAULT_SERVER_URL
        server_url = server_url.rstrip('/')
        if not server_url.endswith(VERSION_SUFFIXES):
            server_url += VERSION_SUFFIXES[0]
        if isinstance(server_url, string_types):
            self.apiclient = APIClient(server_url)
        else:
            self.apiclient = server_url

        self.cache = cache
        if self.cache is True:
            self.cache = MemoryCache(ttl)

    @property
    def server_url(self):
        return self.apiclient.server_url

    def _get_identity_assertion(self, sessionOrAssertion, client_id=None):
        if isinstance(sessionOrAssertion, string_types):
            return sessionOrAssertion
        if client_id is None:
            client_id = self.client_id
        return sessionOrAssertion.get_identity_assertion(
            audience=self.server_url,
            service=client_id
        )

    def get_client_metadata(self, client_id=None):
        """Get the OAuth client metadata for a given client_id."""
        if client_id is None:
            client_id = self.client_id
        return self.apiclient.get("/client/{0}".format(client_id))

    def get_redirect_url(self, state="", redirect_uri=None, scope=None,
                         action=None, email=None, client_id=None,
                         code_challenge=None, code_challenge_method=None,
                         access_type=None, keys_jwk=None):
        """Get the URL to redirect to to initiate the oauth flow."""
        if client_id is None:
            client_id = self.client_id
        params = {
            "client_id": client_id,
            "state": state,
        }
        if redirect_uri is not None:
            params["redirect_uri"] = redirect_uri
        if scope is not None:
            params["scope"] = scope
        if action is not None:
            params["action"] = action
        if email is not None:
            params["email"] = email
        if code_challenge is not None:
            params["code_challenge"] = code_challenge
        if code_challenge_method is not None:
            params["code_challenge_method"] = code_challenge_method
        if keys_jwk is not None:
            params["keys_jwk"] = keys_jwk
        if access_type is not None:
            params["access_type"] = access_type
        query_str = urlencode(params)
        authorization_url = urlparse(self.server_url + "/authorization")
        return urlunparse(authorization_url._replace(query=query_str))

    def trade_code(self, code, client_id=None, client_secret=None,
                   code_verifier=None):
        """Trade the authentication code for a longer lived token.

        :param code: the authentication code from the oauth redirect dance.
        :param client_id: the string generated during FxA client registration.
        :param client_secret: the related secret string.
        :param code_verifier: optional PKCE code verifier.
        :returns: a dict with user id and authorized scopes for this token.
        """
        if client_id is None:
            client_id = self.client_id
        if client_secret is None:
            client_secret = self.client_secret
        url = '/token'
        body = {
            'code': code,
            'client_id': client_id,
        }
        if client_secret is not None:
            body["client_secret"] = client_secret
        if code_verifier is not None:
            body["code_verifier"] = code_verifier
        resp = self.apiclient.post(url, body)

        if 'access_token' not in resp:
            error_msg = 'access_token missing in OAuth response'
            raise OutOfProtocolError(error_msg)

        return resp

    def authorize_code(self, sessionOrAssertion, scope=None, client_id=None,
                       code_challenge=None, code_challenge_method=None):
        """Trade an identity assertion for an oauth authorization code.

        This method takes an identity assertion for a user and uses it to
        generate an oauth authentication code.  This code can in turn be
        traded for a full-blown oauth token.

        Note that the authorize_token() method does the same thing but skips
        the intermediate step of using a short-lived code.  You should prefer
        that method if the registered OAuth client_id has `canGrant` permission.

        :param sessionOrAssertion: an identity assertion for the target user,
                                   or an auth session to use to make one.
        :param scope: optional scope to be provided by the token.
        :param client_id: the string generated during FxA client registration.
        :param code_challenge: optional PKCE code challenge.
        """
        if client_id is None:
            client_id = self.client_id
        assertion = self._get_identity_assertion(sessionOrAssertion, client_id)
        url = "/authorization"
        body = {
            "client_id": client_id,
            "assertion": assertion,
            "state": "x",  # state is required, but we don't use it
        }
        if scope is not None:
            body["scope"] = scope
        if code_challenge is not None:
            body["code_challenge"] = code_challenge
            body["code_challenge_method"] = code_challenge_method or "S256"
        resp = self.apiclient.post(url, body)

        if "redirect" not in resp:
            error_msg = "redirect missing in OAuth response"
            raise OutOfProtocolError(error_msg)

        # This flow is designed for web-based redirects.
        # In order to get the code we must parse it from the redirect url.
        query_params = parse_qs(urlparse(resp["redirect"]).query)
        try:
            return query_params["code"][0]
        except (KeyError, IndexError, ValueError):
            error_msg = "code missing in OAuth redirect url"
            raise OutOfProtocolError(error_msg)

    def authorize_token(self, sessionOrAssertion, scope=None, client_id=None):
        """Trade an identity assertion for an oauth token.

        This method takes an identity assertion for a user and uses it to
        generate an oauth token. The client_id must have implicit grant
        privileges.

        :param sessionOrAssertion: an identity assertion for the target user,
                                   or an auth session to use to make one.
        :param scope: optional scope to be provided by the token.
        :param client_id: the string generated during FxA client registration.
        """
        if client_id is None:
            client_id = self.client_id
        assertion = self._get_identity_assertion(sessionOrAssertion, client_id)
        url = "/authorization"
        body = {
            "client_id": client_id,
            "assertion": assertion,
            "response_type": "token",
            "state": "x",  # state is required, but we don't use it
        }
        if scope is not None:
            body["scope"] = scope
        resp = self.apiclient.post(url, body)

        if 'access_token' not in resp:
            error_msg = 'access_token missing in OAuth response'
            raise OutOfProtocolError(error_msg)

        return resp['access_token']

    def verify_token(self, token, scope=None):
        """Verify an OAuth token, and retrieve user id and scopes.

        :param token: the string to verify.
        :param scope: optional scope expected to be provided for this token.
        :returns: a dict with user id and authorized scopes for this token.
        :raises fxa.errors.ClientError: if the provided token is invalid.
        :raises fxa.errors.TrustError: if the token scopes do not match.
        """
        key = 'fxa.oauth.verify_token:%s:%s' % (
            get_hmac(token, TOKEN_HMAC_SECRET), scope)
        if self.cache is not None:
            resp = self.cache.get(key)
        else:
            resp = None

        if resp is None:
            url = '/verify'
            body = {
                'token': token
            }
            resp = self.apiclient.post(url, body)

            missing_attrs = ", ".join([
                k for k in ('user', 'scope', 'client_id') if k not in resp
            ])
            if missing_attrs:
                error_msg = '{0} missing in OAuth response'.format(
                    missing_attrs)
                raise OutOfProtocolError(error_msg)

            if scope is not None:
                authorized_scope = resp['scope']
                if not scope_matches(authorized_scope, scope):
                    raise ScopeMismatchError(authorized_scope, scope)

            if self.cache is not None:
                self.cache.set(key, json.dumps(resp))
        else:
            resp = json.loads(resp)

        return resp

    def destroy_token(self, token):
        """Destroy an OAuth token

        :param token: the token to destroy.
        :raises fxa.errors.ClientError: if the provided token is invalid.
        """
        url = '/destroy'
        body = {
            'token': token
        }
        self.apiclient.post(url, body)

    def generate_pkce_challenge(self):
        """Ramdomly generate parameters for a PKCE challenge.

        This method returns a two-tuple (challenge, response) where the first
        item contains request parameters for a PKCE challenge, and the second
        item contains the corresponding parameters for a verification.
        """
        code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip("=")
        raw_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
        code_challenge = base64.urlsafe_b64encode(raw_challenge).decode('utf-8').rstrip("=")
        return ({
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
        }, {
            "code_verifier": code_verifier,
        })
Example #5
0
class Client(object):
    """Client for talking to the Firefox Accounts OAuth server"""
    def __init__(self,
                 client_id=None,
                 client_secret=None,
                 server_url=None,
                 cache=True,
                 ttl=DEFAULT_CACHE_EXPIRY):
        self.client_id = client_id
        self.client_secret = client_secret
        if server_url is None:
            server_url = DEFAULT_SERVER_URL
        server_url = server_url.rstrip('/')
        if not server_url.endswith(VERSION_SUFFIXES):
            server_url += VERSION_SUFFIXES[0]
        if isinstance(server_url, string_types):
            self.apiclient = APIClient(server_url)
        else:
            self.apiclient = server_url

        self.cache = cache
        if self.cache is True:
            self.cache = MemoryCache(ttl)

    @property
    def server_url(self):
        return self.apiclient.server_url

    def _get_identity_assertion(self, sessionOrAssertion, client_id=None):
        if isinstance(sessionOrAssertion, string_types):
            return sessionOrAssertion
        if client_id is None:
            client_id = self.client_id
        return sessionOrAssertion.get_identity_assertion(
            audience=self.server_url, service=client_id)

    def get_client_metadata(self, client_id=None):
        """Get the OAuth client metadata for a given client_id."""
        if client_id is None:
            client_id = self.client_id
        return self.apiclient.get("/client/{0}".format(client_id))

    def get_redirect_url(self,
                         state="",
                         redirect_uri=None,
                         scope=None,
                         action=None,
                         email=None,
                         client_id=None,
                         code_challenge=None,
                         code_challenge_method=None,
                         access_type=None,
                         keys_jwk=None):
        """Get the URL to redirect to to initiate the oauth flow."""
        if client_id is None:
            client_id = self.client_id
        params = {
            "client_id": client_id,
            "state": state,
        }
        if redirect_uri is not None:
            params["redirect_uri"] = redirect_uri
        if scope is not None:
            params["scope"] = scope
        if action is not None:
            params["action"] = action
        if email is not None:
            params["email"] = email
        if code_challenge is not None:
            params["code_challenge"] = code_challenge
        if code_challenge_method is not None:
            params["code_challenge_method"] = code_challenge_method
        if keys_jwk is not None:
            params["keys_jwk"] = keys_jwk
        if access_type is not None:
            params["access_type"] = access_type
        query_str = urlencode(params)
        authorization_url = urlparse(self.server_url + "/authorization")
        return urlunparse(authorization_url._replace(query=query_str))

    def trade_code(self,
                   code,
                   client_id=None,
                   client_secret=None,
                   code_verifier=None):
        """Trade the authentication code for a longer lived token.

        :param code: the authentication code from the oauth redirect dance.
        :param client_id: the string generated during FxA client registration.
        :param client_secret: the related secret string.
        :param code_verifier: optional PKCE code verifier.
        :returns: a dict with user id and authorized scopes for this token.
        """
        if client_id is None:
            client_id = self.client_id
        if client_secret is None:
            client_secret = self.client_secret
        url = '/token'
        body = {
            'code': code,
            'client_id': client_id,
        }
        if client_secret is not None:
            body["client_secret"] = client_secret
        if code_verifier is not None:
            body["code_verifier"] = code_verifier
        resp = self.apiclient.post(url, body)

        if 'access_token' not in resp:
            error_msg = 'access_token missing in OAuth response'
            raise OutOfProtocolError(error_msg)

        return resp

    def authorize_code(self,
                       sessionOrAssertion,
                       scope=None,
                       client_id=None,
                       code_challenge=None,
                       code_challenge_method=None):
        """Trade an identity assertion for an oauth authorization code.

        This method takes an identity assertion for a user and uses it to
        generate an oauth authentication code.  This code can in turn be
        traded for a full-blown oauth token.

        Note that the authorize_token() method does the same thing but skips
        the intermediate step of using a short-lived code.  You should prefer
        that method if the registered OAuth client_id has `canGrant` permission.

        :param sessionOrAssertion: an identity assertion for the target user,
                                   or an auth session to use to make one.
        :param scope: optional scope to be provided by the token.
        :param client_id: the string generated during FxA client registration.
        :param code_challenge: optional PKCE code challenge.
        """
        if client_id is None:
            client_id = self.client_id
        assertion = self._get_identity_assertion(sessionOrAssertion, client_id)
        url = "/authorization"
        body = {
            "client_id": client_id,
            "assertion": assertion,
            "state": "x",  # state is required, but we don't use it
        }
        if scope is not None:
            body["scope"] = scope
        if code_challenge is not None:
            body["code_challenge"] = code_challenge
            body["code_challenge_method"] = code_challenge_method or "S256"
        resp = self.apiclient.post(url, body)

        if "redirect" not in resp:
            error_msg = "redirect missing in OAuth response"
            raise OutOfProtocolError(error_msg)

        # This flow is designed for web-based redirects.
        # In order to get the code we must parse it from the redirect url.
        query_params = parse_qs(urlparse(resp["redirect"]).query)
        try:
            return query_params["code"][0]
        except (KeyError, IndexError, ValueError):
            error_msg = "code missing in OAuth redirect url"
            raise OutOfProtocolError(error_msg)

    def authorize_token(self, sessionOrAssertion, scope=None, client_id=None):
        """Trade an identity assertion for an oauth token.

        This method takes an identity assertion for a user and uses it to
        generate an oauth token. The client_id must have implicit grant
        privileges.

        :param sessionOrAssertion: an identity assertion for the target user,
                                   or an auth session to use to make one.
        :param scope: optional scope to be provided by the token.
        :param client_id: the string generated during FxA client registration.
        """
        if client_id is None:
            client_id = self.client_id
        assertion = self._get_identity_assertion(sessionOrAssertion, client_id)
        url = "/authorization"
        body = {
            "client_id": client_id,
            "assertion": assertion,
            "response_type": "token",
            "state": "x",  # state is required, but we don't use it
        }
        if scope is not None:
            body["scope"] = scope
        resp = self.apiclient.post(url, body)

        if 'access_token' not in resp:
            error_msg = 'access_token missing in OAuth response'
            raise OutOfProtocolError(error_msg)

        return resp['access_token']

    def verify_token(self, token, scope=None):
        """Verify an OAuth token, and retrieve user id and scopes.

        :param token: the string to verify.
        :param scope: optional scope expected to be provided for this token.
        :returns: a dict with user id and authorized scopes for this token.
        :raises fxa.errors.ClientError: if the provided token is invalid.
        :raises fxa.errors.TrustError: if the token scopes do not match.
        """
        key = 'fxa.oauth.verify_token:%s:%s' % (get_hmac(
            token, TOKEN_HMAC_SECRET), scope)
        if self.cache is not None:
            resp = self.cache.get(key)
        else:
            resp = None

        if resp is None:
            url = '/verify'
            body = {'token': token}
            resp = self.apiclient.post(url, body)

            missing_attrs = ", ".join(
                [k for k in ('user', 'scope', 'client_id') if k not in resp])
            if missing_attrs:
                error_msg = '{0} missing in OAuth response'.format(
                    missing_attrs)
                raise OutOfProtocolError(error_msg)

            if scope is not None:
                authorized_scope = resp['scope']
                if not scope_matches(authorized_scope, scope):
                    raise ScopeMismatchError(authorized_scope, scope)

            if self.cache is not None:
                self.cache.set(key, json.dumps(resp))
        else:
            resp = json.loads(resp)

        return resp

    def destroy_token(self, token):
        """Destroy an OAuth token

        :param token: the token to destroy.
        :raises fxa.errors.ClientError: if the provided token is invalid.
        """
        url = '/destroy'
        body = {'token': token}
        self.apiclient.post(url, body)

    def generate_pkce_challenge(self):
        """Ramdomly generate parameters for a PKCE challenge.

        This method returns a two-tuple (challenge, response) where the first
        item contains request parameters for a PKCE challenge, and the second
        item contains the corresponding parameters for a verification.
        """
        code_verifier = base64.urlsafe_b64encode(
            os.urandom(32)).decode('utf-8').rstrip("=")
        raw_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest()
        code_challenge = base64.urlsafe_b64encode(raw_challenge).decode(
            'utf-8').rstrip("=")
        return ({
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
        }, {
            "code_verifier": code_verifier,
        })
Example #6
0
class Client(object):
    """Client for talking to the Firefox Accounts auth server."""

    def __init__(self, server_url=None):
        if server_url is None:
            server_url = DEFAULT_SERVER_URL
        if not isinstance(server_url, string_types):
            self.apiclient = server_url
            self.server_url = self.apiclient.server_url
        else:
            server_url = server_url.rstrip("/")
            if not server_url.endswith(VERSION_SUFFIXES):
                server_url += VERSION_SUFFIXES[0]
            self.server_url = server_url
            self.apiclient = APIClient(server_url)

    def create_account(self, email, password=None, stretchpwd=None, **kwds):
        keys = kwds.pop("keys", False)
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        EXTRA_KEYS = ("service", "redirectTo", "resume", "preVerifyToken",
                      "preVerified")
        for extra in kwds:
            if extra in EXTRA_KEYS:
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/account/create"
        if keys:
            url += "?keys=true"
        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=False,
            auth_timestamp=resp["authAt"],
        )

    def login(self, email, password=None, stretchpwd=None, keys=False, unblock_code=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        url = "/account/login"
        if keys:
            url += "?keys=true"

        if unblock_code:
            body["unblockCode"] = unblock_code

        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=resp["verified"],
            verificationMethod=resp.get("verificationMethod"),
            auth_timestamp=resp["authAt"],
        )

    def _get_stretched_password(self, email, password=None, stretchpwd=None):
        if password is not None:
            if stretchpwd is not None:
                msg = "must specify exactly one of 'password' or 'stretchpwd'"
                raise ValueError(msg)
            stretchpwd = quick_stretch_password(email, password)
        elif stretchpwd is None:
            raise ValueError("must specify one of 'password' or 'stretchpwd'")
        return stretchpwd

    def get_account_status(self, uid):
        return self.apiclient.get("/account/status?uid=" + uid)

    def destroy_account(self, email, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        url = "/account/destroy"
        self.apiclient.post(url, body)

    def get_random_bytes(self):
        # XXX TODO: sanity-check the schema of the returned response
        return unhexlify(self.apiclient.post("/get_random_bytes")["data"])

    def fetch_keys(self, key_fetch_token, stretchpwd):
        url = "/account/keys"
        auth = HawkTokenAuth(key_fetch_token, "keyFetchToken", self.apiclient)
        resp = self.apiclient.get(url, auth=auth)
        bundle = unhexlify(resp["bundle"])
        keys = auth.unbundle("account/keys", bundle)
        unwrap_key = derive_key(stretchpwd, "unwrapBkey")
        return (keys[:32], xor(keys[32:], unwrap_key))

    def change_password(self, email, oldpwd=None, newpwd=None,
                        oldstretchpwd=None, newstretchpwd=None):
        oldstretchpwd = self._get_stretched_password(email, oldpwd,
                                                     oldstretchpwd)
        newstretchpwd = self._get_stretched_password(email, newpwd,
                                                     newstretchpwd)
        resp = self.start_password_change(email, oldstretchpwd)
        keys = self.fetch_keys(resp["keyFetchToken"], oldstretchpwd)
        token = resp["passwordChangeToken"]
        new_wrapkb = xor(keys[1], derive_key(newstretchpwd, "unwrapBkey"))
        self.finish_password_change(token, newstretchpwd, new_wrapkb)

    def start_password_change(self, email, stretchpwd):
        body = {
            "email": email,
            "oldAuthPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        return self.apiclient.post("/password/change/start", body)

    def finish_password_change(self, token, stretchpwd, wrapkb):
        body = {
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
            "wrapKb": hexstr(wrapkb),
        }
        auth = HawkTokenAuth(token, "passwordChangeToken", self.apiclient)
        self.apiclient.post("/password/change/finish", body, auth=auth)

    def reset_account(self, email, token, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "authPW": hexstr(derive_key(stretchpwd, "authPW")),
        }
        url = "/account/reset"
        auth = HawkTokenAuth(token, "accountResetToken", self.apiclient)
        self.apiclient.post(url, body, auth=auth)

    def send_reset_code(self, email, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/password/forgot/send_code"
        resp = self.apiclient.post(url, body)
        return PasswordForgotToken(
            self, email,
            resp["passwordForgotToken"],
            resp["ttl"],
            resp["codeLength"],
            resp["tries"],
        )

    def resend_reset_code(self, email, token, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/password/forgot/resend_code"
        auth = HawkTokenAuth(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)
        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)
        return self.apiclient.get(url, auth=auth)

    def verify_email_code(self, uid, code):
        body = {
            "uid": uid,
            "code": code,
        }
        url = "/recovery_email/verify_code"
        return self.apiclient.post(url, body)

    def send_unblock_code(self, email, **kwds):
        body = {
            "email": email
        }

        url = "/account/login/send_unblock_code"
        return self.apiclient.post(url, body)

    def reject_unblock_code(self, uid, unblockCode):
        body = {
            "uid": uid,
            "unblockCode": unblockCode
        }

        url = "/account/login/reject_unblock_code"
        return self.apiclient.post(url, body)
Example #7
0
class Client(object):
    """Client for talking to the Firefox Accounts auth server."""

    def __init__(self, server_url=None):
        if server_url is None:
            server_url = DEFAULT_SERVER_URL
        if isinstance(server_url, basestring):
            self.server_url = server_url
            self.apiclient = APIClient(server_url)
        else:
            self.apiclient = server_url
            self.server_url = self.apiclient.server_url

    def create_account(self, email, password=None, stretchpwd=None, **kwds):
        keys = kwds.pop("keys", False)
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        EXTRA_KEYS = ("service", "redirectTo", "resume", "preVerifyToken",
                      "preVerified")
        for extra in kwds:
            if extra in EXTRA_KEYS:
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/v1/account/create"
        if keys:
            url += "?keys=true"
        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=False,
            auth_timestamp=resp["authAt"],
        )

    def login(self, email, password=None, stretchpwd=None, keys=False):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        url = "/v1/account/login"
        if keys:
            url += "?keys=true"
        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=resp["verified"],
            auth_timestamp=resp["authAt"],
        )

    def _get_stretched_password(self, email, password=None, stretchpwd=None):
        if password is not None:
            if stretchpwd is not None:
                msg = "must specify exactly one of 'password' or 'stretchpwd'"
                raise ValueError(msg)
            stretchpwd = quick_stretch_password(email, password)
        elif stretchpwd is None:
            raise ValueError("must specify one of 'password' or 'stretchpwd'")
        return stretchpwd

    def get_account_status(self, uid):
        return self.apiclient.get("/v1/account/status?uid=" + uid)

    def destroy_account(self, email, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        url = "/v1/account/destroy"
        self.apiclient.post(url, body)

    def get_random_bytes(self):
        # XXX TODO: sanity-check the schema of the returned response
        return unhexlify(self.apiclient.post("/v1/get_random_bytes")["data"])

    def reset_account(self, email, token, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        url = "/v1/account/reset"
        auth = HawkTokenAuth(token, "accountResetToken", self.apiclient)
        self.apiclient.post(url, body, auth=auth)

    def send_reset_code(self, email, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/v1/password/forgot/send_code"
        resp = self.apiclient.post(url, body)
        return PasswordForgotToken(
            self, email,
            resp["passwordForgotToken"],
            resp["ttl"],
            resp["codeLength"],
            resp["tries"],
        )

    def resend_reset_code(self, email, token, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/v1/password/forgot/resend_code"
        auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient)
        return self.apiclient.post(url, body, auth=auth)

    def verify_reset_code(self, token, code):
        body = {
            "code": code,
        }
        url = "/v1/password/forgot/verify_code"
        auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient)
        return self.apiclient.post(url, body, auth=auth)

    def get_reset_code_status(self, token):
        url = "/v1/password/forgot/status"
        auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient)
        return self.apiclient.get(url, auth=auth)
Example #8
0
class Client(object):
    """Client for talking to the Firefox Accounts auth server."""
    def __init__(self, server_url=None):
        if server_url is None:
            server_url = DEFAULT_SERVER_URL
        if isinstance(server_url, basestring):
            self.server_url = server_url
            self.apiclient = APIClient(server_url)
        else:
            self.apiclient = server_url
            self.server_url = self.apiclient.server_url

    def create_account(self, email, password=None, stretchpwd=None, **kwds):
        keys = kwds.pop("keys", False)
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        EXTRA_KEYS = ("service", "redirectTo", "resume", "preVerifyToken",
                      "preVerified")
        for extra in kwds:
            if extra in EXTRA_KEYS:
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/v1/account/create"
        if keys:
            url += "?keys=true"
        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=False,
            auth_timestamp=resp["authAt"],
        )

    def login(self, email, password=None, stretchpwd=None, keys=False):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        url = "/v1/account/login"
        if keys:
            url += "?keys=true"
        resp = self.apiclient.post(url, body)
        # XXX TODO: somehow sanity-check the schema on this endpoint
        return Session(
            client=self,
            email=email,
            stretchpwd=stretchpwd,
            uid=resp["uid"],
            token=resp["sessionToken"],
            key_fetch_token=resp.get("keyFetchToken"),
            verified=resp["verified"],
            auth_timestamp=resp["authAt"],
        )

    def _get_stretched_password(self, email, password=None, stretchpwd=None):
        if password is not None:
            if stretchpwd is not None:
                msg = "must specify exactly one of 'password' or 'stretchpwd'"
                raise ValueError(msg)
            stretchpwd = quick_stretch_password(email, password)
        elif stretchpwd is None:
            raise ValueError("must specify one of 'password' or 'stretchpwd'")
        return stretchpwd

    def get_account_status(self, uid):
        return self.apiclient.get("/v1/account/status?uid=" + uid)

    def destroy_account(self, email, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "email": email,
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        url = "/v1/account/destroy"
        self.apiclient.post(url, body)

    def get_random_bytes(self):
        # XXX TODO: sanity-check the schema of the returned response
        return unhexlify(self.apiclient.post("/v1/get_random_bytes")["data"])

    def reset_account(self, email, token, password=None, stretchpwd=None):
        stretchpwd = self._get_stretched_password(email, password, stretchpwd)
        body = {
            "authPW": hexlify(derive_key(stretchpwd, "authPW")),
        }
        url = "/v1/account/reset"
        auth = HawkTokenAuth(token, "accountResetToken", self.apiclient)
        self.apiclient.post(url, body, auth=auth)

    def send_reset_code(self, email, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/v1/password/forgot/send_code"
        resp = self.apiclient.post(url, body)
        return PasswordForgotToken(
            self,
            email,
            resp["passwordForgotToken"],
            resp["ttl"],
            resp["codeLength"],
            resp["tries"],
        )

    def resend_reset_code(self, email, token, **kwds):
        body = {
            "email": email,
        }
        for extra in kwds:
            if extra in ("service", "redirectTo", "resume"):
                body[extra] = kwds[extra]
            else:
                msg = "Unexpected keyword argument: {0}".format(extra)
                raise TypeError(msg)
        url = "/v1/password/forgot/resend_code"
        auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient)
        return self.apiclient.post(url, body, auth=auth)

    def verify_reset_code(self, token, code):
        body = {
            "code": code,
        }
        url = "/v1/password/forgot/verify_code"
        auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient)
        return self.apiclient.post(url, body, auth=auth)

    def get_reset_code_status(self, token):
        url = "/v1/password/forgot/status"
        auth = HawkTokenAuth(token, "passwordForgotToken", self.apiclient)
        return self.apiclient.get(url, auth=auth)