Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,39 @@ flight fare JFK LHR --dep 2026-08-15 --pp --pp-only

### Setup

PointsPath requires a paid subscription (free tier is the browser extension only). Today the only login mode is to import tokens captured from a logged-in Chrome session:
PointsPath requires a paid subscription (free tier is the browser extension only). Three login modes:

**1. Headed browser login (default, recommended).** Opens a Playwright Chromium so you can sign in normally; the CLI captures the resulting session into `~/.config/flight-cli/pp.json`. Independent of any Chrome PP session you have open elsewhere — different server-side Supabase session, so the refresh chains never race.

```sh
# 1. Capture your Supabase tokens from the browser. The extension is on
# pointspath.com under cookies named `sb-hxjqzkcirzhjvtubefie-auth-token.0/.1`;
# reassemble and save as JSON like:
# {"access_token": "...", "refresh_token": "...", "user": {"email": "..."}}
# 2. Import into the local token store:
flight auth pp login --tokens-file ~/Downloads/pp_tokens.json
# One-time: download Chromium into Playwright's global cache
# (~/Library/Caches/ms-playwright on macOS). The browser install is
# disk-cached, not venv-resident.
uvx --from playwright playwright install chromium

# Then log in. `--with playwright` adds the Python package ephemerally
# for this one invocation — no need to mutate flight-cli's venv.
uv run --with playwright flight auth pp login
flight auth pp whoami # confirm
```

A browser-based `flight auth pp login` is planned but not yet shipped; once you have tokens cached, refresh is automatic for the lifetime of your refresh token.
If you'd rather make playwright a permanent venv resident (skip `--with` every time), there's an optional install extra: `uv pip install -e '.[browser-login]'`. Most users don't need this.

**2. `--from-chrome` (cookie import).** Reads Supabase cookies from your local Chrome profile via `rookiepy`. Quicker than headed login since you don't sign in again — but the CLI then *shares* Chrome's refresh-token chain. Supabase rotates refresh tokens single-use, so a refresh on one side will eventually invalidate the other. Use this when you don't mind re-importing periodically.

```sh
flight auth pp login --from-chrome
```

**3. `--tokens-file PATH` (JSON import).** Bring your own session JSON. Useful when you've captured tokens with another tool (CDP cookie sniff, browser DevTools, etc.).

```sh
flight auth pp login --tokens-file ~/Downloads/pp_tokens.json
# Expected file shape:
# {"access_token": "...", "refresh_token": "...", "user": {"email": "..."}}
```

Once tokens are saved, refresh is automatic for the lifetime of the refresh-token chain (~indefinite, modulo the rotation race in mode 2).

### How airline selection works

Expand All @@ -106,7 +126,7 @@ Pass `--pp-airlines United,Delta,...` to skip discovery and call only the named

### What it doesn't do

- Browser-based login (use `--tokens-file` for now)
- ~~Browser-based login~~ (now the default — see Setup above)
- `calendar --pp` (lowest-fare-calendar overlay) — fan-out is N days × M airlines; deserves its own design
- Match against airlines we don't yet support (the few in pricing-info but not enabled for your tier are silently skipped)

Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,24 @@ dependencies = [
"rich>=13",
"flights>=0.8", # fli — drives Google Flights for booking handoff
"fast-flights>=2.2", # tfs= URL encoder (Google Flights deep-link)
"rookiepy>=0.5", # reads Chrome cookies for `auth pp login --from-chrome`
]

[project.optional-dependencies]
# Heavier interactive-login path. Optional because the package is small
# but `playwright install chromium` is ~150MB — non-PP users shouldn't pay.
browser-login = ["playwright>=1.40"]

[dependency-groups]
dev = [
"basedpyright>=1.20",
"ruff>=0.6",
"pytest>=8.3",
"pytest-cov>=5",
"hypothesis>=6.100",
# Pulled in only for type-checking; `playwright install chromium` is NOT
# run automatically — the dev Python package itself is small.
"playwright>=1.40",
]

[project.scripts]
Expand Down
161 changes: 148 additions & 13 deletions src/flight_cli/pp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,72 @@ def get_valid_tokens() -> Tokens:

# ──────────────────────────── login helpers ────────────────────────────────

# Supabase splits the auth-token cookie across `sb-<ref>-auth-token.0` and
# `.1` to dodge the 4KB cookie limit. The reassembled value is either raw
# JSON or "base64-" + base64 of JSON, depending on gotrue-js version.
SUPABASE_PROJECT_REF = "hxjqzkcirzhjvtubefie"
SUPABASE_AUTH_COOKIE_PREFIX = f"sb-{SUPABASE_PROJECT_REF}-auth-token"


