Skip to content

Instantly share code, notes, and snippets.

@captn3m0
Created May 24, 2026 15:38
Show Gist options
  • Select an option

  • Save captn3m0/04a99f51c49652f46a6126604e47071b to your computer and use it in GitHub Desktop.

Select an option

Save captn3m0/04a99f51c49652f46a6126604e47071b to your computer and use it in GitHub Desktop.
Firefox OAuth Script
#!/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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment