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.
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:
Invariants and transitions
Required proof
Expected paths
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.