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)
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, })
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)
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, })
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, })
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)
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)