Skip to content

OAUTH-001C1H — Replace browser-local Strava OAuth state with a one-use server challenge #441

Description

@daliu

Officer impact: Members start Strava connection from My Account as before, but the website now asks the server for a short-lived security challenge before leaving for Strava. A failed, expired, or repeated callback gives one plain retry path and never contacts Strava or changes the connection.

Officer documentation: Update docs/officers/EVENTS_SHOP_MEMBERS.md and the matching callback diagram/text alternative. Keep the procedure SOURCE ONLY, NOT LIVE until the exact website and Firebase Functions are deployed and verified.

Deployment evidence: None at creation. Closure must distinguish source, tests, merge, Firebase deployment, website publication, runmprc.com verification, Strava/provider configuration, and live behavior. This issue does not authorize provider-console, secret, production-account, production-data, or live callback work.

Parent: #88. Slice: OAUTH-001C1H.

Problem

Current main generates OAuth state in the browser, stores it in sessionStorage, and validates it only in the callback browser. The server exchange receives only the authorization code, so a scripted authenticated caller can bypass state validation. The state has no server-enforced UID/session binding, TTL, atomic one-use consumption, or concurrent replay protection.

Atomic outcome

Replace that browser-local authority with one complete source/test handoff:

  • Add an authenticated App-Check-guarded begin callable that creates 32 random bytes, returns only the base64url challenge, and stores only its SHA-256 digest in the existing server-only members/{uid}/secrets boundary.
  • Keep exactly one active Strava challenge per UID. A new begin request invalidates the earlier challenge.
  • Bind the record to the exact UID and Firebase Auth auth_time session marker and expire it after ten minutes.
  • Require the callback to pass both code and state to stravaExchangeCode.
  • Atomically validate and delete the challenge before credentials, provider exchange, token/connection writes, or any success result. Exactly one concurrent consumer may proceed.
  • Remove browser state generation, sessionStorage storage, and browser-authoritative verification.
  • Preserve the existing immediate callback query/fragment scrub and fixed public failure text.

Invariants and transitions

  • App Check runs before Auth; Auth runs before challenge creation or consumption.
  • begin: absent -> active; repeated begin: active-old -> active-new.
  • exchange: active + matching UID/session + unexpired + exact digest -> consumed, then provider exchange may begin.
  • missing, malformed, wrong-UID, wrong-session, expired, mismatched, already-consumed, or concurrently-lost state -> one fixed denial before provider/network/connection writes.
  • A provider or persistence failure after consumption does not restore or reuse the challenge; the member starts again.
  • The raw challenge, authorization code, digest, Auth token/session fields, callback URL, provider body, and OAuth tokens never enter logs, public errors, connection metadata, custom claims, issues, screenshots, or analytics.
  • State creates no membership, discount, payment, member, or admin authority.
  • No browser write is added. Existing Firestore rules keep the secrets subcollection unreadable and unwritable by every browser role.
  • No TTL policy, collection migration, or backfill is required because there is one fixed record per UID, overwritten on begin and deleted on use.

Required proof

  • Red proof: current exchange reaches the provider with no server state record.
  • Positive: begin stores one digest-only record and returns a fixed-shape raw challenge; exact same UID/session consumes once and preserves the existing valid exchange result.
  • Negative: missing/malformed/short/long/non-base64url state, missing/malformed auth_time, wrong UID, wrong session, mismatch, expiry boundary, already consumed, Firestore read/write/transaction failure, and App Check/Auth failure.
  • Race: two concurrent consumers of one challenge produce one provider exchange and one fixed denial.
  • Browser: connect waits for begin, disables repeat activation, navigates only after a valid response, renders fixed retry text on failure, and ignores obsolete completion after UID/service/unmount changes.
  • Callback: sends the captured scrubbed state to the server exactly once; provider/query errors, missing code/state, reinjection, stale account/service, rejected exchange, and navigation behavior remain fixed and private.
  • No-network tests use synthetic values only. Provider fetch is mocked. No production Firebase project, Strava account, token, code, state, member record, or secret is used.
  • Focused frontend/Functions tests, relevant full suites, lint, Rules, SPA callback, diagnostic build, workflow/artifact checks, and exact diff review pass.

Expected paths

  • functions/strava.js and functions/strava.test.js
  • functions/index.js
  • src/services/strava/stravaService.ts
  • src/pages/account/StravaSection.tsx and StravaCallback.tsx
  • named additive/update blocks in src/pages/account/Account.test.tsx
  • SYSTEM_DESIGN.md, SECURITY.md, and docs/officers/EVENTS_SHOP_MEMBERS.md only as required for current source truth and the matching diagram/text alternative

Dependencies and coordination

#99 and #335 are complete and merged. #88 explicitly permits OAUTH-001C independently after the recorded ABUSE-001A deferral. AUTH-003, IAM/encryption, scope/account policy, provider configuration, deployment, and live proof remain outside this child. No open PR, duplicate issue, assignee, or active exact-path claim was found immediately before publication on exact main 89a0374.

Explicitly out of scope

Do not enable a Strava provider, change credentials/scopes/IAM/encryption, implement athlete uniqueness, refresh concurrency, revoke/audit/reconciliation, alter Rules, deploy Firebase or the website, touch production data, use a real callback, or claim #88 complete.

Claim protocol

Creation is not a claim. Before editing: re-read live claims; assign this child; post CLAIMED by at ; branch ; exact base ; move status:ready to status:in-progress; and push the unique branch. Earliest valid timestamp wins.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:authAuthentication and authorizationarea:firebaseFirebase services and dataarea:stravaStrava integrationarea:webWeb application and hostingpriority:P0Launch blocker or urgent security risksize:MMedium multi-file issuetype:reliabilityReliability and recoverytype:securitySecurity or privacy boundarytype:testingTest infrastructure and quality gates

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions