"""
bw_auth.py — "Sign in with BW" (Pattern B) drop-in for any app on this server.

This is the app-side half of the BW-Auth (id-auth) OAuth-with-PKCE contract.
Copy this file into your app, set four env vars, and call the functions below.
It is framework-agnostic and STDLIB-ONLY (no PyJWT, no requests) so it drops
cleanly into Flask, FastAPI, Django, a bare wsgiref app, or a background worker.

--------------------------------------------------------------------------------
THE FOUR ENV VARS (read lazily — importing this module never requires them)
--------------------------------------------------------------------------------
    BW_CLIENT_ID       your app's client_id (the --name slug you registered)
    BW_CLIENT_SECRET   the secret printed ONCE by app-client-register.
                       Keep it in the container env or a 600 .env. NEVER commit it.
    BW_AUTH            https://auth.bowden.works   (the id-auth login host)
    BW_APP_DOMAIN      https://<your-app-host>     (used to build the default
                       redirect_uri, https://<host>/auth/callback, and the
                       full-signout return URL)

--------------------------------------------------------------------------------
WHAT id-auth GUARANTEES (so your app doesn't have to)
--------------------------------------------------------------------------------
  * Only a REAL BW ACCOUNT reaches /app-authorize's success path. Guests
    (shared-password sessions) cannot mint an app code — so a successful token
    exchange PROVES the visitor holds a real account.
  * The authorization code is single-use, 60s TTL, bound to (client_id,
    redirect_uri), and PKCE-bound to your code_verifier.
  * The id_token is HMAC-signed with YOUR client_secret and audience-bound to
    YOUR client_id, so a token minted for another app can't be replayed at yours.
  * The redirect_uri MUST be on the allowlist you registered, or /app-authorize
    returns 400 — an attacker can't redirect the code to a host they control.

--------------------------------------------------------------------------------
THE id_token FORMAT (must match the gateway's signer byte-for-byte)
--------------------------------------------------------------------------------
    id_token = "<value>|<hmac_hex>"
      value    = base64url( json(claims, separators=(",",":")) ), '=' PADDING STRIPPED
      claims   = {"aud": client_id, "sub": username, "username", "email",
                  "first", "last", "iat": <int>, "exp": <int == iat + 300>}
      hmac_hex = HMAC_SHA256(key=client_secret_utf8, msg=value_utf8).hexdigest()

  verify_id_token() below recomputes the HMAC over `value` with your
  client_secret, constant-time-compares it, then re-pads and base64url-decodes
  `value`, json-loads the claims, and asserts aud == BW_CLIENT_ID and exp > now.
  The token is a ONE-SHOT bootstrap: verify it, pull the identity out, and throw
  it away. Never store it, never treat it as a bearer, never put it in a cookie.

--------------------------------------------------------------------------------
PUBLIC API (see each function's docstring for details)
--------------------------------------------------------------------------------
  new_pkce_pair()                       -> (code_verifier, code_challenge)
  build_authorize_url(verifier, ...)    -> (url, state)      low-level
  start_login(set_cookie, ...)          -> (url, state)      framework-helper
  exchange_code(code, code_verifier,    -> {"identity": {...}, "id_token": "..."}
                redirect_uri=None)
  verify_id_token(id_token)             -> claims dict (raises BWAuthError if bad)
  userinfo(username)                    -> {"username","email","first","last"}
  report_role(username, role)           -> True   (role=None clears)
  logout_url(next_url=None)             -> str     (id-auth full-signout URL)

Every network call talks ONLY to BW_AUTH (https://auth.bowden.works) over TLS.
The app NEVER talks to the gateway directly — id-auth is the broker.
"""

import base64
import hashlib
import hmac
import json
import os
import secrets
import ssl
import time
import urllib.parse
import urllib.request

# The four env-var names, in one place so the reference and docs can't drift.
ENV_CLIENT_ID = "BW_CLIENT_ID"
ENV_CLIENT_SECRET = "BW_CLIENT_SECRET"
ENV_AUTH = "BW_AUTH"
ENV_APP_DOMAIN = "BW_APP_DOMAIN"

# Default login host, used only if BW_AUTH is unset. Production is auth.bowden.works.
DEFAULT_BW_AUTH = "https://auth.bowden.works"

