A simple Python script that authenticates to a Firefox Account, even with 2FA. Stores credentials as creds.json, that can be used for next steps.
Licensed under The Unlicense
| #!/usr/bin/env python3 | |
| # /// script | |
| # requires-python = ">=3.11" | |
| # dependencies = [ | |
| # "cryptography==48.0.0", | |
| # ] | |
| # /// | |
| """ | |
| Minimal Firefox Accounts OAuth PKCE + scoped-keys flow. | |
| Mirrors services/fxaccounts/FxAccountsOAuth.sys.mjs (beginOAuthFlow, | |
| completeOAuthFlow) and services/crypto/modules/jwcrypto.sys.mjs | |
| (decryptJWE) -- generates an ephemeral P-256 ECDH keypair, sends the | |
| public key as `keys_jwk`, then decrypts the `keys_jwe` ECDH-ES+A256GCM | |
| JWE returned by the token endpoint to recover the per-scope key bundle. | |
| Writes the access token, refresh token, and scoped keys to creds.json | |
| in the current directory. The token-server exchange (creds -> Hawk | |
| sync credentials) is a separate step. | |
| Run: | |
| uv run fxa_oauth.py | |
| """ | |
| import base64 | |
| import hashlib | |
| import json | |
| import secrets | |
| import struct | |
| import sys | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| import webbrowser | |
| from cryptography.hazmat.primitives.asymmetric import ec | |
| from cryptography.hazmat.primitives.ciphers.aead import AESGCM | |
| # OAuth client + redirect from | |
| # third_party/application-services/examples/fxa-client/src/main.rs:166 | |
| # Production FxA enforces a per-scope redirect_uri allowlist for any scope | |
| # with scoped keys (see scopedKeysValidation in the fxa-config blob served | |
| # by accounts.firefox.com). For `oldsync`, this client_id + the matching | |
| # /oauth/success/<client_id> page are on the allowlist; combinations that | |
| # aren't get rejected with "Invalid redirect parameter". | |
| # We can't use Firefox Desktop's client_id (5882386c6d801776) here: it has no | |
| # HTTP redirect_uri registered -- desktop relies on context=oauth_webchannel_v1 | |
| # and intercepts the OAuth result via an in-process WebChannel postMessage, | |
| # which a standalone script can't receive. | |
| CLIENT_ID = "a2270f727f45f648" | |
| REDIRECT_URI = f"https://accounts.firefox.com/oauth/success/{CLIENT_ID}" | |
| # All scopes accepted by FxAccountsOAuth.sys.mjs#VALID_SCOPES. Desktop only | |
| # requests [profile, oldsync] during sign-in (FxAccountsConfig.sys.mjs:365); | |
| # profile:write is included here for completeness. | |
| SCOPES = [ | |
| "profile", | |
| "profile:write", | |
| "https://identity.mozilla.com/apps/oldsync", | |
| ] | |
| AUTHORIZATION_URL = "https://accounts.firefox.com/authorization" | |
| TOKEN_URL = "https://api.accounts.firefox.com/v1/oauth/token" | |
| CREDS_PATH = "creds.json" | |
| def b64url(data: bytes) -> str: | |
| return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") | |
| def b64url_decode(data: str) -> bytes: | |
| return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) | |
| def generate_ecdh_keypair() -> tuple[ec.EllipticCurvePrivateKey, dict]: | |
| private_key = ec.generate_private_key(ec.SECP256R1()) | |
| numbers = private_key.public_key().public_numbers() | |
| public_jwk = { | |
| "kty": "EC", | |
| "crv": "P-256", | |
| "x": b64url(numbers.x.to_bytes(32, "big")), | |
| "y": b64url(numbers.y.to_bytes(32, "big")), | |
| } | |
| return private_key, public_jwk | |
| def build_authorization_url() -> tuple[str, str, str, ec.EllipticCurvePrivateKey]: | |
| code_verifier = b64url(secrets.token_bytes(32)) | |
| code_challenge = b64url(hashlib.sha256(code_verifier.encode("ascii")).digest()) | |
| state = b64url(secrets.token_bytes(16)) | |
| private_key, public_jwk = generate_ecdh_keypair() | |
| keys_jwk = b64url(json.dumps(public_jwk, separators=(",", ":")).encode("utf-8")) | |
| params = { | |
| "client_id": CLIENT_ID, | |
| "scope": " ".join(SCOPES), | |
| "action": "email", | |
| "response_type": "code", | |
| "access_type": "offline", | |
| "redirect_uri": REDIRECT_URI, | |
| "state": state, | |
| "code_challenge": code_challenge, | |
| "code_challenge_method": "S256", | |
| "keys_jwk": keys_jwk, | |
| } | |
| return AUTHORIZATION_URL + "?" + urllib.parse.urlencode(params), state, code_verifier, private_key | |
| def exchange_code(code: str, code_verifier: str) -> dict: | |
| body = json.dumps({ | |
| "client_id": CLIENT_ID, | |
| "code": code, | |
| "code_verifier": code_verifier, | |
| }).encode("utf-8") | |
| req = urllib.request.Request( | |
| TOKEN_URL, | |
| data=body, | |
| headers={"Content-Type": "application/json"}, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(req) as resp: | |
| return json.load(resp) | |
| except urllib.error.HTTPError as exc: | |
| sys.exit(f"token exchange failed: HTTP {exc.code}\n{exc.read().decode('utf-8', 'replace')}") | |
| def concat_kdf_sha256(z: bytes, keydatalen_bits: int, other_info: bytes) -> bytes: | |
| """NIST SP 800-56A Concat KDF with SHA-256.""" | |
| keydatalen_bytes = keydatalen_bits // 8 | |
| derived = bytearray() | |
| counter = 1 | |
| while len(derived) < keydatalen_bytes: | |
| derived += hashlib.sha256(struct.pack(">I", counter) + z + other_info).digest() | |
| counter += 1 | |
| return bytes(derived[:keydatalen_bytes]) | |
| def decrypt_keys_jwe(jwe_compact: str, private_key: ec.EllipticCurvePrivateKey) -> bytes: | |
| """Decrypt an ECDH-ES + A256GCM compact JWE (RFC 7518 section 4.6).""" | |
| parts = jwe_compact.split(".") | |
| if len(parts) != 5: | |
| raise ValueError(f"expected 5-part compact JWE, got {len(parts)}") | |
| header_b64, encrypted_key_b64, iv_b64, ct_b64, tag_b64 = parts | |
| header = json.loads(b64url_decode(header_b64)) | |
| if header.get("alg") != "ECDH-ES": | |
| raise ValueError(f"unsupported alg: {header.get('alg')}") | |
| if header.get("enc") != "A256GCM": | |
| raise ValueError(f"unsupported enc: {header.get('enc')}") | |
| if encrypted_key_b64: | |
| raise ValueError("ECDH-ES direct expects empty encrypted_key") | |
| epk = header["epk"] | |
| if epk.get("kty") != "EC" or epk.get("crv") != "P-256": | |
| raise ValueError(f"unsupported epk: {epk}") | |
| peer_pub = ec.EllipticCurvePublicNumbers( | |
| int.from_bytes(b64url_decode(epk["x"]), "big"), | |
| int.from_bytes(b64url_decode(epk["y"]), "big"), | |
| ec.SECP256R1(), | |
| ).public_key() | |
| z = private_key.exchange(ec.ECDH(), peer_pub) | |
| enc = header["enc"].encode("ascii") | |
| apu = b64url_decode(header["apu"]) if header.get("apu") else b"" | |
| apv = b64url_decode(header["apv"]) if header.get("apv") else b"" | |
| keydatalen_bits = 256 | |
| other_info = ( | |
| struct.pack(">I", len(enc)) + enc | |
| + struct.pack(">I", len(apu)) + apu | |
| + struct.pack(">I", len(apv)) + apv | |
| + struct.pack(">I", keydatalen_bits) | |
| ) | |
| cek = concat_kdf_sha256(z, keydatalen_bits, other_info) | |
| iv = b64url_decode(iv_b64) | |
| ciphertext = b64url_decode(ct_b64) | |
| tag = b64url_decode(tag_b64) | |
| aad = header_b64.encode("ascii") | |
| return AESGCM(cek).decrypt(iv, ciphertext + tag, aad) | |
| def main() -> None: | |
| url, state, code_verifier, private_key = build_authorization_url() | |
| print("Open this URL, sign in, then paste the FULL redirect URL you") | |
| print("end up on (it will contain ?code=...&state=...):\n") | |
| print(url) | |
| print() | |
| try: | |
| webbrowser.open(url) | |
| except Exception: | |
| pass | |
| redirect = input("Redirect URL: ").strip() | |
| fields = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(redirect).query)) | |
| if fields.get("state") != state: | |
| sys.exit(f"state mismatch -- expected {state!r}, got {fields.get('state')!r}") | |
| code = fields.get("code") | |
| if not code: | |
| sys.exit(f"no code in redirect URL; query was: {fields}") | |
| tokens = exchange_code(code, code_verifier) | |
| creds = { | |
| "access_token": tokens.get("access_token"), | |
| "refresh_token": tokens.get("refresh_token"), | |
| "expires_in": tokens.get("expires_in"), | |
| "scope": tokens.get("scope"), | |
| "token_type": tokens.get("token_type"), | |
| "scoped_keys": None, | |
| } | |
| keys_jwe = tokens.get("keys_jwe") | |
| if keys_jwe: | |
| plaintext = decrypt_keys_jwe(keys_jwe, private_key) | |
| creds["scoped_keys"] = json.loads(plaintext.decode("utf-8")) | |
| else: | |
| print("WARNING: no keys_jwe in token response (scoped keys missing)", file=sys.stderr) | |
| with open(CREDS_PATH, "w") as f: | |
| json.dump(creds, f, indent=2) | |
| print(f"Wrote {CREDS_PATH}") | |
| if __name__ == "__main__": | |
| main() |
A simple Python script that authenticates to a Firefox Account, even with 2FA. Stores credentials as creds.json, that can be used for next steps.
Licensed under The Unlicense