def _tokens_from_supabase_payload(payload: _JsonDict) -> Tokens:
"""Build Tokens from the JSON object Supabase stores in its session cookie
or returns from /auth/v1/token: {access_token, refresh_token, user, ...}.
Falls back to JWT exp claim if expires_at is absent."""
access: str = payload["access_token"]
parts = access.split(".")
if len(parts) != _JWT_PARTS:
raise PPAuthError("access_token is not a JWT (expected 3 dot-separated parts)")
claims_payload = parts[1] + "=" * (-len(parts[1]) % 4)
claims: _JsonDict = json.loads(base64.urlsafe_b64decode(claims_payload))
user: _JsonDict = payload.get("user") or {}
expires_at = int(payload.get("expires_at") or claims.get("exp") or 0)
return Tokens(
access_token=access,
refresh_token=payload.get("refresh_token") or "",
expires_at=expires_at,
user_email=user.get("email") or claims.get("email"),
)


def tokens_from_supabase_cookies(cookies: list[dict[str, Any]]) -> Tokens:
"""Reassemble Supabase auth tokens from a list of cookie dicts.

Each dict needs `name` and `value` keys (the shape produced by rookiepy
and Playwright's BrowserContext.cookies()). Filters to `sb-<ref>-auth-
token[.N]` cookies, concatenates by index, decodes optional `base64-`
prefix, parses the JSON, and returns Tokens.
"""
by_idx: dict[int, str] = {}
for c in cookies:
name = c.get("name", "")
if not name.startswith(SUPABASE_AUTH_COOKIE_PREFIX):
continue
if "verifier" in name: # PKCE-flow verifier cookie; not the session
continue
if name == SUPABASE_AUTH_COOKIE_PREFIX:
by_idx[0] = c.get("value", "")
else:
suffix = name.removeprefix(SUPABASE_AUTH_COOKIE_PREFIX + ".")
try:
by_idx[int(suffix)] = c.get("value", "")
except ValueError:
continue
if not by_idx:
raise PPAuthError(
f"No {SUPABASE_AUTH_COOKIE_PREFIX}.* cookies found "
"(is the browser logged into PointsPath?)",
)
joined = "".join(by_idx[i] for i in sorted(by_idx))
if joined.startswith("base64-"):
decoded = base64.b64decode(joined.removeprefix("base64-") + "==", validate=False)
raw_json = decoded.decode("utf-8")
else:
raw_json = joined
payload: _JsonDict = json.loads(raw_json)
t = _tokens_from_supabase_payload(payload)
save_tokens(t)
return t


def import_from_tokens_file(path: Path) -> Tokens:
"""Import tokens from a JSON file (e.g., one captured via CDP cookie sniffing).
Expand All @@ -190,18 +256,87 @@ def import_from_tokens_file(path: Path) -> Tokens:
{access_token, refresh_token, supabase_url?, user?}
"""
raw: _JsonDict = json.loads(Path(path).read_text())
access: str = raw["access_token"]
parts = access.split(".")
if len(parts) != _JWT_PARTS:
raise PPAuthError("access_token is not a JWT (expected 3 dot-separated parts)")
payload = parts[1] + "=" * (-len(parts[1]) % 4)
claims: _JsonDict = json.loads(base64.urlsafe_b64decode(payload))
user: _JsonDict = raw.get("user") or {}
t = Tokens(
access_token=access,
refresh_token=raw.get("refresh_token") or "",
expires_at=int(claims.get("exp") or 0),
user_email=user.get("email") or claims.get("email"),
)
t = _tokens_from_supabase_payload(raw)
save_tokens(t)
return t


# Pointspath login URL — the homepage redirects unauthenticated users here.
_PP_HOME_URL = "https://pointspath.com/"

# Convenience login mode default: leave the browser open long enough for the
# user to click through email confirmation / 2FA if needed.
_DEFAULT_BROWSER_LOGIN_TIMEOUT_SECS = 300


def login_from_chrome() -> Tokens:
"""Import a PointsPath session from a local Chrome profile via rookiepy.

Convenience path: piggybacks on whatever Chrome session you already have
open. Inherits Chrome's refresh chain, so a CLI refresh here can race
against Chrome's gotrue-js. Prefer the headed Playwright path
(`login_via_browser`) for an independent session.
"""
try:
import rookiepy # noqa: PLC0415 # pyright: ignore[reportMissingTypeStubs]
except ImportError as e: # pragma: no cover — install-time concern
raise PPAuthError(
"rookiepy isn't installed (used for --from-chrome). "
"Install with: uv pip install rookiepy",
) from e

cookies: list[_JsonDict] = rookiepy.chrome(domains=["pointspath.com"]) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
return tokens_from_supabase_cookies(cookies)


def login_via_browser(
*,
timeout_secs: int = _DEFAULT_BROWSER_LOGIN_TIMEOUT_SECS,
poll_interval_secs: float = 1.0,
) -> Tokens:
"""Open a headed Playwright Chromium for the user to log into PointsPath.

Independent of any user-facing Chrome session — this is the recommended
primary path. Polls for the Supabase auth-token cookie; returns as soon
as it appears, or raises PPAuthError on timeout / browser close.
"""
try:
from playwright.sync_api import Error as PlaywrightError # noqa: PLC0415
from playwright.sync_api import sync_playwright # noqa: PLC0415
except ImportError as e: # pragma: no cover — install-time concern
raise PPAuthError(
"playwright isn't installed (needed for headed browser login). "
"Quickest path: rerun this command via "
"`uv run --with playwright flight auth pp login` "
"(plus a one-time `uvx --from playwright playwright install chromium`). "
"Alternatives: --from-chrome or --tokens-file PATH.",
) from e