# HTTP timeout for the two server-to-server calls (token exchange, userinfo,
# report-role). id-auth is on the LAN bridge; these are fast. Keep it short so a
# hung request never wedges a web worker.
HTTP_TIMEOUT = 8

# The id_token lifetime the gateway signs (iat + 300). We don't rely on this
# constant for verification (we read exp from the claims), it's here for docs.
TOKEN_TTL = 300


class BWAuthError(Exception):
    """Any failure in the BW-Auth flow: bad config, network error, a 4xx from
    id-auth, or an invalid/expired/forged id_token. The message is safe to log;
    it never contains the client_secret. Catch this around exchange_code() and
    verify_id_token() and treat it as "authentication failed — bounce to /login".
    """


# --------------------------------------------------------------------------
# Config — read lazily so importing this module never requires the env to be set
# (a per-app Claude can `import bw_auth` to introspect it without a live config).
# --------------------------------------------------------------------------

def _env(name, default=None, required=False):
    val = os.environ.get(name, default)
    if required and not val:
        raise BWAuthError(
            f"{name} is not set. This app is not configured for BW-Auth. "
            f"Set {ENV_CLIENT_ID}, {ENV_CLIENT_SECRET}, {ENV_AUTH}, and "
            f"{ENV_APP_DOMAIN} in the app container environment."
        )
    return val


def client_id():
    """Your registered client_id (the --name slug). Required for every call."""
    return _env(ENV_CLIENT_ID, required=True)


def client_secret():
    """Your client_secret. Required for the server-to-server calls (token,
    userinfo, report-role) and to verify/sign the id_token HMAC. Lives ONLY in
    the app env — never logged, never sent in a query string, never to a browser.
    """
    return _env(ENV_CLIENT_SECRET, required=True)


def auth_base():
    """The id-auth login host, e.g. https://auth.bowden.works — no trailing slash."""
    return (_env(ENV_AUTH, default=DEFAULT_BW_AUTH) or DEFAULT_BW_AUTH).rstrip("/")


def app_domain():
    """This app's public origin, e.g. https://myapp.example.com — no trailing slash.
    Used to build the default redirect_uri and the full-signout return URL."""
    return _env(ENV_APP_DOMAIN, required=True).rstrip("/")


def default_redirect_uri():
    """The canonical callback URL: <BW_APP_DOMAIN>/auth/callback. This MUST be on
    the redirect_uri allowlist you registered with app-client-register. If your
    app mounts the callback elsewhere, pass an explicit redirect_uri to
    build_authorize_url()/start_login()/exchange_code() and register THAT."""
    return f"{app_domain()}/auth/callback"


# --------------------------------------------------------------------------
# base64url helpers — MUST match the gateway's _b64url (urlsafe, '=' stripped).
# --------------------------------------------------------------------------

def _b64url_nopad(raw: bytes) -> str:
    """urlsafe base64 with '=' padding stripped — the exact encoding the gateway
    uses for both the id_token `value` and the PKCE code_challenge."""
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _b64url_decode(value: str) -> bytes:
    """Inverse of _b64url_nopad: re-pad to a multiple of 4, then urlsafe-decode."""
    pad = "=" * (-len(value) % 4)
    return base64.urlsafe_b64decode(value + pad)


# --------------------------------------------------------------------------
# PKCE (S256)
# --------------------------------------------------------------------------

def new_pkce_pair():
    """Generate a fresh (code_verifier, code_challenge) PKCE pair.

    code_verifier  = secrets.token_urlsafe(48)   (a high-entropy random string)
    code_challenge = base64url( sha256(verifier) ), '=' stripped, method "S256"

    Store the VERIFIER server-side across the redirect (an HttpOnly+Secure cookie
    is the usual home — see start_login) and send the CHALLENGE in the authorize
    URL. At /app-token you present the verifier; id-auth recomputes the challenge
    and rejects the code if it doesn't match. This binds the code to the exact
    browser/session that started the login, defeating code interception.
    """
    verifier = secrets.token_urlsafe(48)
    challenge = _b64url_nopad(hashlib.sha256(verifier.encode("ascii")).digest())
    return verifier, challenge


# --------------------------------------------------------------------------
# Step 1 — build the /app-authorize redirect
# --------------------------------------------------------------------------

