Mobile auth replay
Companion skills for mobile/web reverse engineering — pairs with android-reverse-engineering-skill
npx -y skills add abedegno/reverse-engineering-companion --skill mobile-auth-replayAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Reproduce a mobile app's OAuth2/PKCE auth flow from a desktop tool (Flask helper, Python script). Includes the WAF / TLS-fingerprint gotcha where Python's default urllib gets blocked by Akamai Bot Manager and you have to use curl_cffi to impersonate Chrome's TLS handshake. Use after you've observed the auth flow in mitm and want to drive the backend without an Android device in the loop.
SKILL.md
12.5 KB, ~3.0k tokens by cl100k_base, as published. Nobody here has run it
Mobile Auth Contract Replay
After android-mitm-setup has revealed the auth flow — typically Auth0 or a similar OAuth2 IdP with PKCE — the next step is usually "drive this same contract from my laptop, without the phone in the loop". This skill is the recipe: a small Flask helper, the static constants from mitmproxy, PKCE in 8 lines, and the WAF gotcha that bites you once and only once.
When this skill applies
- You've seen the app's auth handshake in mitmproxy (
/authorize,/oauth/token,/userinfo, etc.). - You want to obtain a real access token to make backend calls without going through the app.
- The IdP follows the OAuth2 + PKCE pattern (Auth0, Okta, Cognito, Keycloak, any modern auth-as-a-service).
- You have legal grounds — your own account, your own app, or an authorised research engagement.
Architecture
A tiny Flask app running on 127.0.0.1:5000. Routes:
| Route | Purpose |
|---|---|
/ | Landing page with "Sign in" / "Sign up" links |
/start | Generates PKCE verifier+challenge, sets state, redirects to IdP /authorize |
/signin | Same as /start but without screen_hint=signup |
/callback | Receives the ?code&state echo, exchanges for tokens, stores |
/callback-form | Manual paste of the redirect URL (for when the IdP redirects to an Android intent that the desktop browser shows as 404 — see below) |
/logout | Federated logout — IMPORTANT: include ?client_id= |
/<resource> | One per backend endpoint you want to surface |
Session state:
verifier_by_state: dict[str, str]— in-memory only, popped on consumption- Tokens in a 0600 file at
~/.<helper-name>/session.json
The static constants
These come from mitmproxy capture and are encoded as Python literals. Capture them once:
| Constant | Where to find it |
|---|---|
AUTH_DOMAIN | The Host: header on /authorize calls (e.g. tenant.auth0.com) |
CLIENT_ID | Query param on /authorize |
REDIRECT_URI | Query param on /authorize — typically a custom-scheme intent URI |
SCOPE | Query param on /authorize (e.g. openid profile email offline_access) |
AUDIENCE | Query param on /authorize (the API the access token authorises) |
RESPONSE_TYPE | Almost always code |
CHALLENGE_METHOD | Almost always S256 |
If the app also sends static headers (bundle ID, app version, device platform) on backend calls — capture those too. They're usually a soft CSRF gate that the backend insists on but doesn't otherwise validate.
PKCE in 8 lines
import base64, hashlib, secrets
from urllib.parse import urlencode
def build_authorize_url(domain, client_id, redirect_uri, scope, audience,
screen_hint=None):
verifier = secrets.token_urlsafe(64)[:128]
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
state = secrets.token_urlsafe(24)
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"audience": audience,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"nonce": secrets.token_urlsafe(16),
}
if screen_hint:
params["screen_hint"] = screen_hint # 'signup' to force registration
return (f"https://{domain}/authorize?{urlencode(params)}", state, verifier)
Store {state: verifier} in the helper. When the IdP calls back with ?code=X&state=Y, look up the verifier for that state and pass it to the token-exchange call.
Token exchange
def exchange_code(domain, client_id, redirect_uri, code, verifier):
body = {
"grant_type": "authorization_code",
"client_id": client_id,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
}
# See "TLS-fingerprint WAF gotcha" below — use curl_cffi, not urllib
from curl_cffi import requests as cffi
resp = cffi.post(
f"https://{domain}/oauth/token",
json=body,
headers={"Content-Type": "application/json"},
impersonate="chrome",
timeout=15,
)
resp.raise_for_status()
return resp.json() # {access_token, refresh_token, id_token, expires_in, token_type}
TLS-fingerprint WAF gotcha — the one that bites you
Some auth hosts are fronted by Akamai Bot Manager (or Cloudflare Bot Management, or other JA3-fingerprint filters). They inspect the TLS ClientHello and reject handshakes that look like programmatic clients — Python's default urllib / requests / httpx get flagged.
Symptoms:
- First few requests work, then suddenly
403 Forbiddenwith an HTML body ("Access Denied. You don't have permission to access..."). - Same code worked yesterday, fails today.
- Works from your phone, fails from your laptop.
Fix: use curl_cffi with impersonate="chrome". It calls libcurl with the BoringSSL build that real Chrome ships, producing an indistinguishable JA3 fingerprint.
from curl_cffi import requests as cffi
# Use cffi instead of requests/urllib for the auth host
resp = cffi.get(url, headers=headers, impersonate="chrome")
resp = cffi.post(url, json=body, headers=headers, impersonate="chrome")
Split your HTTP client by destination:
- Auth host (and any other WAF-fronted host):
curl_cffiwithimpersonate="chrome". - Backend / API gateway (Azure APIM, AWS API Gateway, your own backend): vanilla
requestsorurllibusually fine. These are typically WAF'd differently or not at all for legitimate API consumers.
If you don't want a curl_cffi dependency, subprocess to system curl works too — same TLS stack, same fingerprint. It's slower and uglier but has no extra Python dep:
import subprocess, json
def curl_post(url, body, headers):
args = ["curl", "-sS", "--fail-with-body", "-X", "POST", url]
for k, v in headers.items():
args.extend(["-H", f"{k}: {v}"])
args.extend(["-d", json.dumps(body)])
return subprocess.check_output(args, text=True)
This was the original workaround discovered in field use; curl_cffi is the contemporary equivalent.
The desktop-browser 404 is success
The mobile app's redirect_uri is typically an Android intent URI (e.g. com.example.app://callback) or a Universal Link (https://app.example.com/callback). When the IdP redirects the desktop browser to that URI:
- Intent URI: browser shows a "Cannot find app" error or "Page can't be reached".
- Universal Link with no app installed: HTTP 404 from
https://app.example.com/callback.
Both are the success state for our purposes. The URL in the browser's address bar contains ?code=X&state=Y — that's what you came for. The helper should provide a paste field (/callback-form):
<form method="post" action="/callback-submit">
<label>Paste the full URL from your browser's address bar after the redirect:</label>
<input name="redirect_url" type="text" size="80">
<button type="submit">Submit</button>
</form>
Server-side, parse with urllib.parse:
from urllib.parse import urlparse, parse_qs
def parse_callback_input(raw):
parsed = urlparse(raw.strip())
qs = parse_qs(parsed.query)
return qs.get("code", [None])[0], qs.get("state", [None])[0]
Refresh tokens
Most IdPs rotate refresh tokens on every refresh (/oauth/token with grant_type=refresh_token). Persist the new one if returned; fall back to the previous if the response omits a new one (some IdPs only rotate occasionally):
def refresh_access(domain, client_id, refresh_token):
resp = cffi.post(
f"https://{domain}/oauth/token",
json={"grant_type": "refresh_token",
"client_id": client_id,
"refresh_token": refresh_token},
headers={"Content-Type": "application/json"},
impersonate="chrome",
)
resp.raise_for_status()
data = resp.json()
return {
"access_token": data["access_token"],
"refresh_token": data.get("refresh_token", refresh_token), # fallback
"expires_at": time.time() + data["expires_in"] - 30, # 30s safety
}
Federated logout — the ?client_id= requirement
Auth0-shaped logout endpoints quietly no-op if you forget ?client_id=. The full call is:
GET https://{AUTH_DOMAIN}/v2/logout?client_id={CLIENT_ID}&returnTo={URL_ENCODED_RETURN}
Without client_id, the IdP returns 200 but the session cookie isn't cleared. Symptom: you "sign out" then "sign in" and end up back in the same account instead of seeing the sign-in form.
Project layout
helper-name/
├── app.py # Flask routes
├── auth.py # PKCE, /authorize URL builder, token exchange/refresh
├── http.py # cffi vs requests split, retry logic
├── persistence.py # session.json read/write at 0600
├── test_auth.py # PKCE construction, URL building, parser
├── test_app.py # Flask test client
├── smoke_test.py # Manual end-to-end
├── requirements.txt # flask, curl_cffi, pytest
└── README.md
Workflow
- Capture the auth flow with
android-mitm-setup. Note every parameter to/authorize, every header on/oauth/token, the redirect URI scheme. - Encode the constants as Python literals in your helper. Use the exact values; don't simplify.
- Build the helper: PKCE →
/authorizeredirect →/callbackparse → token exchange → token storage. - Use
curl_cffiimpersonate="chrome"` for the auth host. Get this right the first time. - Test the round-trip with the smoke test. Sign in as your own account; obtain an access token; call ONE backend endpoint with it; confirm the response is sensible.
- Iterate. Add helper routes for each backend resource you need to script.
Common errors and what they mean
| Error | Likely cause |
|---|---|
403 Access Denied (HTML body, not JSON) | Akamai/Cloudflare JA3 filter. Use curl_cffi. |
invalid_grant from /oauth/token | Code reused, expired (default 60s), or wrong code_verifier. |
invalid_request with code_challenge_method | You sent plain; modern Auth0 tenants reject it. Use S256. |
Loop on /authorize — never reaches /callback | state mismatch. The IdP doesn't return state if scope is missing. Add openid to scope. |
Token works for /userinfo but not the backend | audience was missing or wrong. The token's aud claim must match the backend's expected audience. |
Refresh token "absent" — /oauth/token returns no refresh_token | Scope didn't include offline_access. Add it; re-sign-in. |
| Federated logout doesn't clear session | Missing ?client_id=. |
References
references/pkce-and-oauth2-shape.md— the OAuth2 + PKCE message flow in detail, including state/nonce handling.references/waf-tls-fingerprinting.md— why JA3 matters, whatcurl_cffidoes, when you need it.
Pairs with
android-mitm-setup— capture the auth flow before you replay it.find-production-sourcemap— if the app is a webview with a JS bundle, the auth constants are usually right there in the unminified source.closed-loop-live-demo— once you have access tokens, the live-demo skill drives backend calls end-to-end.
Scope reminder
This skill obtains access tokens for your own account to call APIs your account is authorised for without the official app in the loop. It does not authenticate as other users, does not bypass MFA, and does not defeat account-takeover protections.
If during this work you discover something material (e.g. weak authorisation checks on the backend), follow responsible disclosure: contact the operator privately, give them a window, don't publish the exploit path before they've had a chance to respond.