import time # noqa: PLC0415

with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
try:
context = browser.new_context()
page = context.new_page()
page.goto(_PP_HOME_URL)

deadline = time.time() + timeout_secs
while time.time() < deadline:
try:
raw_cookies = context.cookies([_PP_HOME_URL])
except PlaywrightError as e:
raise PPAuthError(f"Browser closed before login completed: {e}") from e
cookies: list[_JsonDict] = [dict(c) for c in raw_cookies]
try:
return tokens_from_supabase_cookies(cookies)
except PPAuthError:
# Cookies aren't ready yet; user is still authenticating.
pass
time.sleep(poll_interval_secs)
raise PPAuthError(
f"Timed out after {timeout_secs}s waiting for PointsPath login. "
"Re-run `flight auth pp login` and complete sign-in before the timeout.",
)
finally:
browser.close()
71 changes: 53 additions & 18 deletions src/flight_cli/pp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
from .auth import (
TOKENS_PATH,
PPAuthError,
Tokens,
clear_tokens,
get_valid_tokens,
import_from_tokens_file,
load_tokens,
login_from_chrome,
login_via_browser,
)
from .client import (
DEFAULT_AIRLINES,
Expand Down Expand Up @@ -80,35 +83,67 @@ def pp_login(
typer.Option(
"--tokens-file",
"-f",
help="Import tokens from a JSON file (e.g. one captured via CDP cookie sniff).",
help="Import tokens from a JSON file (e.g. previously captured Supabase session).",
),
] = None,
from_chrome: Annotated[
bool,
typer.Option(
"--from-chrome",
help=(
"Read tokens from a local Chrome profile via rookiepy. "
"Inherits Chrome's session (and its rotation chain — Chrome and "
"the CLI may race on refresh). Convenience path; prefer the "
"default headed browser login for a clean, independent session."
),
),
] = False,
) -> None:
"""Save tokens to ~/.config/flight-cli/pp.json.

Today only `--tokens-file` is wired. Browser-based login is planned but
not yet implemented; for now, capture tokens from your authenticated Chrome
session and pass the JSON file in.
"""Authenticate with PointsPath. Three modes (mutually exclusive).

Default (no flag): open a headed Playwright Chromium so you can sign
in normally. Captures the resulting Supabase session into
~/.config/flight-cli/pp.json. Independent of any user-facing Chrome
PP session. Needs playwright at runtime (ephemeral or installed): the
README PP Setup section covers `uv run --with playwright` + the
one-time `uvx --from playwright playwright install chromium`.

--from-chrome: import the session from your local Chrome profile via
cookies. Quicker, but the CLI then shares Chrome's refresh-token
chain (Supabase rotates single-use, so a refresh on one side will
eventually invalidate the other).

--tokens-file PATH: import from a pre-captured JSON file. Expected
shape: {access_token, refresh_token, user.email}.
"""
if tokens_file is None:
err.print("[yellow]Browser-based login isn't implemented yet.[/]")
err.print(
"Workaround: capture your Supabase tokens from your authenticated "
"Chrome session and pass them in:\n"
" flight-cli auth pp login --tokens-file /path/to/pp_tokens.json\n"
"Expected file shape: "
'{"access_token": "...", "refresh_token": "...", "user": {"email": "..."}}'
)
modes = [bool(tokens_file), from_chrome]
if sum(modes) > 1:
err.print("[red]Pick at most one of --tokens-file or --from-chrome.[/]")
raise typer.Exit(2)

try:
t = import_from_tokens_file(tokens_file)
t: Tokens
if tokens_file is not None:
t = import_from_tokens_file(tokens_file)
source = f"{tokens_file}"
elif from_chrome:
t = login_from_chrome()
source = "local Chrome cookies"
else:
console.print(
"[dim]Opening a browser to PointsPath. Sign in normally; "
"the CLI will capture your session and close the browser.[/]"
)
t = login_via_browser()
source = "headed browser login"
except (PPAuthError, OSError, json.JSONDecodeError, KeyError) as e:
err.print(f"[red]Login failed: {e}[/]")
err.print(f"[red]Login failed ({type(e).__name__}): {e}[/]")
raise typer.Exit(1) from e

when = datetime.fromtimestamp(t.expires_at).isoformat() if t.expires_at else "?"
console.print(
f"[green]Saved[/] tokens for [bold]{t.user_email or '?'}[/] "
f"to {TOKENS_PATH}\n access_token expires: {when}"
f"to {TOKENS_PATH}\n source: {source}\n access_token expires: {when}"
)


Expand Down
Loading
Loading