def build_authorize_url(code_verifier, redirect_uri=None, state=None, silent=False):
    """Build the GET https://auth.bowden.works/app-authorize URL to send the
    browser to. Low-level: you own the verifier and state.

    Args:
        code_verifier: the PKCE verifier from new_pkce_pair() (kept server-side).
        redirect_uri:  where id-auth sends the code back. Defaults to
                       default_redirect_uri() (<BW_APP_DOMAIN>/auth/callback).
                       MUST be on your registered allowlist.
        state:         opaque anti-CSRF value echoed back to your callback. If
                       None, a fresh random one is generated. Store it (cookie or
                       session) and compare on the callback — reject on mismatch.
        silent:        SILENT probe (OIDC prompt=none) — for PUBLIC-mode apps that
                       want to auto-sign-in an already-SSO'd visitor WITHOUT ever
                       showing the BW login page to an anonymous one. When True and
                       the visitor has NO BW account session, id-auth 302s back to
                       <redirect_uri>?error=login_required&state=... (instead of the
                       login page) — your callback catches that and renders your OWN
                       welcome page. When False (an explicit "Sign in" click), a
                       non-SSO'd visitor lands on the BW login page as normal.

    Returns:
        (url, state) — redirect the browser to `url`; remember `state`.

    On success (session present, or after login) id-auth 302s to:
        <redirect_uri>?code=<single-use-code>&state=<state>
    Only a REAL account (not a guest/shared-password session) can complete it.
    """
    if state is None:
        state = secrets.token_urlsafe(24)
    if redirect_uri is None:
        redirect_uri = default_redirect_uri()
    challenge = _b64url_nopad(
        hashlib.sha256(code_verifier.encode("ascii")).digest()
    )
    params = {
        "client_id": client_id(),
        "redirect_uri": redirect_uri,
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
    if silent:
        params["prompt"] = "none"
    return f"{auth_base()}/app-authorize?{urllib.parse.urlencode(params)}", state


def is_login_required(query):
    """True if a SILENT (prompt=none) probe bounced back with no session — i.e. the
    visitor is anonymous and your app should render its own welcome page. `query`
    is the callback's parsed query params (a dict of str->str or the raw querystring).
    Use this to branch in your /auth/callback: login_required -> welcome page (and
    do NOT re-probe — loop-guard it); a `code` present -> exchange_code() as normal."""
    if isinstance(query, str):
        query = {k: v[0] for k, v in urllib.parse.parse_qs(query).items()}
    return (query.get("error") or "") == "login_required"


# Cookie names the framework helpers use to carry PKCE + state across the redirect.
PKCE_COOKIE = "bw_pkce"      # holds the code_verifier (HttpOnly, Secure, SameSite=Lax)
STATE_COOKIE = "bw_state"    # holds the state nonce (HttpOnly, Secure, SameSite=Lax)


def start_login(set_cookie, redirect_uri=None, cookie_max_age=600, silent=False):
    """Framework-agnostic helper that begins a login: it mints a PKCE pair + state,
    hands you the two short-lived cookies to set, and returns the authorize URL.

    `set_cookie` is a callback YOU provide that persists a cookie. It is called as:
        set_cookie(name, value, max_age)
    and must set an HttpOnly + Secure + SameSite=Lax cookie (these carry a secret
    verifier and a CSRF nonce across the round-trip; they must not be JS-readable).

    Example (Flask): pass a closure that stashes (name, value, max_age) and, after
    building your redirect response, calls resp.set_cookie(name, value,
    max_age=max_age, httponly=True, secure=True, samesite="Lax"). See example-flask/app.py.

    Returns:
        (authorize_url, state) — redirect the browser to authorize_url.

    On the callback, read the verifier from the PKCE_COOKIE and the expected state
    from the STATE_COOKIE, compare the returned `state`, then call
    finish_login(code, verifier) (or exchange_code + verify_id_token directly).
    """
    verifier, _challenge = new_pkce_pair()
    url, state = build_authorize_url(verifier, redirect_uri=redirect_uri, silent=silent)
    set_cookie(PKCE_COOKIE, verifier, cookie_max_age)
    set_cookie(STATE_COOKIE, state, cookie_max_age)
    return url, state


# --------------------------------------------------------------------------
# SPA / PWA silent-probe helpers.
#
# A single-page app (React/Vue/…), ESPECIALLY served as a PWA, cannot use the
# server-rendered silent probe that example-flask does on "/": the service worker
# serves the cached index.html for "/", so a server-side probe there never runs.
# The SPA pattern instead is:
#
#   1. A PUBLIC endpoint GET /api/me returns {authenticated, should_probe, …profile}.
#      The SERVER computes should_probe — the loop-guard/opt-out cookies below are
#      HttpOnly, so the SPA's JS cannot read them itself.
#   2. On load the SPA calls /api/me; if (!authenticated && should_probe) it does a
#      FULL-PAGE navigation (window.location, NOT fetch) to /auth/probe.
#   3. /auth/probe calls start_probe() (sets the loop-guard cookie) and 302s into the
#      SILENT (prompt=none) authorize. The callback returns a ?code (signed in) or
#      ?error=login_required (anonymous → the SPA renders its OWN welcome page).
#
# CRITICAL for PWAs: /login, /logout, and /auth/* (INCLUDING /auth/probe) MUST be in
# the service worker's navigateFallbackDenylist, or the SW answers them from cache and
# sign-in silently fails with no request reaching the server. See PATTERN-B.md.
# --------------------------------------------------------------------------

PROBED_COOKIE = "bw_probed"   # set just before a silent probe; loop-guards re-probing
OPTOUT_COOKIE = "bw_optout"   # set by your /logout; suppresses auto-probe after sign-out
PROBE_MAX_AGE = 600           # probe an anonymous visitor at most once per this window (s)
OPTOUT_MAX_AGE = 86400        # after sign-out, stay opted-out this long (or until explicit login)


def should_probe(mode, authenticated, cookies):
    """Should the SPA fire a silent auto-sign-in probe on load?

    True only when ALL hold: the domain is PUBLIC mode (account/password-gated domains
    are already behind the gate — no client probe needed); the visitor is NOT already
    authenticated in your app; they have NOT opted out (no OPTOUT_COOKIE, set by your
    /logout); and they have NOT been probed this window (no PROBED_COOKIE). Computed on
    the SERVER for /api/me because those cookies are HttpOnly.

    Args:
        mode:          your domain mode ("public" | "password" | "account").
        authenticated: whether the caller already has an APP session.
        cookies:       a mapping of cookie name -> value (e.g. request.cookies).
    """
    if str(mode).lower() != "public" or authenticated:
        return False
    return not cookies.get(OPTOUT_COOKIE) and not cookies.get(PROBED_COOKIE)


def me_payload(authenticated, mode, cookies, profile=None):
    """Build the JSON body for your PUBLIC `GET /api/me`: the SPA reads `authenticated`
    to pick its view and `should_probe` to decide whether to navigate to /auth/probe.
    Merge in whatever public profile fields your app wants to expose (never secrets)."""
    body = {"authenticated": bool(authenticated),
            "should_probe": should_probe(mode, authenticated, cookies)}
    if profile:
        body.update(profile)
    return body


def start_probe(set_cookie, redirect_uri=None):
    """Begin a SILENT probe from your `/auth/probe` route: set the loop-guard cookie
    (so an anonymous visitor is probed at most once per PROBE_MAX_AGE window) and
    return the authorize URL to 302 the browser to. Mirrors start_login(silent=True).

    `set_cookie(name, value, max_age)` is your framework's cookie setter — the same
    callback shape start_login uses; it must set HttpOnly + Secure + SameSite=Lax.
    Returns (authorize_url, state)."""
    set_cookie(PROBED_COOKIE, "1", PROBE_MAX_AGE)
    return start_login(set_cookie, redirect_uri=redirect_uri, silent=True)


def clear_probe_cookies(delete_cookie):
    """After a successful sign-in (in /auth/callback) clear the loop-guard + opt-out
    cookies so the app is in a clean state. `delete_cookie(name)` deletes one cookie."""
    delete_cookie(PROBED_COOKIE)
    delete_cookie(OPTOUT_COOKIE)


def mark_opted_out(set_cookie, max_age=OPTOUT_MAX_AGE):
    """Call from your /logout AFTER clearing the app session: sets OPTOUT_COOKIE so the
    silent probe does NOT immediately re-sign-in the user who just left. An explicit
    'Sign in' click (start_login, not a probe) clears it."""
    set_cookie(OPTOUT_COOKIE, "1", max_age)


# --------------------------------------------------------------------------
# HTTP plumbing for the server-to-server POSTs. Talks ONLY to BW_AUTH over TLS.
# --------------------------------------------------------------------------

def _post_json(path, payload):
    """POST `payload` as JSON to <BW_AUTH><path> and return the parsed JSON dict.

    Raises BWAuthError on transport failure or any non-2xx (surfacing id-auth's
    {"error": ...} body when present). The client_secret may be in `payload`, so
    this function never logs the payload and never puts it in a URL/query string.
    """
    url = f"{auth_base()}{path}"
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url, data=body, method="POST",
        headers={"Content-Type": "application/json", "Accept": "application/json"},
    )
    # Standard verified TLS. BW_AUTH is https://auth.bowden.works with a real cert.
    ctx = ssl.create_default_context()
    try:
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as r:
            return json.loads(r.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        # id-auth returns {"error": "..."} on the documented 4xx cases.
        detail = ""
        try:
            detail = json.loads(e.read().decode("utf-8")).get("error", "")
        except Exception:
            pass
        raise BWAuthError(
            f"{path} returned HTTP {e.code}"
            + (f": {detail}" if detail else "")
        ) from None
    except urllib.error.URLError as e:
        raise BWAuthError(f"could not reach id-auth at {url}: {e.reason}") from None
    except (ValueError, json.JSONDecodeError) as e:
        raise BWAuthError(f"{path} returned a non-JSON response: {e}") from None


# --------------------------------------------------------------------------
# Step 2 — exchange the code at /app-token (server-to-server, from the BACKEND)
# --------------------------------------------------------------------------

def exchange_code(code, code_verifier, redirect_uri=None):
    """Redeem the single-use authorization code for an identity + id_token.

    This is a server-to-server call from your app BACKEND (never the browser) —
    it carries the client_secret. id-auth verifies the code (single-use, 60s TTL,
    bound to client_id + redirect_uri), checks the PKCE verifier against the
    challenge from step 1, and verifies your client_secret, then returns the
    signed id_token.

    Args:
        code:          the ?code=... value id-auth put on your callback URL.
        code_verifier: the PKCE verifier you stored before the redirect.
        redirect_uri:  MUST be byte-identical to the one used in step 1. Defaults
                       to default_redirect_uri().

    Returns:
        {"identity": {"username","email","first","last"},
         "id_token": "<value>|<hmac_hex>"}

    You should still call verify_id_token(result["id_token"]) — it's the
    cryptographic proof of the audience and freshness. `identity` is a convenience
    copy of the same fields; treat verify_id_token()'s claims as authoritative.

    Raises BWAuthError on a bad/expired/replayed code, PKCE mismatch, or bad
    client_secret (id-auth returns 4xx {"error": ...} for all of these).
    """
    if redirect_uri is None:
        redirect_uri = default_redirect_uri()
    resp = _post_json("/app-token", {
        "code": code,
        "client_id": client_id(),
        "client_secret": client_secret(),
        "redirect_uri": redirect_uri,
        "code_verifier": code_verifier,
    })
    if "id_token" not in resp or "identity" not in resp:
        raise BWAuthError(
            "/app-token response missing id_token/identity: "
            + (resp.get("error", "unexpected response shape"))
        )
    return resp


def finish_login(code, code_verifier, redirect_uri=None):
    """Convenience: exchange_code() + verify_id_token() in one call. Returns the
    verified claims dict (aud/sub/username/email/first/last/iat/exp). Use this in a
    callback when you just want "give me the trusted identity or raise". The
    id_token is verified and discarded — nothing to store."""
    result = exchange_code(code, code_verifier, redirect_uri=redirect_uri)
    return verify_id_token(result["id_token"])


# --------------------------------------------------------------------------
# id_token verification — the exact inverse of the gateway's signer.
# --------------------------------------------------------------------------

def verify_id_token(id_token, leeway=0):
    """Verify a BW id_token and return its claims, or raise BWAuthError.

    Format (must match the gateway byte-for-byte):
        id_token = "<value>|<hmac_hex>"
        value    = base64url( json(claims, separators=(",",":")) ), no '=' padding
        hmac_hex = HMAC_SHA256(key=client_secret, msg=value).hexdigest()

    Steps: split off the trailing HMAC, recompute HMAC over `value` with our
    client_secret, constant-time compare, then re-pad + base64url-decode `value`,
    json-load the claims, and assert aud == our client_id and exp > now.

    The token is a one-shot bootstrap credential: call this once in your callback,
    read the identity out of the returned claims, and DISCARD the token. Never
    store it, never accept it as a bearer on later requests.
    """
    if not id_token or "|" not in id_token:
        raise BWAuthError("id_token is empty or malformed (expected value|hmac)")
    value, sig = id_token.rsplit("|", 1)
    expected = hmac.new(
        client_secret().encode("utf-8"), value.encode("utf-8"), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise BWAuthError("id_token signature mismatch (wrong client_secret or forged token)")
    try:
        claims = json.loads(_b64url_decode(value).decode("utf-8"))
    except Exception as e:
        raise BWAuthError(f"id_token payload is not valid base64url JSON: {e}") from None
    if claims.get("aud") != client_id():
        raise BWAuthError(
            f"id_token audience mismatch: token aud={claims.get('aud')!r}, "
            f"expected {client_id()!r}"
        )
    now = int(time.time())
    if int(claims.get("exp", 0)) <= now - leeway:
        raise BWAuthError("id_token has expired")
    return claims


# --------------------------------------------------------------------------
# Step 3 — /app/userinfo — fetch a profile for an ACCOUNT-domain visitor.
# --------------------------------------------------------------------------

def userinfo(username):
    """Look up a BW account's profile by username (server-to-server).

    For ACCOUNT-gated domains: the whole host is behind the gate, so every request
    arrives with a trusted X-Auth-User header (the account's username). There is
    no OAuth code to exchange — you already know WHO the visitor is. Call
    userinfo(x_auth_user) to pull their email/first/last so you can upsert the app
    account, then set your own app session. No id_token is involved in this path.

    Args:
        username: the value from the (Caddy-set, unspoofable) X-Auth-User header.
                  Lowercase account usernames only — a guest ("guest" / "guest:...")
                  is NOT an account and will 404 here (guests can't reach an
                  ACCOUNT-gated host at all).

    Returns:
        {"username","email","first","last"}

    Raises BWAuthError if the username is unknown/inactive (id-auth returns 404).
    """
    return _post_json("/app/userinfo", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
    })


# --------------------------------------------------------------------------
# Step 4 — /app/report-role — publish a DISPLAY-ONLY role to the BW hub.
# --------------------------------------------------------------------------

def report_role(username, role):
    """Report this user's role WITHIN your app to the BW hub, for display only.

    Roles are purely cosmetic in id-auth/the hub: they help the owner see "adi is
    an editor in myapp" at a glance. They do NOT grant any real access —
    your app is the sole authority on what a role can do. An app can only ever
    write its OWN client_id's roles, and the role never feeds back into id-auth's
    real site-access list.

    Call this right after you establish/refresh a user's app session (on every
    login is fine — it's idempotent). Pass role=None (or "") to CLEAR the role,
    e.g. when a user is deactivated in your app.

    Args:
        username: the BW account username (your app_accounts primary key).
        role:     a short display string ("admin", "editor", "viewer", ...), or
                  None/"" to clear.

    Returns:
        True on success. Raises BWAuthError on a bad client_secret / unknown user.
    """
    resp = _post_json("/app/report-role", {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "role": role,
    })
    if not resp.get("success"):
        raise BWAuthError(
            "report-role failed: " + resp.get("error", "unknown error")
        )
    return True


def report_presence(username, ip=None):
    """Tell the BW hub that `username` is CURRENTLY active in your app, so the hub's
    who's-online shows their NAME instead of an anonymous IP.

    Why this exists: on a Pattern B app id-auth is NOT in your login loop (you run
    your own login), so it only ever sees anonymous traffic and can label a visitor
    by IP at best. This heartbeat is how id-auth learns who's really here. While your
    app is reporting, id-auth stops recording anonymous IP visits for your host and
    shows the reported names instead (it falls back to IPs if you go quiet).

    Call it on a HEARTBEAT for each signed-in user — e.g. straight from your
    `/api/me` handler whenever the caller is authenticated (the SPA polls /api/me, so
    that keeps presence fresh), or a dedicated ping every ~60s. It's reported against
    YOUR app's host (derived from app_domain()). Requires presence/history tracking to
    be enabled on the host (owner: hub toggle or `srv-gw id-site-set`).

    Fire-and-forget: presence is best-effort telemetry, so any failure is swallowed —
    a hub blip must never break your app or slow the request.

    Args:
        username: the BW account username (your app_accounts primary key).
        ip:       optional — the user's client IP, shown next to their name in the hub.
    """
    host = urllib.parse.urlparse(app_domain()).hostname
    payload = {
        "client_id": client_id(),
        "client_secret": client_secret(),
        "username": username,
        "host": host,
    }
    if ip:
        payload["ip"] = ip
    try:
        _post_json("/app/report-presence", payload)
    except BWAuthError:
        pass  # best-effort telemetry — never surface a presence hiccup to the app


# --------------------------------------------------------------------------
# Logout — clear the app session, then hit id-auth for a full cross-site signout.
# --------------------------------------------------------------------------

def logout_url(next_url=None):
    """Build the id-auth full-signout URL.

    Your app's /logout should FIRST clear its own session cookie, then redirect the
    browser here so the shared BW (SSO) session is revoked too — otherwise the user
    is still signed in to BW and would be silently re-authed on the next visit.

    Args:
        next_url: where id-auth should send the browser after signing out.
                  Defaults to app_domain() (your homepage). Must be a site id-auth
                  recognises as a safe redirect (any served host qualifies).

    Returns:
        e.g. https://auth.bowden.works/logout?redirect=https%3A%2F%2Fmyapp.example.com
    """
    dest = next_url or app_domain()
    return f"{auth_base()}/logout?redirect={urllib.parse.quote(dest, safe='')}"


# --------------------------------------------------------------------------
# Helper: is this X-Auth-User a real account, or a shared-password guest?
# --------------------------------------------------------------------------

def is_guest(x_auth_user):
    """True if the X-Auth-User header value is a shared-password GUEST session
    rather than a real BW account.

    id-auth reports guests as the literal "guest" or "guest:<label>" (the label is
    a display-only name the guest typed — NOT an identity). Real accounts come
    through as their plain lowercase username. On a PASSWORD-gated domain you use
    this to decide whether to show the "Sign in with BW account" upgrade button
    (guest) or to just establish the app session (real account).
    """
    u = (x_auth_user or "").strip().lower()
    return u == "" or u == "guest" or u.startswith("guest:")


# --------------------------------------------------------------------------
# Tiny self-check when run directly: prove the signer/verifier round-trips using
# the SAME construction as the gateway (see gateway.py _sign_app_token / _b64url /
# handle_app_mint_token). This does NOT contact the network and needs no real
# config — it uses a throwaway secret to demonstrate the crypto matches.
# --------------------------------------------------------------------------

def _selfcheck():
    secret = "test-secret-not-a-real-one"
    cid = "demoapp"
    now = int(time.time())
    claims = {
        "aud": cid, "sub": "adi",
        "username": "adi", "email": "adi@example.com",
        "first": "Adi", "last": "Example",
        "iat": now, "exp": now + TOKEN_TTL,
    }
    # Reproduce the gateway's exact signing (gateway.py lines 7754/7759/7915).
    value = base64.urlsafe_b64encode(
        json.dumps(claims, separators=(",", ":")).encode()
    ).decode().rstrip("=")
    sig = hmac.new(secret.encode(), value.encode(), hashlib.sha256).hexdigest()
    token = f"{value}|{sig}"

    # Verify with the module's verifier, overriding config via env for the check.
    os.environ[ENV_CLIENT_ID] = cid
    os.environ[ENV_CLIENT_SECRET] = secret
    got = verify_id_token(token)
    assert got["username"] == "adi" and got["aud"] == cid, got
    # A wrong secret must fail.
    os.environ[ENV_CLIENT_SECRET] = "wrong"
    try:
        verify_id_token(token)
    except BWAuthError:
        pass
    else:
        raise AssertionError("verify_id_token accepted a bad signature")

    # SPA silent-probe gating (no network, no config).
    assert should_probe("public", False, {}) is True
    assert should_probe("public", True, {}) is False                     # already signed in
    assert should_probe("account", False, {}) is False                   # gated domain
    assert should_probe("password", False, {}) is False                  # gated domain
    assert should_probe("public", False, {OPTOUT_COOKIE: "1"}) is False  # opted out
    assert should_probe("public", False, {PROBED_COOKIE: "1"}) is False  # already probed
    assert me_payload(False, "public", {})["should_probe"] is True
    assert me_payload(True, "public", {}, {"username": "adi"})["username"] == "adi"
    print("bw_auth self-check OK: sign/verify round-trips, rejects bad secret, "
          "SPA probe-gating correct")


if __name__ == "__main__":
    _selfcheck()
