# Mobile Auth Replay

> 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: `abedegno/mobile-auth-replay` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add abedegno/mobile-auth-replay`
- Raw SKILL.md: https://api.skillmd.com/api/skills/abedegno/mobile-auth-replay/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: abedegno (https://skillmd.com/u/abedegno)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/abedegno/mobile-auth-replay

---


# Mobile Auth Contract Replay

After [`android-mitm-setup`](../android-mitm-setup/SKILL.md) 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

```python
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

```python
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 Forbidden` with 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.

```python
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_cffi` with `impersonate="chrome"`.
- Backend / API gateway (Azure APIM, AWS API Gateway, your own backend): vanilla `requests` or `urllib` usually 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:

```python
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`):

```html
<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`:

```python
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):

```python
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

1. **Capture the auth flow** with [`android-mitm-setup`](../android-mitm-setup/SKILL.md). Note every parameter to `/authorize`, every header on `/oauth/token`, the redirect URI scheme.
2. **Encode the constants** as Python literals in your helper. Use the exact values; don't simplify.
3. **Build the helper**: PKCE → `/authorize` redirect → `/callback` parse → token exchange → token storage.
4. **Use `curl_cffi` impersonate="chrome"`** for the auth host. Get this right the first time.
5. **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.
6. **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`](references/pkce-and-oauth2-shape.md) — the OAuth2 + PKCE message flow in detail, including state/nonce handling.
- [`references/waf-tls-fingerprinting.md`](references/waf-tls-fingerprinting.md) — why JA3 matters, what `curl_cffi` does, when you need it.

## Pairs with

- [`android-mitm-setup`](../android-mitm-setup/SKILL.md) — capture the auth flow before you replay it.
- [`find-production-sourcemap`](../find-production-sourcemap/SKILL.md) — 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`](../closed-loop-live-demo/SKILL.md) — 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.

