Docs / runbooks / Yahoo Login & Identity Resolution

How "Sign in with Yahoo" resolves a stable user identity, the failure modes that have broken it, and the guards that keep it from regressing.

Yahoo Login & Identity Resolution

How /auth/yahoo/callback turns a Yahoo OAuth login into a stable users row, why it broke (2026-06-27), and the guards that must not be removed.

How identity is resolved (in order)

The callback (web_dashboard.py: auth_yahoo_callback) resolves the user's guid โ€” the key the users table is indexed on โ€” from these sources, first valid wins:

  1. token_data['xoauth_yahoo_guid'] โ€” the OAuth token's guid. Not reliably present.
  2. Fantasy API (fantasysports.yahooapis.com/.../users;use_login=1) โ€” users.0.user[0].guid.
  3. OIDC userinfo (https://api.login.yahoo.com/openid/v1/userinfo) โ€” the sub claim. This is the authoritative, non-masked source (it returns the authenticated user's own id) and also yields email + name.

Every candidate is passed through _valid_yahoo_guid() before it's accepted. If no valid guid resolves, the login is refused (it never invents/stores a placeholder).

The DB upsert then: SELECT โ€ฆ WHERE yahoo_guid = <guid>; if found โ†’ update; else link by email (an existing row with the same email is updated to this guid, carrying is_admin/approval forward) before falling back to inserting a new row.

The 2026-06-27 outage (what broke, and why this design)

The owner's Yahoo login bounced to the logged-out view. Trace showed three simultaneous failures:

Source What it returned Why
Token xoauth_yahoo_guid absent Yahoo dropped it from the token response
Fantasy API guid --hidden-- the user's Yahoo privacy settings mask the guid
Social Directory API social.yahooapis.com/v1/me 404 Yahoo retired that endpoint

With all three dead/masked, the old code (which only checked if not yahoo_guid) had been storing the literal string --hidden-- as a guid. Because users.yahoo_guid is UNIQUE-indexed, that collapsed every privacy-masked user onto one shared row โ€” a cross-account identity collision โ€” and minted non-admin twin rows that stranded admin grants. The fix added validation + switched to OIDC userinfo, whose sub is the real, non-masked guid (verified: sub == 2LTAGYAJ5LMMK35PIIXU3VBZUE, the owner's real id).

โ›” Regression guards โ€” do not remove

  1. The authorize request MUST include scope=openid email profile (auth_yahoo). Without openid, the token carries no id_token, the userinfo endpoint is unauthorized, and identity resolution falls back to the masked/dead sources โ†’ logins refuse. Symptom of regression: token_keys has no id_token; users bounce to the logged-out view. Changing the scope requires the Yahoo app to have OpenID Connect permissions enabled in the Yahoo developer console, and users will re-consent once.
  2. Use the OIDC userinfo endpoint, never social.yahooapis.com โ€” the latter is gone (404). _YAHOO_USERINFO_URL = https://api.login.yahoo.com/openid/v1/userinfo.
  3. Keep _valid_yahoo_guid() (rejects empty, --hidden--, and anything not ^[A-Z0-9]{20,}$). This is what prevents the cross-account collision. Never accept a raw API value as a guid without it.
  4. Keep the email-link fallback in the upsert โ€” if a returning user's guid ever changes (Yahoo API generation/privacy state), they re-link to their existing row by email instead of duplicating.
  5. fantasy-football-secrets/FLASK_SECRET_KEY must be set (it is) so sessions are valid across both backend replicas; an unset/inconsistent key logs users out on the round-robin hop.

Diagnosing a future break

The OAuth round-trip itself working (you see Yahoo's consent screen) but landing logged-out almost always means guid resolution failed. To trace, temporarily add at the three resolution points:

logger.warning(f"AUTHDIAG token_keys={sorted(token_data.keys())} ...")
logger.warning(f"AUTHDIAG userinfo keys={sorted(me.keys())} sub={me.get('sub')!r} email={...!r}")
logger.warning(f"AUTHDIAG final yahoo_guid={yahoo_guid!r} valid={_valid_yahoo_guid(yahoo_guid)}")
then kubectl logs <backend pod> -n fantasyfootball | grep AUTHDIAG after one login. Remove the logging once resolved (it prints sub/email).

Fallback access while debugging: the local admin account always works at /login (username/password from DASHBOARD_USERNAME/DASHBOARD_PASSWORD), independent of Yahoo.