diff --git a/.github/lint-baseline.json b/.github/lint-baseline.json index 366f943..54e7a4d 100644 --- a/.github/lint-baseline.json +++ b/.github/lint-baseline.json @@ -31,7 +31,7 @@ ".ts": 0, ".tsx": 6 }, - "fingerprint": "sha256:f6d14c2fd59a703cf4c69f45f80ea93f02ee33484980f9dd028cb37f149ca671", + "fingerprint": "sha256:56b52a74434e910548d72c741ab21b634274d3cc9c36fe9983d8942b34eebddf", "scannedFiles": [ "src/App.jsx", "src/App.test.jsx", @@ -200,9 +200,9 @@ }, { "file": "src/pages/account/StravaSection.tsx", - "line": 305, + "line": 358, "column": 11, - "endLine": 305, + "endLine": 358, "endColumn": 42, "severity": 2, "ruleId": "react/jsx-no-bind", @@ -213,10 +213,10 @@ }, { "file": "src/pages/account/StravaSection.tsx", - "line": 310, - "column": 23, - "endLine": 310, - "endColumn": 48, + "line": 364, + "column": 11, + "endLine": 364, + "endColumn": 36, "severity": 2, "ruleId": "react/jsx-no-bind", "message": "JSX props should not use functions", diff --git a/SECURITY.md b/SECURITY.md index 813504d..0787289 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -129,6 +129,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-014 | SUPPLY-001D6 [#381](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/381) resolves the sole root `picomatch` 2.x node from 2.3.1 to the patched 2.3.2 release without changing `package.json`; the existing `jest-util` `^2.2.3` and `micromatch` `^2.3.1` ranges already admit it. The exact lock change touches only that node's version, public-registry URL, and integrity while its non-development classification, MIT license, `>=8.6` engine, parent ranges, and the separate development-only `tinyglobby` picomatch 4.0.4 copy stay fixed. A dependency-free Node test pins those identities and ranges; deterministic public-API regressions prove that inherited POSIX names cannot inject host method text into a compiled expression while ordinary `[[:alpha:]]` matching remains compatible. A fresh 2026-07-19 UTC Node 20.19.5/npm 10.8.2 full audit falls from 65 findings (1 critical, 32 high) to 64 (1 critical, 31 high), and the production-only snapshot falls from 6 findings (0 critical, 3 high, 2 moderate, 1 low) to 5 (0 critical, 2 high, 2 moderate, 1 low), removing GHSA-3v7f-55p6-f55p and GHSA-c2c7-rcm5-vvqj from the sole affected node. | No application source imports `picomatch`, and clean before/after optimized builds on the same toolchain are byte-identical: the same 59 files and 19,475,953 bytes. The sorted per-file SHA-256 manifest has aggregate SHA-256 `57f2db4d6217de2f4e158d357aa928cb0af2b6a18238d61d3d866ffd53f234ba`. `npm ls picomatch --all` still reports the pre-existing hoisted `fdir` `^3 || ^4` mismatch; this patch neither introduces nor repairs that separate build-tool layout debt. The remaining root critical `protobufjs` finding is in the old development-only `firebase-tools` chain and requires a separate major CLI, emulator, and protected-release compatibility review; all other root and Functions findings also remain unresolved and are not accepted as safe. This source/test change does not publish the website, deploy Firebase, change a provider, inspect production data, or prove live behavior. | | Part of RISK-014 | SUPPLY-001D7 [#385](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/385) resolves the sole root, non-development `yaml` 1.x node from 1.10.2 to the patched 1.10.3 release without changing `package.json`; the existing `cosmiconfig` `^1.10.0`, `cssnano` `^1.10.2`, and nested `cosmiconfig` `^1.7.2` ranges already admit it. The exact lock change touches only that node's version, public-registry URL, and integrity while the three separate development-only `yaml` 2.x copies remain fixed. A dependency-free Node test pins all four locked identities and development classifications, the three unchanged parent ranges, and the installed root version. Its fixed 601-byte collection input runs in a bounded child process with a reduced stack and proves the patched public `parseDocument` API reports one parser-owned `YAMLSemanticError` instead of exposing raw `RangeError` failures. A fresh 2026-07-19 UTC Node 20.20.2/npm 10.8.2 full audit remains 64 findings (1 critical, 31 high, 19 moderate, 13 low) because vulnerable development-only `yaml` 2.7.1 and 2.8.0 copies remain; the production-only snapshot falls from 5 findings (2 high, 2 moderate, 1 low) to 4 (2 high, 1 moderate, 1 low). | No application source imports `yaml`, and clean before/after optimized builds on the same toolchain are byte-identical: the same 59 files and 19,489,263 bytes. The sorted per-file SHA-256 manifest has aggregate SHA-256 `0207e35601232cd74febaa6e72b9e2322f2159276f32d9ce046f8b050f0f40ed`. The two affected development-only `yaml` 2.x copies and all other root and Functions findings remain unresolved and are not accepted as safe; they require separate compatibility review rather than widening this lock-only production patch. Source, tests, and any merge do not publish the website, deploy Firebase, change a provider, inspect production data, or prove live behavior. | | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | +| RISK-009, part of RISK-024 | OAUTH-001C1H [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) replaces browser-generated/session-storage-authoritative Strava OAuth state with a 32-byte server-issued challenge. Source stores only a SHA-256 digest in one browser-denied per-UID secret record, binds it to exact UID and decoded Auth `auth_time`, expires it after ten minutes, and transactionally deletes one match before provider exchange or connection writes. A later begin overwrites the older record; mismatch, expiry, replay, and the concurrent loser stop with one fixed result before Strava. Synthetic tests use mocks and no real callback or provider value. | This source does not deploy either Function or the website, configure Strava, prove the custom App Check guard is enabled, enable native runtime App Check enforcement, inspect production data, or prove live OAuth behavior. The initial capability callback still suppresses browser App Check startup, so fail-closed exchange enforcement also needs a separately reviewed clean-page handoff. Scope/account policy, refresh concurrency, provider/local reconciliation, revoke/audit, IAM/encryption, and provider/live evidence remain open under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88). | | Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) source routes the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibits direct console calls elsewhere in application/public runtime source. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | | Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) merged through [PR #189](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/189) as `bec7d5e365eacb418563a172029f241f660d9768`, adding the no-new-dependency exact-record checker, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. The checker now scans 108 frontend files; CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227) and CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239) each retire exactly one reviewed finding. PAY-004C1 [#359](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/359) retires two label errors and one prompt warning by removing those unsafe controls. | The reviewable lint baseline deliberately records 121 configured errors and 6 warnings as individual file/line/column/rule/message records; it is not a clean-lint claim. Any added, removed, moved, or changed record requires review, every fatal parser/configuration failure rejects with a fixed diagnostic, and the baseline must not be regenerated just to pass. The clean check covers tracked and non-ignored untracked files; ignored dependency/build outputs are outside this proof. Hosted source checks do not configure branch protection, deploy Firebase, or prove Stripe/live behavior. #169, #173, #182, #206, #226, #232, and #238 expand the focused commerce suite; configure/prove branch-required checks and add the broader TEST-001 suite. | | DATA-001A3 / registration confidentiality | DATA-001A3 [#374](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/374) limits the My Account registration callable to one exact stored-UID query. Source no longer reads token email or verification claims and no longer searches registrations by runner email. Matching-email records with no UID or another account's UID are excluded; exact-UID records retain their existing sanitized summary, event lookup, and sort. The page's empty result says only that no upcoming registration is linked and that a signed-out registration may not appear. Synthetic tests use getter and Proxy canaries and make no write or provider call. | This source containment is not deployed or live and does not associate, migrate, repair, or change a registration. Anonymous, historical, mistyped-email, shared-email, and differently owned records stay hidden until a separately approved, audited server association under [#115](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/115). Keep the email index until separate inventory and migration review. Complete protected Function deployment/readback, website publication, exact `runmprc.com` verification, and a made-up account check before describing the boundary as live. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 76faaf3..4cf7875 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -132,23 +132,46 @@ Text alternative: the 404 page temporarily carries the complete return route to ### Strava callback current-address cleanup — source only, not live -OAUTH-001C1G [#335](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/335) adds a second boundary after the existing Pages handoff. The Strava callback keeps only its initial `code`, `state`, and provider-error fields in temporary component memory. It replaces the current native browser and React Router entry with the same path, no address details after `?` or `#`, and no saved callback detail before the callback mounts its Auth/service work, verifies state, or starts the existing exchange attempt. +OAUTH-001C1G [#335](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/335) adds a second boundary after the existing Pages handoff. The Strava callback keeps only its initial `code`, `state`, and provider-error fields in temporary component memory. It replaces the current native browser and React Router entry with the same path, no address details after `?` or `#`, and no saved callback detail before the callback mounts its Auth/service work, checks the required fields, or starts the server exchange attempt. ```mermaid flowchart LR Route["Strava callback with made-up address details after ? or #"] --> Capture["Capture three selected fields in temporary page memory"] Capture --> Replace["Replace current browser and Router entry with path only"] Replace --> Clean{"Both current locations clean?"} - Clean -- "Not yet" --> Wait["Wait; no state check or exchange"] + Clean -- "Not yet" --> Wait["Wait; no callback check or exchange"] Wait -. "Detected replacement failure" .-> Stop["Fixed accessible failure"] Clean -- "Yes" --> Checks["Existing sign-in, provider-error, code, and state checks"] Checks --> Exchange["At most one exchange attempt for the same signed-in account and app"] Outside["Earlier browser, provider, hosting, or network copies"] -. "Remain outside this boundary" .-> Residual["Back or history is not cleanup proof"] ``` -Text alternative: the callback captures three selected made-up fields in temporary page memory, replaces the current browser and Router entry with the clean path, and proceeds to the existing checks only after both current locations are clean. Unconfirmed cleanup waits without state verification or exchange. A detected replacement failure shows the fixed stop. Cleaning the current entry does not erase earlier browser or outside copies. +Text alternative: the callback captures three selected made-up fields in temporary page memory, replaces the current browser and Router entry with the clean path, and proceeds to the callback checks only after both current locations are clean. Unconfirmed cleanup waits without an exchange. A detected replacement failure shows the fixed stop. Cleaning the current entry does not erase earlier browser or outside copies. -The source also discards a later same-route callback. After unmount or a signed-in UID, service, Firebase resources, or app change, an obsolete browser result cannot navigate or show success. That does not cancel an exchange that already reached the server or provider; its outcome may still occur and require separate reconciliation. This child does not change the Pages bridge, provider request, server state model, App Check enforcement, scopes, membership, or deployment. Source, tests, merge, website publication, `runmprc.com` revision verification, Firebase deployment, Strava configuration, production data, and live OAuth behavior remain separate states. Canonical [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) remains open for server-issued one-use state, expiry/replay, account and scope policy, concurrency, reconciliation, revoke/audit, IAM/encryption, provider configuration, deployment, and live proof. +The source also discards a later same-route callback. After unmount or a signed-in UID, service, Firebase resources, or app change, an obsolete browser result cannot navigate or show success. That does not cancel an exchange that already reached the server or provider; its outcome may still occur and require separate reconciliation. The later #441 boundary below supplies source-only server-issued state, UID/session binding, expiry, and one-use consumption without changing this address-cleanup order. Source, tests, merge, website publication, `runmprc.com` revision verification, Firebase deployment, Strava configuration, production data, and live OAuth behavior remain separate states. Canonical [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88) remains open for native App Check enforcement, account and scope policy, refresh concurrency, reconciliation, revoke/audit, IAM/encryption, provider configuration, deployment, and live proof. + +### Strava server-issued one-use state — source only, not live + +OAUTH-001C1H [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) replaces browser-generated, `sessionStorage`-authoritative OAuth state with one server-issued challenge. The signed-in website calls `stravaBeginAuthorization`; after the existing App Check and Auth guards, the Function creates 32 random bytes, returns the base64url challenge, and stores only its SHA-256 digest in one fixed server-only `members/{uid}/secrets/stravaOAuthState` record. The record is bound to the exact UID and decoded Firebase Auth `auth_time`, expires after ten minutes, and is overwritten by a later begin request for the same UID. + +```mermaid +flowchart LR + Start["Signed-in member chooses Connect Strava"] --> Begin["Begin Function: App Check, Auth, and auth_time"] + Begin --> Record["Server-only fixed record: digest, UID, session marker, expiry"] + Begin --> Provider["Raw challenge travels to Strava in OAuth state"] + Provider --> Callback["Callback captures code and state, then cleans current address"] + Callback --> Exchange["Exchange Function receives code and state"] + Exchange --> Consume{"Transaction consumes one matching, unexpired record?"} + Consume -- "No" --> Stop["Fixed denial; no provider call or connection write"] + Consume -- "Yes" --> Delete["Delete challenge before external work"] + Delete --> Token["Existing validated Strava token exchange and paired local write"] +``` + +Text alternative: a signed-in member receives one short-lived Strava state value while the server retains only its digest and identity/session binding; after the callback address is clean, one transaction deletes the matching record before the existing provider exchange, while every mismatch, expiry, replay, or concurrent loser stops without contacting Strava or changing the connection. + +The state transition is `absent -> active`, or `active-old -> active-new` when connection is started again, followed by exactly one `active -> consumed` transaction. Missing, malformed, wrong-UID, wrong-session, expired, mismatched, already-consumed, and concurrently lost attempts share a fixed public failure and perform no provider or connection write. A provider or persistence failure after consumption does not restore the challenge; the member must start again. Because there is one fixed per-UID record that is overwritten or deleted, this additive source change needs no backfill or collection migration. Existing Firestore Rules already deny every browser read and write under `members/{uid}/secrets/{secretId}`. + +This source does not prove the custom App Check guard is fail-closed in a deployed environment, enable native runtime App Check enforcement, configure or contact Strava, deploy Firebase or the website, inspect production data, or prove live OAuth behavior. The initial capability-bearing callback currently suppresses browser App Check startup, so a separate reviewed clean-page handoff is required before fail-closed exchange enforcement can be called compatible. Those remain separate work under [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88), ABUSE-001A, and the protected release issues. ### Firebase Auth action link — source only, not live @@ -302,6 +325,7 @@ All four slices are source boundaries until the exact Rules, Functions, and webs | `members/{uid}` | Profile and role mirror | Create-once signup/recovery Functions create phone-free pending profiles; self-service name-only allowlist while #178/#197 pause phone collection; server role operations | Confidential | | `members/{uid}/connections/{provider}` | Non-secret connection metadata | Cloud Functions | Confidential | | `members/{uid}/secrets/{provider}` | OAuth tokens | Cloud Functions | Restricted secret | +| `members/{uid}/secrets/stravaOAuthState` | One transient Strava state digest with UID/Auth-session binding and expiry; raw state is never stored | Cloud Functions | Restricted security state | | `promoCodes/{id}` | Intended promotion configuration | Admin only | Confidential; currently not integrated into checkout validation | | `ratelimits/{bucket}` | Abuse-control counters | Cloud Functions | Confidential operational data | | `mail/{id}` | Transactional email outbox | Cloud Functions and email extension | Restricted PII | diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 52bfc4a..7a98c20 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1052,12 +1052,12 @@ Officer review steps after the source merge: 5. Confirm a made-up provider query failure shows `We could not connect Strava. Please return to My Account and try again.` 6. Confirm a made-up exchange failure shows the same sentence. 7. Confirm no made-up provider detail appears on the page or in browser console output. -8. Confirm missing-code and failed-security-check results still stop before an exchange. +8. Confirm a missing code or state stops in the page before exchange, and a rejected server state uses the same fixed connection-failure result. 9. Confirm only a successful exchange returns to My Account, and the visible `Back to account` link still works without an exchange. 10. Confirm the failure sentence is announced as an urgent screen-reader alert. 11. Record website publication, `runmprc.com`, Firebase, Strava, production-data, and live-behavior evidence as separate results. -**Expected result:** the reviewed source uses one fixed, actionable sentence for both a callback query failure and an exchange failure. It does not inspect, display, or log the rejected exchange value. Existing sign-in, missing-code, failed-security-check, success, and Back-to-account behavior stays in place. The separate OAUTH-001C1G child adds source-only cleanup of the current browser entry before callback-specific checks or exchange. It does not erase earlier browser, provider, hosting, or network copies or complete issue #88. +**Expected result:** the reviewed source uses one fixed, actionable sentence for both a callback query failure and an exchange failure. It does not inspect, display, or log the rejected exchange value. Existing sign-in, missing-code, missing-state, server-rejection, success, and Back-to-account behavior stays in place. The separate OAUTH-001C1G child adds source-only cleanup of the current browser entry before callback-specific checks or exchange, and #441 adds the later source-only server state decision. Neither erases earlier browser, provider, hosting, or network copies or completes issue #88. **Stop conditions:** any real member or Strava account; a request for a callback URL, authorization code, state value, provider error, private browser history, or screenshot containing private values; a real provider call; a production Firebase or Strava change; a raw detail in the page or console; or a claim that source, tests, merge, or a green workflow proves the wording is live. @@ -1118,7 +1118,58 @@ Officer review steps after the source merge: **Escalation:** contact the platform/security and privacy owners if callback details may have appeared outside the current clean page. Use the private incident path. Do not copy a value into an issue, message, screenshot, or AI tool. -This child keeps canonical issue #88 open and incomplete. Server-issued one-use state, UID/session binding, expiry and replay protection, App Check handoff, account and scope policy, concurrency, revoke/audit behavior, IAM/encryption, provider configuration, deployment, and live verification remain separate work. +This child keeps canonical issue #88 open and incomplete. The separate #441 procedure below records the later source-only server state boundary. Native App Check enforcement, account and scope policy, refresh concurrency, revoke/audit behavior, IAM/encryption, provider configuration, deployment, and live verification remain separate work. + +## Strava one-use connection check — SOURCE ONLY, NOT LIVE + +**Status: NOT AVAILABLE YET** + +**Purpose:** make one Strava connection attempt usable only by the same signed-in account session that started it, for ten minutes and one server use. + +**Approver:** membership lead plus platform/security and privacy owners. + +**Prerequisites:** issue [#441](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/441) must be merged at one exact reviewed commit. Source review uses only made-up state, account, session, and provider values with mocked Firebase and Strava boundaries. A future staged release requires the separate reviewed clean-page App Check handoff, then the exact begin and exchange Functions deployed and read back before the matching website revision. The current initial callback suppresses browser App Check startup and is not proof of fail-closed compatibility. An approved non-production callback rehearsal is separate provider evidence; it does not by itself prove production or live behavior. A source merge, preview, green workflow, or website-only publication is not enough. + +```mermaid +flowchart LR + A["Member chooses Connect Strava"] --> B["Server creates one short-lived challenge"] + B --> C["Server stores digest and account-session binding only"] + B --> D["Strava returns code and challenge"] + D --> E["Page cleans the current address"] + E --> F{"Server consumes exact challenge once?"} + F -- "No, expired, wrong, or repeated" --> G["Fixed stop; try again from My Account"] + F -- "Yes" --> H["Delete challenge before provider exchange"] +``` + +Text alternative: the server gives the signed-in member one short-lived Strava challenge but stores only its digest and account-session binding; after the page cleans the callback address, the server deletes one exact match before exchange, and every expired, wrong, or repeated attempt stops with the same retry path. + +Officer review steps after the source merge: + +1. Keep this procedure marked **NOT AVAILABLE YET**. +2. Ask the platform owner for issue #441, the reviewed pull request, exact merged commit, and synthetic test results. +3. Confirm the tests use no real member, Firebase project, Strava account, callback, code, state, token, or provider request. +4. Confirm the begin Function applies its existing App Check guard, then requires a signed-in account and a valid Auth-session marker before writing a challenge. +5. Confirm the server stores no raw challenge. The retained record contains fixed schema/provider labels, a digest, UID, session marker, issue time, and expiry only. The raw challenge must not be in the database, connection record, logs, screenshots, or issue evidence. +6. Confirm starting again replaces the earlier challenge for that same website account. +7. Confirm the callback still removes current address details before it sends the made-up code and state to the server. +8. Confirm the server transaction checks the same UID and session, the exact digest, and the ten-minute expiry, then deletes the record before any Strava request or connection write. Source tests use a mocked Strava boundary. +9. Confirm missing, malformed, wrong-account, wrong-session, mismatched, expired, repeated, and simultaneous losing attempts all stop before the provider and connection write. +10. Confirm two simultaneous made-up callbacks produce at most one provider attempt. +11. Confirm a provider or database failure after consumption does not restore or reuse the challenge; the member starts again from My Account. +12. Confirm the Connect button blocks a repeated click while start is pending, navigates only after a valid server result, and shows one plain retry result on failure. +13. Confirm account, service, or page changes make an older browser completion inert. +14. Confirm this state creates no membership, payment, discount, member role, or admin authority. +15. Record source changed, tests passed, merged, Firebase Functions deployed, website published, `runmprc.com` revision verified, Strava configured, production data changed, and live behavior verified as separate results. + +**Expected result:** source permits one matching, unexpired challenge to reach the existing exchange path, which is mocked in source tests. Every invalid or repeated attempt receives one fixed failure before provider or connection work. A later start invalidates the earlier challenge. The raw challenge is returned through the server/browser/provider handoff and is never persisted server-side. This is source behavior only until both Functions and the website are released backend-first and separately verified. + +**Stop conditions:** any real account, Strava account, callback, code, state, token, browser-history capture, developer-tools capture, production record, provider call, or secret; a raw challenge in storage or logs; more than one provider call for simultaneous use; website publication before both Functions are verified; missing App Check/Auth checks; or a claim that source, tests, merge, preview, or green CI proves live protection. + +**Success proof:** for source completion, record issue #441, the reviewed pull request, exact commit, intended old-source failure, green begin/consume/replay/race/browser/callback tests, relevant full checks, and independent security/privacy/officer reviews. For a future staged release, separately record the protected Firebase deployment and readback, matching website publication, exact `runmprc.com` revision, approved non-production Strava configuration, and a made-up callback rehearsal. None of those results alone proves production live behavior. Do not use production data or a real member. + +**Undo:** before publication, use one reviewed revert or safe roll-forward. After a future approved release, restore the previous compatible Functions and website revisions together through the protected backend-first rollback, then verify both surfaces separately. Never undo by copying, recreating, or replaying a callback value or editing a member secret record. + +**Escalation:** contact the platform/security and privacy owners. Add the membership lead if a member cannot reconnect. Use the private incident path if a callback value, token, or account detail may have appeared outside the protected flow; do not copy it into an issue, message, screenshot, email, or AI tool. ## Strava connection record pairing — SOURCE ONLY, NOT LIVE diff --git a/functions/index.js b/functions/index.js index f39db06..c468ab4 100644 --- a/functions/index.js +++ b/functions/index.js @@ -17,6 +17,7 @@ const { createMerchCheckout } = require('./createMerchCheckout'); const { lookupOrder } = require('./lookupOrder'); const { adminOrderAction } = require('./adminOrderAction'); const { + stravaBeginAuthorization, stravaExchangeCode, stravaFetchStats, stravaDisconnect, @@ -51,6 +52,7 @@ exports.lookupOrder = lookupOrder; exports.adminOrderAction = adminOrderAction; // Strava +exports.stravaBeginAuthorization = stravaBeginAuthorization; exports.stravaExchangeCode = stravaExchangeCode; exports.stravaFetchStats = stravaFetchStats; exports.stravaDisconnect = stravaDisconnect; diff --git a/functions/strava.js b/functions/strava.js index bf01201..ab06749 100644 --- a/functions/strava.js +++ b/functions/strava.js @@ -1,6 +1,7 @@ const functions = require('firebase-functions'); const admin = require('firebase-admin'); const { Timestamp } = require('firebase-admin/firestore'); +const { createHash, randomBytes, timingSafeEqual } = require('node:crypto'); const { URL: NodeURL } = require('node:url'); const { types: { isProxy } } = require('node:util'); const { requireAppCheck } = require('./stripeHelpers'); @@ -11,6 +12,10 @@ const STRAVA_ACTIVITIES_URL = 'https://www.strava.com/api/v3/athlete/activities' const STRAVA_STATS_URL = (id) => `https://www.strava.com/api/v3/athletes/${id}/stats`; const STRAVA_AUTHORIZATION_ERROR_MESSAGE = 'Strava authorization could not be completed.'; const STRAVA_AUTHORIZATION_CODE_MAX_LENGTH = 1_024; +const STRAVA_OAUTH_STATE_BYTE_LENGTH = 32; +const STRAVA_OAUTH_STATE_LENGTH = 43; +const STRAVA_OAUTH_STATE_LIFETIME_SECONDS = 600; +const STRAVA_OAUTH_STATE_SCHEMA_VERSION = 1; const STRAVA_TOKEN_MAX_LENGTH = 2_048; const STRAVA_SCOPE_MAX_LENGTH = 1_024; const STRAVA_PROFILE_TEXT_MAX_LENGTH = 1_024; @@ -23,6 +28,8 @@ const STRAVA_LONG_MAX_AS_NUMBER = 9_223_372_036_854_776_000; const STRAVA_REFRESH_ERROR_MESSAGE = 'Strava connection could not be refreshed.'; const STRAVA_DATA_ERROR_MESSAGE = 'Strava activity data could not be loaded.'; const VISIBLE_ASCII_PATTERN = /^[\x21-\x7e]+$/; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/u; const OAUTH_SCOPE_PATTERN = /^[\x21\x23-\x5b\x5d-\x7e]+(?: [\x21\x23-\x5b\x5d-\x7e]+)*$/; const PROFILE_WHITESPACE_OR_BACKSLASH_PATTERN = /[\s\\]/u; const HTTPS_PREFIX_PATTERN = /^https:\/\//iu; @@ -39,10 +46,15 @@ const numberIsInteger = Number.isInteger; const numberIsSafeInteger = Number.isSafeInteger; const reflectApply = Reflect.apply; const reflectHas = Reflect.has; +const reflectOwnKeys = Reflect.ownKeys; const regexpTest = RegExp.prototype.test; const stringCharCodeAt = String.prototype.charCodeAt; const INVALID_SELECTED_VALUE = Symbol('invalid-selected-value'); const MISSING_SELECTED_VALUE = Symbol('missing-selected-value'); +const INVALID_AUTHORIZATION_REQUEST = Symbol('invalid-authorization-request'); +const OAUTH_STATE_CONSUMED = Symbol('oauth-state-consumed'); +const OAUTH_STATE_DENIED = Symbol('oauth-state-denied'); +const OAUTH_STATE_INTERNAL = Symbol('oauth-state-internal'); function stravaAuthorizationError(code) { return new functions.https.HttpsError(code, STRAVA_AUTHORIZATION_ERROR_MESSAGE); @@ -95,6 +107,32 @@ function selectedProviderDataValue(record, key, required) { } } +function hasExactOwnKeys(record, expectedKeys) { + let ownKeys; + try { + ownKeys = reflectApply(reflectOwnKeys, Reflect, [record]); + } catch (_error) { + return false; + } + if (ownKeys.length !== expectedKeys.length) return false; + for (let index = 0; index < ownKeys.length; index += 1) { + const key = ownKeys[index]; + let expected = false; + for (let expectedIndex = 0; expectedIndex < expectedKeys.length; expectedIndex += 1) { + if (key === expectedKeys[expectedIndex]) { + expected = true; + break; + } + } + if (!expected) return false; + } + return true; +} + +function isExactPlainRecord(record, expectedKeys) { + return isPlainJsonRecord(record) && hasExactOwnKeys(record, expectedKeys); +} + function isBoundedVisibleAscii(value, maxLength) { return typeof value === 'string' && value.length > 0 @@ -106,6 +144,143 @@ function isPositiveSafeInteger(value) { return typeof value === 'number' && numberIsSafeInteger(value) && value > 0; } +function snapshotAuthSession(auth) { + if (!isPlainJsonRecord(auth)) return null; + + const uid = selectedOwnDataValue(auth, 'uid', true); + const token = selectedOwnDataValue(auth, 'token', true); + if ( + typeof uid !== 'string' + || uid.length === 0 + || !isPlainJsonRecord(token) + ) { + return null; + } + + const authTime = selectedOwnDataValue(token, 'auth_time', true); + if (!isPositiveSafeInteger(authTime)) return null; + return objectFreeze({ uid, authTime }); +} + +function requireStravaAuthSession(context) { + const auth = context && context.auth; + if (!auth) { + throw stravaAuthorizationError('unauthenticated'); + } + const session = snapshotAuthSession(auth); + if (!session) throw stravaAuthorizationError('unauthenticated'); + return session; +} + +function currentEpochSeconds() { + let nowSeconds; + try { + nowSeconds = Math.floor(Date.now() / 1_000); + } catch (_error) { + return null; + } + return isPositiveSafeInteger(nowSeconds) + && nowSeconds <= Number.MAX_SAFE_INTEGER - STRAVA_OAUTH_STATE_LIFETIME_SECONDS + ? nowSeconds + : null; +} + +function stateDigest(state) { + return createHash('sha256').update(state, 'utf8').digest('hex'); +} + +function isCanonicalOAuthState(value) { + if ( + typeof value !== 'string' + || value.length !== STRAVA_OAUTH_STATE_LENGTH + || !patternMatches(BASE64URL_PATTERN, value) + ) { + return false; + } + try { + const decoded = Buffer.from(value, 'base64url'); + return decoded.length === STRAVA_OAUTH_STATE_BYTE_LENGTH + && decoded.toString('base64url') === value; + } catch (_error) { + return false; + } +} + +function isValidAuthorizationCode(code) { + return typeof code === 'string' + && code.length > 0 + && code.length <= STRAVA_AUTHORIZATION_CODE_MAX_LENGTH; +} + +function snapshotAuthorizationRequest(data) { + if (!isPlainJsonRecord(data)) return INVALID_AUTHORIZATION_REQUEST; + + if (hasExactOwnKeys(data, ['code'])) { + const code = selectedOwnDataValue(data, 'code', true); + return isValidAuthorizationCode(code) + ? OAUTH_STATE_DENIED + : INVALID_AUTHORIZATION_REQUEST; + } + if (!hasExactOwnKeys(data, ['code', 'state'])) return INVALID_AUTHORIZATION_REQUEST; + + const code = selectedOwnDataValue(data, 'code', true); + const state = selectedOwnDataValue(data, 'state', true); + if (!isCanonicalOAuthState(state)) return OAUTH_STATE_DENIED; + if (!isValidAuthorizationCode(code)) return INVALID_AUTHORIZATION_REQUEST; + return objectFreeze({ code, state }); +} + +function snapshotStoredOAuthState(record) { + const keys = [ + 'schemaVersion', + 'provider', + 'stateDigest', + 'uid', + 'authTime', + 'issuedAtSeconds', + 'expiresAtSeconds', + ]; + if (!isExactPlainRecord(record, keys)) return null; + + const schemaVersion = selectedOwnDataValue(record, 'schemaVersion', true); + const provider = selectedOwnDataValue(record, 'provider', true); + const digest = selectedOwnDataValue(record, 'stateDigest', true); + const uid = selectedOwnDataValue(record, 'uid', true); + const authTime = selectedOwnDataValue(record, 'authTime', true); + const issuedAtSeconds = selectedOwnDataValue(record, 'issuedAtSeconds', true); + const expiresAtSeconds = selectedOwnDataValue(record, 'expiresAtSeconds', true); + if ( + schemaVersion !== STRAVA_OAUTH_STATE_SCHEMA_VERSION + || provider !== 'strava' + || typeof digest !== 'string' + || !patternMatches(SHA256_HEX_PATTERN, digest) + || typeof uid !== 'string' + || uid.length === 0 + || !isPositiveSafeInteger(authTime) + || !isPositiveSafeInteger(issuedAtSeconds) + || !isPositiveSafeInteger(expiresAtSeconds) + || issuedAtSeconds > Number.MAX_SAFE_INTEGER - STRAVA_OAUTH_STATE_LIFETIME_SECONDS + || expiresAtSeconds !== issuedAtSeconds + STRAVA_OAUTH_STATE_LIFETIME_SECONDS + ) { + return null; + } + return objectFreeze({ + digest, + uid, + authTime, + issuedAtSeconds, + expiresAtSeconds, + }); +} + +function stateDigestsMatch(left, right) { + try { + return timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex')); + } catch (_error) { + return false; + } +} + function isNonNegativeSafeInteger(value) { return typeof value === 'number' && numberIsSafeInteger(value) && value >= 0; } @@ -528,6 +703,60 @@ function secretDocRef(uid, db = admin.firestore()) { .collection('secrets').doc('strava'); } +function oauthStateDocRef(uid, db = admin.firestore()) { + return db + .collection('members').doc(uid) + .collection('secrets').doc('stravaOAuthState'); +} + +async function consumeOAuthState(session, state) { + let digest; + try { + digest = stateDigest(state); + } catch (_error) { + throw stravaAuthorizationError('internal'); + } + let outcome; + try { + const db = admin.firestore(); + const stateRef = oauthStateDocRef(session.uid, db); + outcome = await db.runTransaction(async (transaction) => { + const snapshot = await transaction.get(stateRef); + if (!snapshot.exists) return OAUTH_STATE_DENIED; + + let storedRecord; + try { + storedRecord = snapshot.data(); + } catch (_error) { + return OAUTH_STATE_DENIED; + } + const stored = snapshotStoredOAuthState(storedRecord); + const nowSeconds = currentEpochSeconds(); + if (nowSeconds === null) return OAUTH_STATE_INTERNAL; + if ( + !stored + || stored.uid !== session.uid + || stored.authTime !== session.authTime + || stored.issuedAtSeconds > nowSeconds + || stored.expiresAtSeconds <= nowSeconds + || !stateDigestsMatch(stored.digest, digest) + ) { + return OAUTH_STATE_DENIED; + } + transaction.delete(stateRef); + return OAUTH_STATE_CONSUMED; + }); + } catch (_error) { + throw stravaAuthorizationError('internal'); + } + if (outcome === OAUTH_STATE_DENIED) { + throw stravaAuthorizationError('permission-denied'); + } + if (outcome !== OAUTH_STATE_CONSUMED) { + throw stravaAuthorizationError('internal'); + } +} + async function exchangeCode(code) { const { clientId, clientSecret } = getStravaCreds(); let resp; @@ -617,24 +846,63 @@ async function getFreshAccessToken(uid) { return refreshed.accessToken; } +exports.stravaBeginAuthorization = functions.https.onCall(async (data, context) => { + requireAppCheck(context); + const session = requireStravaAuthSession(context); + if (!isExactPlainRecord(data, [])) { + throw stravaAuthorizationError('invalid-argument'); + } + + const issuedAtSeconds = currentEpochSeconds(); + if (issuedAtSeconds === null) throw stravaAuthorizationError('internal'); + + let state; + let digest; + try { + state = randomBytes(STRAVA_OAUTH_STATE_BYTE_LENGTH).toString('base64url'); + if (!isCanonicalOAuthState(state)) throw new Error('invalid generated state'); + digest = stateDigest(state); + } catch (_error) { + throw stravaAuthorizationError('internal'); + } + + try { + await oauthStateDocRef(session.uid).set({ + schemaVersion: STRAVA_OAUTH_STATE_SCHEMA_VERSION, + provider: 'strava', + stateDigest: digest, + uid: session.uid, + authTime: session.authTime, + issuedAtSeconds, + expiresAtSeconds: issuedAtSeconds + STRAVA_OAUTH_STATE_LIFETIME_SECONDS, + }); + } catch (_error) { + throw stravaAuthorizationError('internal'); + } + + return objectFreeze({ + state, + expiresInSeconds: STRAVA_OAUTH_STATE_LIFETIME_SECONDS, + }); +}); + exports.stravaExchangeCode = functions .runWith({ secrets: ['STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET'] }) .https.onCall(async (data, context) => { requireAppCheck(context); - if (!context.auth) { - throw new functions.https.HttpsError('unauthenticated', 'Sign-in required'); + const session = requireStravaAuthSession(context); + const request = snapshotAuthorizationRequest(data); + if (request === OAUTH_STATE_DENIED) { + throw stravaAuthorizationError('permission-denied'); } - const { code } = data || {}; - if ( - typeof code !== 'string' - || code.length === 0 - || code.length > STRAVA_AUTHORIZATION_CODE_MAX_LENGTH - ) { + if (request === INVALID_AUTHORIZATION_REQUEST) { throw stravaAuthorizationError('invalid-argument'); } - const { uid } = context.auth; + const { uid } = session; + + await consumeOAuthState(session, request.state); - const exchange = await exchangeCode(code); + const exchange = await exchangeCode(request.code); try { const db = admin.firestore(); diff --git a/functions/strava.test.js b/functions/strava.test.js index 315d891..ba3a091 100644 --- a/functions/strava.test.js +++ b/functions/strava.test.js @@ -29,12 +29,38 @@ jest.mock('firebase-admin', () => { const deletes = []; const directSetAttempts = []; const documents = new Map(); + const documentVersions = new Map(); const reads = []; + let transactionCommitFailure; + let transactionCommitPostApplyFailure; + const transactionCommitAttempts = []; + const transactionDeleteAttempts = []; + const transactionDeleteFailures = new Map(); + const transactionDeletes = []; + const transactionGetFailures = new Map(); + const transactionReads = []; + let transactionBeforeCommitHook; + let transactionRetryHook; + let transactionRunAttempts = 0; + let transactionRunFailure; const writeFailures = new Map(); const writes = []; + function bumpVersion(path) { + documentVersions.set(path, (documentVersions.get(path) || 0) + 1); + } + + function applyDelete(path) { + documents.delete(path); + bumpVersion(path); + } + function applyWrite(operation) { writes.push(operation); + if (operation.options === undefined) { + documents.set(operation.path, operation.data); + bumpVersion(operation.path); + } } function document(path) { @@ -48,6 +74,7 @@ jest.mock('firebase-admin', () => { if (deleteFailures.has(path)) { throw deleteFailures.get(path); } + applyDelete(path); }, get: async () => { reads.push(path); @@ -105,6 +132,66 @@ jest.mock('firebase-admin', () => { }; return batch; }, + runTransaction: async (updateFunction) => { + transactionRunAttempts += 1; + if (transactionRunFailure) throw transactionRunFailure; + + for (let attempt = 0; attempt < 5; attempt += 1) { + const readVersions = new Map(); + const stagedDeletes = []; + const transaction = { + get: async (ref) => { + transactionReads.push(ref.path); + if (transactionGetFailures.has(ref.path)) { + throw transactionGetFailures.get(ref.path); + } + const version = documentVersions.get(ref.path) || 0; + const exists = documents.has(ref.path); + const data = documents.get(ref.path); + readVersions.set(ref.path, version); + return { + exists, + data: () => data, + }; + }, + delete: (ref) => { + transactionDeleteAttempts.push(ref.path); + if (transactionDeleteFailures.has(ref.path)) { + throw transactionDeleteFailures.get(ref.path); + } + stagedDeletes.push(ref.path); + return transaction; + }, + }; + + const result = await updateFunction(transaction); + transactionCommitAttempts.push([...stagedDeletes]); + if (transactionCommitFailure) throw transactionCommitFailure; + if (transactionBeforeCommitHook) await transactionBeforeCommitHook(); + + let conflicted = false; + for (const [path, readVersion] of readVersions) { + if ((documentVersions.get(path) || 0) !== readVersion) { + conflicted = true; + break; + } + } + if (conflicted) { + if (transactionRetryHook) await transactionRetryHook(); + continue; + } + + for (const path of stagedDeletes) { + transactionDeletes.push(path); + applyDelete(path); + } + if (transactionCommitPostApplyFailure) { + throw transactionCommitPostApplyFailure; + } + return result; + } + throw new Error('synthetic transaction retry limit'); + }, collection: (name) => ({ doc: (id) => document(`${name}/${id}`), }), @@ -117,7 +204,10 @@ jest.mock('firebase-admin', () => { deleteFailures.clear(); deletes.splice(0, deletes.length); }, - __clearDocuments: () => documents.clear(), + __clearDocuments: () => { + documents.clear(); + documentVersions.clear(); + }, __clearReads: () => reads.splice(0, reads.length), __clearWrites: () => { batchCommitFailure = undefined; @@ -128,6 +218,18 @@ jest.mock('firebase-admin', () => { batchSetAttempts.splice(0, batchSetAttempts.length); batchSetFailures.clear(); directSetAttempts.splice(0, directSetAttempts.length); + transactionCommitFailure = undefined; + transactionCommitPostApplyFailure = undefined; + transactionCommitAttempts.splice(0, transactionCommitAttempts.length); + transactionDeleteAttempts.splice(0, transactionDeleteAttempts.length); + transactionDeleteFailures.clear(); + transactionDeletes.splice(0, transactionDeletes.length); + transactionGetFailures.clear(); + transactionReads.splice(0, transactionReads.length); + transactionBeforeCommitHook = undefined; + transactionRetryHook = undefined; + transactionRunAttempts = 0; + transactionRunFailure = undefined; writeFailures.clear(); writes.splice(0, writes.length); }, @@ -138,6 +240,11 @@ jest.mock('firebase-admin', () => { __getDirectSetAttempts: () => [...directSetAttempts], __getDocument: (path) => documents.get(path), __getReads: () => [...reads], + __getTransactionCommitAttempts: () => [...transactionCommitAttempts], + __getTransactionDeleteAttempts: () => [...transactionDeleteAttempts], + __getTransactionDeletes: () => [...transactionDeletes], + __getTransactionReads: () => [...transactionReads], + __getTransactionRunAttempts: () => transactionRunAttempts, __getWrites: () => [...writes], __hasDocument: (path) => documents.has(path), __setBatchCommitFailure: (error) => { @@ -151,7 +258,30 @@ jest.mock('firebase-admin', () => { }, __setBatchSetFailure: (path, error) => batchSetFailures.set(path, error), __setDeleteFailure: (path, error) => deleteFailures.set(path, error), - __setDocument: (path, data) => documents.set(path, data), + __removeDocument: (path) => applyDelete(path), + __setDocument: (path, data) => { + documents.set(path, data); + bumpVersion(path); + }, + __setTransactionCommitFailure: (error) => { + transactionCommitFailure = error; + }, + __setTransactionCommitPostApplyFailure: (error) => { + transactionCommitPostApplyFailure = error; + }, + __setTransactionDeleteFailure: (path, error) => { + transactionDeleteFailures.set(path, error); + }, + __setTransactionGetFailure: (path, error) => transactionGetFailures.set(path, error), + __setTransactionBeforeCommitHook: (hook) => { + transactionBeforeCommitHook = hook; + }, + __setTransactionRetryHook: (hook) => { + transactionRetryHook = hook; + }, + __setTransactionRunFailure: (error) => { + transactionRunFailure = error; + }, __setWriteFailure: (path, error) => writeFailures.set(path, error), }; }); @@ -172,19 +302,30 @@ jest.mock('./stripeHelpers', () => ({ const admin = require('firebase-admin'); const functions = require('firebase-functions'); const { Timestamp } = require('firebase-admin/firestore'); +const { createHash } = require('node:crypto'); const { requireAppCheck } = require('./stripeHelpers'); -const { stravaDisconnect, stravaExchangeCode, stravaFetchStats } = require('./strava'); +const { + stravaBeginAuthorization, + stravaDisconnect, + stravaExchangeCode, + stravaFetchStats, +} = require('./strava'); const FIXED_AUTHORIZATION_ERROR = 'Strava authorization could not be completed.'; const FIXED_REFRESH_ERROR = 'Strava connection could not be refreshed.'; const FIXED_DATA_ERROR = 'Strava activity data could not be loaded.'; const FIXED_DISCONNECT_WARNING = 'strava_disconnect_revoke_failed'; const GUARDED_FETCH = global.fetch; +const AUTH_TIME = 1_700_000_000; const CONTEXT = Object.freeze({ app: Object.freeze({ appId: 'synthetic-app-check' }), - auth: Object.freeze({ uid: 'synthetic-member-000001' }), + auth: Object.freeze({ + uid: 'synthetic-member-000001', + token: Object.freeze({ auth_time: AUTH_TIME }), + }), }); const CODE = 'synthetic_authorization_code'; +const STATE = 'A'.repeat(43); const MAX_AUTHORIZATION_CODE_LENGTH = 1_024; const MAX_TOKEN_LENGTH = 2_048; const MAX_SCOPE_LENGTH = 1_024; @@ -196,6 +337,7 @@ const MAX_ACTIVITY_DATE_LENGTH = 64; const MAX_STRAVA_LONG_AS_NUMBER = 9_223_372_036_854_776_000; const CONNECTION_PATH = 'members/synthetic-member-000001/connections/strava'; const SECRET_PATH = 'members/synthetic-member-000001/secrets/strava'; +const STATE_PATH = 'members/synthetic-member-000001/secrets/stravaOAuthState'; function publicError(error) { return { @@ -215,9 +357,294 @@ async function captureFailure(action) { throw new Error('Expected Strava request to fail.'); } +function sha256Hex(value) { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function validOAuthStateRecord({ + state = STATE, + uid = CONTEXT.auth.uid, + authTime = AUTH_TIME, + issuedAtSeconds = 1_800_000_000, + expiresAtSeconds = issuedAtSeconds + 600, +} = {}) { + return { + schemaVersion: 1, + provider: 'strava', + stateDigest: sha256Hex(state), + uid, + authTime, + issuedAtSeconds, + expiresAtSeconds, + }; +} + +describe('Strava authorization challenge creation boundary', () => { + let consoleSpies; + let dateNowSpy; + + const NOW_SECONDS = 1_800_000_000; + + beforeEach(() => { + admin.__clearDeletes(); + admin.__clearDocuments(); + admin.__clearReads(); + admin.__clearWrites(); + requireAppCheck.mockReset(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(NOW_SECONDS * 1_000); + consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] + .map((method) => jest.spyOn(console, method).mockImplementation(() => undefined)); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + consoleSpies.forEach((spy) => spy.mockRestore()); + }); + + function expectNoBeginSideEffects() { + expect(admin.__getReads()).toEqual([]); + expect(admin.__getWrites()).toEqual([]); + expect(admin.__getDeletes()).toEqual([]); + expect(admin.__getTransactionRunAttempts()).toBe(0); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + } + + test('runs App Check before reading Auth or creating a challenge', async () => { + const authRead = jest.fn(); + const context = { app: CONTEXT.app }; + Object.defineProperty(context, 'auth', { + get: () => { + authRead(); + return CONTEXT.auth; + }, + }); + const appCheckFailure = new functions.https.HttpsError( + 'failed-precondition', + 'synthetic app check rejection', + ); + requireAppCheck.mockImplementationOnce(() => { + throw appCheckFailure; + }); + + await expect(stravaBeginAuthorization({}, context)).rejects.toBe(appCheckFailure); + + expect(requireAppCheck).toHaveBeenCalledWith(context); + expect(authRead).not.toHaveBeenCalled(); + expectNoBeginSideEffects(); + }); + + test('rejects a missing caller before request validation or Firestore', async () => { + const error = await captureFailure(() => stravaBeginAuthorization( + { unexpected: 'synthetic' }, + { ...CONTEXT, auth: null }, + )); + + expect(publicError(error)).toEqual({ + code: 'unauthenticated', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(requireAppCheck).toHaveBeenCalledTimes(1); + expectNoBeginSideEffects(); + }); + + test.each([ + ['missing', {}], + ['own undefined', { auth_time: undefined }], + ['null', { auth_time: null }], + ['zero', { auth_time: 0 }], + ['negative', { auth_time: -1 }], + ['fractional', { auth_time: 1.5 }], + ['NaN', { auth_time: Number.NaN }], + ['infinite', { auth_time: Number.POSITIVE_INFINITY }], + ['unsafe', { auth_time: Number.MAX_SAFE_INTEGER + 1 }], + ['string', { auth_time: String(AUTH_TIME) }], + ['bigint', { auth_time: BigInt(AUTH_TIME) }], + ['boxed', { auth_time: Object(AUTH_TIME) }], + ['inherited', Object.create({ auth_time: AUTH_TIME })], + ['proxied', new Proxy({ auth_time: AUTH_TIME }, {})], + ])('rejects %s decoded auth_time before challenge creation', async (_name, token) => { + const error = await captureFailure(() => stravaBeginAuthorization({}, { + ...CONTEXT, + auth: { uid: CONTEXT.auth.uid, token }, + })); + + expect(publicError(error)).toEqual({ + code: 'unauthenticated', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expectNoBeginSideEffects(); + }); + + test('does not invoke a decoded auth_time accessor while rejecting it', async () => { + const authTimeRead = jest.fn(); + const token = {}; + Object.defineProperty(token, 'auth_time', { enumerable: true, get: authTimeRead }); + + const error = await captureFailure(() => stravaBeginAuthorization({}, { + ...CONTEXT, + auth: { uid: CONTEXT.auth.uid, token }, + })); + + expect(publicError(error)).toEqual({ + code: 'unauthenticated', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(authTimeRead).not.toHaveBeenCalled(); + expectNoBeginSideEffects(); + }); + + test.each([ + ['undefined', undefined], + ['null', null], + ['an array', []], + ['an extra field', { unexpected: true }], + ['a custom prototype', Object.create({ inherited: true })], + ['a Proxy', new Proxy({}, {})], + ])('requires an exact empty request instead of %s', async (_name, data) => { + const error = await captureFailure(() => stravaBeginAuthorization(data, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'invalid-argument', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expectNoBeginSideEffects(); + }); + + test.each([ + ['minimum', 1], + ['maximum', Number.MAX_SAFE_INTEGER], + ])('accepts the exact positive-safe-integer %s auth_time boundary', async ( + _name, + authTime, + ) => { + const context = { + ...CONTEXT, + auth: { uid: CONTEXT.auth.uid, token: { auth_time: authTime } }, + }; + + const result = await stravaBeginAuthorization({}, context); + + expect(result.state).toMatch(/^[A-Za-z0-9_-]{43}$/u); + expect(result.expiresInSeconds).toBe(600); + expect(admin.__getDocument(STATE_PATH)).toEqual(expect.objectContaining({ authTime })); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('stores one digest-only fixed record and returns only the raw challenge and lifetime', async () => { + const result = await stravaBeginAuthorization({}, CONTEXT); + + expect(result).toEqual({ + state: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/u), + expiresInSeconds: 600, + }); + expect(Buffer.from(result.state, 'base64url')).toHaveLength(32); + const expectedRecord = { + schemaVersion: 1, + provider: 'strava', + stateDigest: sha256Hex(result.state), + uid: CONTEXT.auth.uid, + authTime: AUTH_TIME, + issuedAtSeconds: NOW_SECONDS, + expiresAtSeconds: NOW_SECONDS + 600, + }; + expect(admin.__getDirectSetAttempts()).toEqual([{ + path: STATE_PATH, + data: expectedRecord, + options: undefined, + }]); + expect(admin.__getDocument(STATE_PATH)).toEqual(expectedRecord); + expect(JSON.stringify(expectedRecord)).not.toContain(result.state); + expect(Object.keys(expectedRecord)).toEqual([ + 'schemaVersion', + 'provider', + 'stateDigest', + 'uid', + 'authTime', + 'issuedAtSeconds', + 'expiresAtSeconds', + ]); + expect(admin.__getReads()).toEqual([]); + expect(admin.__getDeletes()).toEqual([]); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('replaces the one prior challenge record for the same UID', async () => { + const first = await stravaBeginAuthorization({}, CONTEXT); + const second = await stravaBeginAuthorization({}, CONTEXT); + + expect(second.state).not.toBe(first.state); + expect(admin.__getDirectSetAttempts()).toHaveLength(2); + expect(admin.__getDocument(STATE_PATH)).toEqual({ + schemaVersion: 1, + provider: 'strava', + stateDigest: sha256Hex(second.state), + uid: CONTEXT.auth.uid, + authTime: AUTH_TIME, + issuedAtSeconds: NOW_SECONDS, + expiresAtSeconds: NOW_SECONDS + 600, + }); + expect(JSON.stringify(admin.__getDocument(STATE_PATH))).not.toContain(first.state); + expect(JSON.stringify(admin.__getDocument(STATE_PATH))).not.toContain(second.state); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('maps a Firestore replacement failure to one fixed error without exposing it', async () => { + admin.__setWriteFailure( + STATE_PATH, + new Error('challenge-write-canary raw-state-canary'), + ); + + const error = await captureFailure(() => stravaBeginAuthorization({}, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'internal', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(JSON.stringify(publicError(error))).not.toMatch(/challenge-write-canary|raw-state-canary/u); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getWrites()).toEqual([]); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test.each([ + ['not-a-number', Number.NaN], + ['zero', 0], + ['overflow', Number.MAX_SAFE_INTEGER * 1_000], + ['non-numeric', BigInt(NOW_SECONDS * 1_000)], + ])('maps a %s server clock to one fixed error before challenge creation', async ( + _name, + now, + ) => { + dateNowSpy.mockReturnValue(now); + + const error = await captureFailure(() => stravaBeginAuthorization({}, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'internal', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expectNoBeginSideEffects(); + }); +}); + describe('Strava authorization exchange failure boundary', () => { let fetchMock; let consoleSpies; + let dateNowSpy; + + const NOW_SECONDS = 1_800_000_000; beforeEach(() => { process.env.STRAVA_CLIENT_ID = 'strava_client_test'; @@ -228,6 +655,11 @@ describe('Strava authorization exchange failure boundary', () => { admin.__clearWrites(); Timestamp.now.mockClear(); requireAppCheck.mockReset(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(NOW_SECONDS * 1_000); + admin.__setDocument(STATE_PATH, validOAuthStateRecord({ + issuedAtSeconds: NOW_SECONDS - 1, + expiresAtSeconds: NOW_SECONDS + 599, + })); fetchMock = jest.fn(); global.fetch = fetchMock; consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] @@ -236,6 +668,7 @@ describe('Strava authorization exchange failure boundary', () => { afterEach(() => { global.fetch = GUARDED_FETCH; + dateNowSpy.mockRestore(); consoleSpies.forEach((spy) => spy.mockRestore()); delete process.env.STRAVA_CLIENT_ID; delete process.env.STRAVA_CLIENT_SECRET; @@ -328,7 +761,10 @@ describe('Strava authorization exchange failure boundary', () => { configure(failure); const json = mockSuccessfulExchange(); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); const exposed = publicError(error); expect(exposed).toEqual({ @@ -347,13 +783,18 @@ describe('Strava authorization exchange failure boundary', () => { expect(admin.__getDeletes()).toEqual([]); expect(admin.__hasDocument(SECRET_PATH)).toBe(false); expect(admin.__hasDocument(CONNECTION_PATH)).toBe(false); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); } async function expectInvalidSuccessfulResponse(response) { const json = mockExchangeResponse(response); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); expect(publicError(error)).toEqual({ code: 'internal', @@ -363,6 +804,8 @@ describe('Strava authorization exchange failure boundary', () => { }); expect(fetchMock).toHaveBeenCalledTimes(1); expect(json).toHaveBeenCalledTimes(1); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); expectNoSideEffects(); } @@ -377,6 +820,7 @@ describe('Strava authorization exchange failure boundary', () => { await expect(stravaExchangeCode({ code: 'x'.repeat(MAX_AUTHORIZATION_CODE_LENGTH + 1), + state: STATE, }, CONTEXT)).rejects.toBe(appCheckFailure); expect(requireAppCheck).toHaveBeenCalledWith(CONTEXT); @@ -387,6 +831,7 @@ describe('Strava authorization exchange failure boundary', () => { test('rejects a missing caller before code validation, provider exchange, or Firestore', async () => { await expect(stravaExchangeCode({ code: 'x'.repeat(MAX_AUTHORIZATION_CODE_LENGTH + 1), + state: STATE, }, { ...CONTEXT, auth: null })) .rejects.toMatchObject({ code: 'unauthenticated' }); @@ -399,7 +844,7 @@ describe('Strava authorization exchange failure boundary', () => { delete process.env.STRAVA_CLIENT_ID; delete process.env.STRAVA_CLIENT_SECRET; - const error = await captureFailure(() => stravaExchangeCode({}, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ state: STATE }, CONTEXT)); expect(publicError(error)).toEqual({ code: 'invalid-argument', @@ -412,6 +857,445 @@ describe('Strava authorization exchange failure boundary', () => { expectNoSideEffects(); }); + test('rejects exchange before provider access when no server challenge exists', async () => { + mockSuccessfulExchange(); + admin.__removeDocument(STATE_PATH); + + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expectNoSideEffects(); + }); + + test.each([ + ['missing token', undefined], + ['missing auth_time', {}], + ['zero auth_time', { auth_time: 0 }], + ['fractional auth_time', { auth_time: 1.5 }], + ['unsafe auth_time', { auth_time: Number.MAX_SAFE_INTEGER + 1 }], + ['string auth_time', { auth_time: String(AUTH_TIME) }], + ])('rejects %s after Auth and before request validation or Firestore', async ( + _name, + token, + ) => { + const error = await captureFailure(() => stravaExchangeCode( + { unexpected: 'synthetic' }, + { ...CONTEXT, auth: { uid: CONTEXT.auth.uid, token } }, + )); + + expect(publicError(error)).toEqual({ + code: 'unauthenticated', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(admin.__getTransactionRunAttempts()).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + expectNoSideEffects(); + }); + + test.each([ + ['undefined root', undefined], + ['null root', null], + ['array root', [CODE, STATE]], + ['missing code', { state: STATE }], + ['extra field', { code: CODE, state: STATE, unexpected: true }], + ['custom prototype', Object.assign(Object.create({ inherited: true }), { + code: CODE, + state: STATE, + })], + ['Proxy root', new Proxy({ code: CODE, state: STATE }, {})], + ])('rejects an inexact authorization request with %s before Firestore', async ( + _name, + data, + ) => { + const error = await captureFailure(() => stravaExchangeCode(data, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'invalid-argument', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(admin.__getTransactionRunAttempts()).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + expectNoSideEffects(); + }); + + test.each([ + ['missing', { code: CODE }], + ['own undefined', { code: CODE, state: undefined }], + ['null', { code: CODE, state: null }], + ['empty', { code: CODE, state: '' }], + ['short', { code: CODE, state: 'A'.repeat(42) }], + ['long', { code: CODE, state: 'A'.repeat(44) }], + ['non-base64url', { code: CODE, state: `${'A'.repeat(42)}+` }], + ['padded', { code: CODE, state: `${'A'.repeat(42)}=` }], + ['non-canonical trailing bits', { code: CODE, state: 'B'.repeat(43) }], + ['boxed', { code: CODE, state: Object(STATE) }], + ])('returns one fixed denial for %s state before any transaction or provider access', async ( + _name, + data, + ) => { + delete process.env.STRAVA_CLIENT_ID; + delete process.env.STRAVA_CLIENT_SECRET; + + const error = await captureFailure(() => stravaExchangeCode(data, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(admin.__getTransactionRunAttempts()).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + expectNoSideEffects(); + }); + + test('returns the fixed state denial without invoking a state accessor', async () => { + const stateRead = jest.fn(); + const data = { code: CODE }; + Object.defineProperty(data, 'state', { enumerable: true, get: stateRead }); + + const error = await captureFailure(() => stravaExchangeCode(data, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(stateRead).not.toHaveBeenCalled(); + expect(admin.__getTransactionRunAttempts()).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + expectNoSideEffects(); + }); + + test('consumes valid state before missing provider credentials and never restores it', async () => { + delete process.env.STRAVA_CLIENT_ID; + delete process.env.STRAVA_CLIENT_SECRET; + + const credentialFailure = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(credentialFailure)).toEqual({ + code: 'failed-precondition', + message: 'Strava credentials not configured', + details: undefined, + cause: undefined, + }); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__getWrites()).toEqual([]); + expect(admin.__getDirectSetAttempts()).toEqual([]); + expect(Timestamp.now).not.toHaveBeenCalled(); + + process.env.STRAVA_CLIENT_ID = 'strava_client_test'; + process.env.STRAVA_CLIENT_SECRET = 'strava_secret_test'; + const replay = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(replay)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__getWrites()).toEqual([]); + expect(admin.__getDirectSetAttempts()).toEqual([]); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('does not invoke request accessors while rejecting them', async () => { + const codeRead = jest.fn(); + const data = { state: STATE }; + Object.defineProperty(data, 'code', { enumerable: true, get: codeRead }); + + const error = await captureFailure(() => stravaExchangeCode(data, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'invalid-argument', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(codeRead).not.toHaveBeenCalled(); + expect(admin.__getTransactionRunAttempts()).toBe(0); + expect(fetchMock).not.toHaveBeenCalled(); + expectNoSideEffects(); + }); + + async function expectStoredStateDenial(record, context = CONTEXT, state = STATE) { + admin.__setDocument(STATE_PATH, record); + delete process.env.STRAVA_CLIENT_ID; + delete process.env.STRAVA_CLIENT_SECRET; + + const error = await captureFailure(() => stravaExchangeCode({ code: CODE, state }, context)); + + expect(publicError(error)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__getTransactionRunAttempts()).toBe(1); + expect(admin.__getTransactionReads()).toEqual([STATE_PATH]); + expect(admin.__getTransactionDeleteAttempts()).toEqual([]); + expect(admin.__getTransactionDeletes()).toEqual([]); + expect(admin.__getWrites()).toEqual([]); + expect(admin.__getDeletes()).toEqual([]); + expect(admin.__getDocument(STATE_PATH)).toBe(record); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + } + + test.each([ + ['undefined root', undefined], + ['null root', null], + ['primitive root', true], + ['array root', []], + ['null-prototype root', Object.create(null)], + ['custom-prototype root', Object.create({ inherited: true })], + ['Proxy root', new Proxy(validOAuthStateRecord(), {})], + ['missing field', (() => { + const record = validOAuthStateRecord(); + delete record.provider; + return record; + })()], + ['extra field', { ...validOAuthStateRecord(), unexpected: true }], + ['wrong schema version', { ...validOAuthStateRecord(), schemaVersion: 2 }], + ['wrong provider', { ...validOAuthStateRecord(), provider: 'other' }], + ['short digest', { ...validOAuthStateRecord(), stateDigest: 'a'.repeat(63) }], + ['uppercase digest', { + ...validOAuthStateRecord(), + stateDigest: sha256Hex(STATE).toUpperCase(), + }], + ['zero auth time', { ...validOAuthStateRecord(), authTime: 0 }], + ['zero issued time', { + ...validOAuthStateRecord(), + issuedAtSeconds: 0, + expiresAtSeconds: 600, + }], + ['non-ten-minute lifetime', { + ...validOAuthStateRecord(), + expiresAtSeconds: 1_800_000_601, + }], + ])('rejects malformed stored OAuth state with %s before provider access', async ( + _name, + record, + ) => { + await expectStoredStateDenial(record); + }); + + test('does not invoke a stored digest accessor while rejecting the record', async () => { + const digestRead = jest.fn(); + const record = validOAuthStateRecord(); + Object.defineProperty(record, 'stateDigest', { + enumerable: true, + get: digestRead, + }); + + await expectStoredStateDenial(record); + + expect(digestRead).not.toHaveBeenCalled(); + }); + + test.each([ + ['wrong stored UID', validOAuthStateRecord({ uid: 'synthetic-other-member' }), CONTEXT], + ['wrong Auth session', validOAuthStateRecord({ authTime: AUTH_TIME + 1 }), CONTEXT], + ['mismatched digest', validOAuthStateRecord({ state: 'E'.repeat(43) }), CONTEXT], + ])('rejects %s before credentials or provider access', async (_name, record, context) => { + await expectStoredStateDenial(record, context); + }); + + test('rejects a state stored only under a different caller UID', async () => { + const otherUid = 'synthetic-member-000002'; + const otherContext = { + ...CONTEXT, + auth: { uid: otherUid, token: { auth_time: AUTH_TIME } }, + }; + const otherStatePath = `members/${otherUid}/secrets/stravaOAuthState`; + + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, otherContext)); + + expect(publicError(error)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(admin.__getTransactionReads()).toEqual([otherStatePath]); + expect(admin.__getTransactionDeleteAttempts()).toEqual([]); + expect(admin.__hasDocument(STATE_PATH)).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__getWrites()).toEqual([]); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('rejects at the exact expiry boundary and retains the unusable record', async () => { + const record = validOAuthStateRecord({ + issuedAtSeconds: NOW_SECONDS - 600, + expiresAtSeconds: NOW_SECONDS, + }); + + await expectStoredStateDenial(record); + }); + + test('accepts one second before expiry and consumes before provider access', async () => { + admin.__setDocument(STATE_PATH, validOAuthStateRecord({ + issuedAtSeconds: NOW_SECONDS - 599, + expiresAtSeconds: NOW_SECONDS + 1, + })); + mockSuccessfulExchange(); + + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); + + expect(result).toEqual({ ok: true, athleteId: 123456 }); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('consumes once so a replay is denied before a second provider exchange', async () => { + mockSuccessfulExchange(); + + await expect(stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT)) + .resolves.toEqual({ ok: true, athleteId: 123456 }); + const replay = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(replay)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('allows exactly one concurrent consumer to reach the provider', async () => { + mockSuccessfulExchange(); + + const outcomes = await Promise.allSettled([ + stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT), + stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT), + ]); + + expect(outcomes.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); + const rejected = outcomes.find(({ status }) => status === 'rejected'); + expect(publicError(rejected.reason)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(admin.__getTransactionRunAttempts()).toBe(2); + expect(admin.__getTransactionReads()).toEqual([STATE_PATH, STATE_PATH, STATE_PATH]); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test('rechecks expiry when Firestore retries after a conflicting write', async () => { + const record = validOAuthStateRecord({ + issuedAtSeconds: NOW_SECONDS - 599, + expiresAtSeconds: NOW_SECONDS + 1, + }); + admin.__setDocument(STATE_PATH, record); + let introduceConflict = true; + admin.__setTransactionBeforeCommitHook(() => { + if (!introduceConflict) return; + introduceConflict = false; + admin.__setDocument(STATE_PATH, record); + }); + admin.__setTransactionRetryHook(() => { + dateNowSpy.mockReturnValue((NOW_SECONDS + 1) * 1_000); + }); + mockSuccessfulExchange(); + + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'permission-denied', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__getTransactionReads()).toEqual([STATE_PATH, STATE_PATH]); + expect(admin.__getTransactionDeleteAttempts()).toEqual([STATE_PATH]); + expect(admin.__getTransactionDeletes()).toEqual([]); + expect(admin.__getDocument(STATE_PATH)).toBe(record); + expect(Date.now).toHaveBeenCalledTimes(2); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + + test.each([ + ['transaction start', (failure) => admin.__setTransactionRunFailure(failure), false], + ['transaction read', (failure) => admin.__setTransactionGetFailure(STATE_PATH, failure), false], + ['transaction delete staging', (failure) => ( + admin.__setTransactionDeleteFailure(STATE_PATH, failure) + ), false], + ['transaction commit', (failure) => admin.__setTransactionCommitFailure(failure), false], + ['transaction acknowledgement', (failure) => ( + admin.__setTransactionCommitPostApplyFailure(failure) + ), true], + ])('maps a %s failure to one fixed error and never reaches the provider', async ( + _name, + configure, + consumed, + ) => { + const failure = new Error('transaction-canary raw-state-canary'); + configure(failure); + + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); + + expect(publicError(error)).toEqual({ + code: 'internal', + message: FIXED_AUTHORIZATION_ERROR, + details: undefined, + cause: undefined, + }); + expect(JSON.stringify(publicError(error))).not.toMatch(/transaction-canary|raw-state-canary/u); + expect(fetchMock).not.toHaveBeenCalled(); + expect(admin.__hasDocument(STATE_PATH)).toBe(!consumed); + expect(admin.__getWrites()).toEqual([]); + expect(admin.__getDeletes()).toEqual([]); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); + test.each([ ['an undefined', undefined], ['a null', null], @@ -428,7 +1312,10 @@ describe('Strava authorization exchange failure boundary', () => { delete process.env.STRAVA_CLIENT_ID; delete process.env.STRAVA_CLIENT_SECRET; - const error = await captureFailure(() => stravaExchangeCode({ code }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code, + state: STATE, + }, CONTEXT)); expect(publicError(error)).toEqual({ code: 'invalid-argument', @@ -445,6 +1332,7 @@ describe('Strava authorization exchange failure boundary', () => { const error = await captureFailure(() => stravaExchangeCode({ code: 'x'.repeat(MAX_AUTHORIZATION_CODE_LENGTH + 1), + state: STATE, }, CONTEXT)); expect(publicError(error)).toEqual({ @@ -463,6 +1351,7 @@ describe('Strava authorization exchange failure boundary', () => { const error = await captureFailure(() => stravaExchangeCode({ code: 'x'.repeat(MAX_AUTHORIZATION_CODE_LENGTH + 1), + state: STATE, }, CONTEXT)); expect(publicError(error)).toEqual({ @@ -482,7 +1371,7 @@ describe('Strava authorization exchange failure boundary', () => { ])('preserves an admitted %s code-unit-for-code-unit', async (_case, code) => { mockSuccessfulExchange(); - const result = await stravaExchangeCode({ code }, CONTEXT); + const result = await stravaExchangeCode({ code, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -504,7 +1393,10 @@ describe('Strava authorization exchange failure boundary', () => { json, }); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); expect(publicError(error)).toEqual({ code: 'invalid-argument', @@ -526,7 +1418,10 @@ describe('Strava authorization exchange failure boundary', () => { { providerBody: 'provider-body-canary' }, )); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); expect(publicError(error)).toEqual({ code: 'unavailable', @@ -536,6 +1431,8 @@ describe('Strava authorization exchange failure boundary', () => { }); expect(JSON.stringify(publicError(error))) .not.toMatch(/transport-canary|provider\.example|secret-canary|provider-body-canary/i); + expect(admin.__hasDocument(STATE_PATH)).toBe(false); + expect(admin.__getTransactionDeletes()).toEqual([STATE_PATH]); expectNoSideEffects(); }); @@ -545,7 +1442,10 @@ describe('Strava authorization exchange failure boundary', () => { ); fetchMock.mockResolvedValue({ ok: true, json }); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); expect(publicError(error)).toEqual({ code: 'unavailable', @@ -615,7 +1515,10 @@ describe('Strava authorization exchange failure boundary', () => { }); mockExchangeResponse(response); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); expect(publicError(error)).toEqual({ code: 'unavailable', @@ -697,7 +1600,7 @@ describe('Strava authorization exchange failure boundary', () => { else response.scope = scope; mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(admin.__getWrites()[0].data.scope).toBeNull(); @@ -833,7 +1736,7 @@ describe('Strava authorization exchange failure boundary', () => { try { mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(admin.__getWrites()[1].data.firstName).toBeNull(); @@ -885,7 +1788,7 @@ describe('Strava authorization exchange failure boundary', () => { else response.athlete[providerField] = value; mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(admin.__getWrites()[1].data[documentField]).toBeNull(); @@ -902,7 +1805,7 @@ describe('Strava authorization exchange failure boundary', () => { response.athlete.profile = 'https://例え.example/athlete/%E2%98%83?size=large#profile'; mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(admin.__getWrites()[0].data.scope).toBe(response.scope); @@ -927,7 +1830,7 @@ describe('Strava authorization exchange failure boundary', () => { + 'p'.repeat(MAX_PROFILE_URL_LENGTH - profilePrefix.length); mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(admin.__getWrites()[0].data).toMatchObject({ @@ -949,7 +1852,7 @@ describe('Strava authorization exchange failure boundary', () => { Object.freeze(response); mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(admin.__getWrites()).toHaveLength(2); @@ -973,7 +1876,7 @@ describe('Strava authorization exchange failure boundary', () => { }); mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(admin.__getWrites()[0].data.access_token).toBe('access_token_test'); @@ -1002,7 +1905,7 @@ describe('Strava authorization exchange failure boundary', () => { }); mockExchangeResponse(response); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(unknownGetter).not.toHaveBeenCalled(); @@ -1053,7 +1956,10 @@ describe('Strava authorization exchange failure boundary', () => { admin.__setWriteFailure(CONNECTION_PATH, failure); const json = mockSuccessfulExchange(); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); const exposed = publicError(error); const expectedWrites = expectedExchangeWrites(); @@ -1090,7 +1996,10 @@ describe('Strava authorization exchange failure boundary', () => { admin.__setBatchCommitPostApplyFailure(failure); const json = mockSuccessfulExchange(); - const error = await captureFailure(() => stravaExchangeCode({ code: CODE }, CONTEXT)); + const error = await captureFailure(() => stravaExchangeCode({ + code: CODE, + state: STATE, + }, CONTEXT)); const exposed = publicError(error); const expectedWrites = expectedExchangeWrites(); @@ -1118,7 +2027,7 @@ describe('Strava authorization exchange failure boundary', () => { test('preserves the successful server-only exchange, writes, and minimal result', async () => { const json = mockSuccessfulExchange(); - const result = await stravaExchangeCode({ code: CODE }, CONTEXT); + const result = await stravaExchangeCode({ code: CODE, state: STATE }, CONTEXT); expect(result).toEqual({ ok: true, athleteId: 123456 }); expect(requireAppCheck.mock.invocationCallOrder[0]) diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index fb0a421..4f83fb8 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -10,6 +10,7 @@ import { BrowserRouter, MemoryRouter, Route, Routes, useLocation, useNavigate, useNavigationType, } from 'react-router-dom'; +import { getFunctions, httpsCallable } from 'firebase/functions'; import { useServiceLocator } from '../../services/ServiceLocatorContext'; import { useAuth } from '../../services/hooks/useAuth'; import { @@ -19,11 +20,12 @@ import { updateMyProfile, } from '../../services/account/accountService'; import { + buildStravaAuthorizeUrl, getStravaConnection, + stravaBeginAuthorization, stravaDisconnect, stravaExchangeCode, stravaFetchStats, - verifyStravaState, } from '../../services/strava/stravaService'; import AccountPage, { AccountContent, AccountPageShell } from './Account'; import StravaCallback from './StravaCallback'; @@ -39,15 +41,21 @@ jest.mock('../../services/hooks/useAuth', () => ({ useAuth: jest.fn(), })); +jest.mock('firebase/functions', () => ({ + getFunctions: jest.fn(), + httpsCallable: jest.fn(), +})); + jest.mock('../../services/strava/stravaService', () => { const actual = jest.requireActual('../../services/strava/stravaService'); return { ...actual, + buildStravaAuthorizeUrl: jest.fn(actual.buildStravaAuthorizeUrl), getStravaConnection: jest.fn(), + stravaBeginAuthorization: jest.fn(), stravaDisconnect: jest.fn(), stravaExchangeCode: jest.fn(), stravaFetchStats: jest.fn(), - verifyStravaState: jest.fn(), }; }); @@ -80,6 +88,7 @@ jest.mock('./StravaSection', () => function StravaSection() { }); const ActualStravaSection = jest.requireActual('./StravaSection').default; +const ActualStravaService = jest.requireActual('../../services/strava/stravaService'); const USER = { uid: 'synthetic-user', @@ -103,6 +112,11 @@ const app = { name: 'synthetic-app' }; const firestore = { name: 'synthetic-firestore' }; const signOut = jest.fn(); const resendVerificationEmail = jest.fn(); +const STRAVA_AUTHORIZATION_STATE = 'A'.repeat(43); +const STRAVA_AUTHORIZATION_CHALLENGE = Object.freeze({ + state: STRAVA_AUTHORIZATION_STATE, + expiresInSeconds: 600 as const, +}); const STRAVA_CONNECTION = { provider: 'strava' as const, @@ -1246,11 +1260,42 @@ describe('Account profile recovery', () => { }); const STRAVA_ACTIVITY_FAILURE = 'We could not load your Strava activity right now. Please try again later.'; +const STRAVA_CONNECT_FAILURE = 'We could not start your Strava connection. Please try again.'; function renderActualStravaSection() { return render(); } +function installStravaLocationRecorder() { + const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location'); + if (originalDescriptor === undefined) throw new Error('missing synthetic Location descriptor'); + let assignedHref = 'https://example.test/account'; + const replacement = { + origin: 'https://example.test', + get href() { + return assignedHref; + }, + set href(value: string) { + assignedHref = String(value); + }, + }; + Reflect.deleteProperty(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + enumerable: true, + value: replacement, + }); + return { + get assignedHref() { + return assignedHref; + }, + restore() { + Reflect.deleteProperty(window, 'location'); + Object.defineProperty(window, 'location', originalDescriptor); + }, + }; +} + function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -1288,6 +1333,284 @@ function stravaStatsFor(label: string, athleteId: number) { }; } +describe('Strava authorization service boundary', () => { + const functions = { name: 'synthetic-functions' }; + + beforeEach(() => { + jest.clearAllMocks(); + (getFunctions as jest.Mock).mockReturnValue(functions); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('accepts one exact server challenge without browser state storage', async () => { + const callable = jest.fn().mockResolvedValue({ + data: STRAVA_AUTHORIZATION_CHALLENGE, + }); + const storageRead = jest.spyOn(Storage.prototype, 'getItem'); + const storageWrite = jest.spyOn(Storage.prototype, 'setItem'); + const storageRemove = jest.spyOn(Storage.prototype, 'removeItem'); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + const challenge = await ActualStravaService.stravaBeginAuthorization(app); + const authorizationUrl = ActualStravaService.buildStravaAuthorizeUrl( + 'synthetic-client-id', + 'https://example.test/account/strava/callback', + challenge, + ); + const parsedUrl = new URL(authorizationUrl); + + expect(challenge).toEqual(STRAVA_AUTHORIZATION_CHALLENGE); + expect(Object.isFrozen(challenge)).toBe(true); + expect(getFunctions).toHaveBeenCalledWith(app); + expect(httpsCallable).toHaveBeenCalledWith(functions, 'stravaBeginAuthorization'); + expect(callable).toHaveBeenCalledWith({}); + expect(parsedUrl.origin).toBe('https://www.strava.com'); + expect(parsedUrl.pathname).toBe('/oauth/authorize'); + expect(parsedUrl.searchParams.get('state')).toBe(STRAVA_AUTHORIZATION_STATE); + expect(parsedUrl.searchParams.get('client_id')).toBe('synthetic-client-id'); + expect(parsedUrl.searchParams.get('redirect_uri')) + .toBe('https://example.test/account/strava/callback'); + expect(storageRead).not.toHaveBeenCalled(); + expect(storageWrite).not.toHaveBeenCalled(); + expect(storageRemove).not.toHaveBeenCalled(); + }); + + test.each([ + ['missing state', { expiresInSeconds: 600 }], + ['short state', { state: 'A'.repeat(42), expiresInSeconds: 600 }], + ['long state', { state: 'A'.repeat(44), expiresInSeconds: 600 }], + ['non-base64url state', { state: `${'A'.repeat(42)}=`, expiresInSeconds: 600 }], + ['non-canonical state', { state: `${'A'.repeat(42)}B`, expiresInSeconds: 600 }], + ['wrong lifetime', { state: STRAVA_AUTHORIZATION_STATE, expiresInSeconds: 599 }], + ['extra field', { ...STRAVA_AUTHORIZATION_CHALLENGE, extra: true }], + ['an array', [STRAVA_AUTHORIZATION_CHALLENGE]], + ['a null-prototype record', Object.assign(Object.create(null), STRAVA_AUTHORIZATION_CHALLENGE)], + ])('rejects a malformed %s response before URL construction', async (_case, data) => { + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaBeginAuthorization(app)) + .rejects.toThrow('Invalid Strava authorization response.'); + expect(buildStravaAuthorizeUrl).not.toHaveBeenCalled(); + }); + + test('does not invoke a response field getter while rejecting it', async () => { + const stateGetter = jest.fn(() => STRAVA_AUTHORIZATION_STATE); + const data = Object.defineProperty( + { expiresInSeconds: 600 }, + 'state', + { enumerable: true, get: stateGetter }, + ); + const callable = jest.fn().mockResolvedValue({ data }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaBeginAuthorization(app)) + .rejects.toThrow('Invalid Strava authorization response.'); + expect(stateGetter).not.toHaveBeenCalled(); + }); + + test('sends the captured code and state in one exact exchange request', async () => { + const callable = jest.fn().mockResolvedValue({ + data: { ok: true, athleteId: null }, + }); + (httpsCallable as jest.Mock).mockReturnValue(callable); + + await expect(ActualStravaService.stravaExchangeCode( + app, + 'synthetic-code', + STRAVA_AUTHORIZATION_STATE, + )).resolves.toEqual({ ok: true, athleteId: null }); + + expect(getFunctions).toHaveBeenCalledWith(app); + expect(httpsCallable).toHaveBeenCalledWith(functions, 'stravaExchangeCode'); + expect(callable).toHaveBeenCalledTimes(1); + expect(callable).toHaveBeenCalledWith({ + code: 'synthetic-code', + state: STRAVA_AUTHORIZATION_STATE, + }); + }); +}); + +describe('Strava authorization start browser boundary', () => { + const originalClientId = process.env.REACT_APP_STRAVA_CLIENT_ID; + let locationRecorder: ReturnType | null; + + beforeEach(() => { + jest.clearAllMocks(); + locationRecorder = null; + process.env.REACT_APP_STRAVA_CLIENT_ID = 'synthetic-client-id'; + (useServiceLocator as jest.Mock).mockReturnValue({ + services: { firebaseResources: { app, firestore } }, + isReady: true, + }); + (getStravaConnection as jest.Mock).mockResolvedValue(null); + (stravaBeginAuthorization as jest.Mock).mockResolvedValue( + STRAVA_AUTHORIZATION_CHALLENGE, + ); + (buildStravaAuthorizeUrl as jest.Mock).mockImplementation( + ActualStravaService.buildStravaAuthorizeUrl, + ); + }); + + afterEach(() => { + locationRecorder?.restore(); + if (originalClientId === undefined) { + delete process.env.REACT_APP_STRAVA_CLIENT_ID; + } else { + process.env.REACT_APP_STRAVA_CLIENT_ID = originalClientId; + } + jest.restoreAllMocks(); + }); + + test('waits for begin and blocks a repeated activation while pending', async () => { + const begin = deferred(); + (stravaBeginAuthorization as jest.Mock).mockReturnValueOnce(begin.promise); + renderActualStravaSection(); + const button = await screen.findByRole('button', { name: 'Connect Strava' }); + + fireEvent.click(button); + fireEvent.click(screen.getByRole('button', { name: 'Connecting...' })); + + expect(screen.getByRole('button', { name: 'Connecting...' })).toBeDisabled(); + expect(stravaBeginAuthorization).toHaveBeenCalledWith(app); + expect(stravaBeginAuthorization).toHaveBeenCalledTimes(1); + expect(buildStravaAuthorizeUrl).not.toHaveBeenCalled(); + }); + + test('navigates only after one valid begin response', async () => { + const begin = deferred(); + locationRecorder = installStravaLocationRecorder(); + (stravaBeginAuthorization as jest.Mock).mockReturnValueOnce(begin.promise); + renderActualStravaSection(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect Strava' })); + expect(locationRecorder.assignedHref).toBe('https://example.test/account'); + await act(async () => begin.resolve(STRAVA_AUTHORIZATION_CHALLENGE)); + + const expectedUrl = ActualStravaService.buildStravaAuthorizeUrl( + 'synthetic-client-id', + 'https://example.test/account/strava/callback', + STRAVA_AUTHORIZATION_CHALLENGE, + ); + expect(buildStravaAuthorizeUrl).toHaveBeenCalledWith( + 'synthetic-client-id', + 'https://example.test/account/strava/callback', + STRAVA_AUTHORIZATION_CHALLENGE, + ); + expect(buildStravaAuthorizeUrl).toHaveBeenCalledTimes(1); + expect(locationRecorder.assignedHref).toBe(expectedUrl); + expect(screen.getByRole('button', { name: 'Connecting...' })).toBeDisabled(); + }); + + test('shows one accessible retry state without inspecting a begin rejection', async () => { + const messageGetter = jest.fn(() => { + throw new Error('begin-message-getter-canary'); + }); + (stravaBeginAuthorization as jest.Mock).mockRejectedValueOnce( + Object.defineProperty({}, 'message', { + configurable: true, + get: messageGetter, + }), + ); + (stravaBeginAuthorization as jest.Mock).mockReturnValueOnce(new Promise(() => { + // Keep the retry pending so no navigation is attempted. + })); + renderActualStravaSection(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect Strava' })); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(STRAVA_CONNECT_FAILURE); + expect(alert).toHaveAttribute('aria-live', 'assertive'); + expect(alert).toHaveAttribute('aria-atomic', 'true'); + expect(messageGetter).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('begin-message-getter-canary'); + const retry = screen.getByRole('button', { name: 'Try again' }); + expect(retry).toBeEnabled(); + fireEvent.click(retry); + expect(screen.getByRole('button', { name: 'Connecting...' })).toBeDisabled(); + expect(stravaBeginAuthorization).toHaveBeenCalledTimes(2); + expect(buildStravaAuthorizeUrl).not.toHaveBeenCalled(); + }); + + test('rejects a malformed begin response without navigating', async () => { + locationRecorder = installStravaLocationRecorder(); + const malformed = { + state: STRAVA_AUTHORIZATION_STATE, + expiresInSeconds: 599, + }; + (stravaBeginAuthorization as jest.Mock).mockResolvedValueOnce(malformed); + renderActualStravaSection(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect Strava' })); + + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CONNECT_FAILURE); + expect(buildStravaAuthorizeUrl).toHaveBeenCalledWith( + 'synthetic-client-id', + 'https://example.test/account/strava/callback', + malformed, + ); + expect(locationRecorder.assignedHref).toBe('https://example.test/account'); + expect(screen.getByRole('button', { name: 'Try again' })).toBeEnabled(); + }); + + test.each([ + ['UID', 'uid'], + ['service', 'service'], + ])('discards an obsolete begin success after a %s change', async (_case, change) => { + const begin = deferred(); + locationRecorder = installStravaLocationRecorder(); + (stravaBeginAuthorization as jest.Mock).mockReturnValueOnce(begin.promise); + const firstServices = { firebaseResources: { app, firestore } }; + const locator = { current: { services: firstServices, isReady: true } }; + (useServiceLocator as jest.Mock).mockImplementation(() => locator.current); + const view = render(); + fireEvent.click(await screen.findByRole('button', { name: 'Connect Strava' })); + + if (change === 'uid') { + view.rerender(); + } else { + locator.current = { + services: { firebaseResources: { app, firestore } }, + isReady: true, + }; + view.rerender(); + } + await waitFor(() => expect(getStravaConnection).toHaveBeenCalledTimes(2)); + await act(async () => begin.resolve(STRAVA_AUTHORIZATION_CHALLENGE)); + + expect(buildStravaAuthorizeUrl).not.toHaveBeenCalled(); + expect(locationRecorder.assignedHref).toBe('https://example.test/account'); + expect(stravaBeginAuthorization).toHaveBeenCalledTimes(1); + }); + + test('makes an obsolete begin rejection inert after unmount', async () => { + const begin = deferred(); + const messageGetter = jest.fn(() => { + throw new Error('unmounted-begin-getter-canary'); + }); + const consoleSpies = ['debug', 'error', 'info', 'log', 'warn'] + .map((method) => jest.spyOn(console, method as any).mockImplementation(() => undefined)); + (stravaBeginAuthorization as jest.Mock).mockReturnValueOnce(begin.promise); + const view = renderActualStravaSection(); + fireEvent.click(await screen.findByRole('button', { name: 'Connect Strava' })); + view.unmount(); + + await act(async () => begin.reject(Object.defineProperty({}, 'message', { + configurable: true, + get: messageGetter, + }))); + + expect(messageGetter).not.toHaveBeenCalled(); + expect(buildStravaAuthorizeUrl).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('unmounted-begin-getter-canary'); + consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + }); +}); + describe('Strava activity browser failure boundary', () => { beforeEach(() => { jest.clearAllMocks(); @@ -2174,7 +2497,6 @@ describe('Strava callback error boundary', () => { isAuthenticated: true, isLoading: false, }); - (verifyStravaState as jest.Mock).mockReturnValue(true); (stravaExchangeCode as jest.Mock).mockResolvedValue({ ok: true, athleteId: null, @@ -2187,7 +2509,7 @@ describe('Strava callback error boundary', () => { }); describe('current browser history cleanup', () => { - test('cleans native and Router state before hooks, state verification, or exchange', async () => { + test('cleans native and Router state before hooks or exchange', async () => { const routerObservation: CallbackRouterObservation = { current: null }; const observedLocations: Array<{ native: string; @@ -2215,10 +2537,6 @@ describe('Strava callback error boundary', () => { isLoading: false, }; }); - (verifyStravaState as jest.Mock).mockImplementation(() => { - observeLocation(); - return true; - }); (stravaExchangeCode as jest.Mock).mockImplementationOnce(() => { observeLocation(); return new Promise(() => { @@ -2260,8 +2578,11 @@ describe('Strava callback error boundary', () => { 'data-state-clean', 'true', ); - expect(verifyStravaState).toHaveBeenCalledWith('private-state-canary'); - expect(stravaExchangeCode).toHaveBeenCalledWith(app, 'private-code-canary'); + expect(stravaExchangeCode).toHaveBeenCalledWith( + app, + 'private-code-canary', + 'private-state-canary', + ); expect(document.body).not.toHaveTextContent( /private-code-canary|private-state-canary|private-fragment-canary|history-state-canary/, ); @@ -2302,7 +2623,6 @@ describe('Strava callback error boundary', () => { expect(observedLocations).not.toHaveLength(0); expect(observedLocations).toEqual(observedLocations.map(() => '')); expect(screen.getByText('Connecting your Strava...')).toBeInTheDocument(); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); expect(document.body).not.toHaveTextContent(/waiting-.*-canary/); }); @@ -2333,7 +2653,6 @@ describe('Strava callback error boundary', () => { expect(alert).toHaveAttribute('aria-atomic', 'true'); expect(useServiceLocator).not.toHaveBeenCalled(); expect(useAuth).not.toHaveBeenCalled(); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); expect(document.body).not.toHaveTextContent(/history-.*-canary/); }); @@ -2357,7 +2676,6 @@ describe('Strava callback error boundary', () => { expect(alert).toHaveTextContent(STRAVA_CALLBACK_FAILURE); expect(useServiceLocator).not.toHaveBeenCalled(); expect(useAuth).not.toHaveBeenCalled(); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); expect(document.body).not.toHaveTextContent(/history-noop-.*-canary/); }); @@ -2394,7 +2712,6 @@ describe('Strava callback error boundary', () => { expect(window.location.pathname).toBe('/silent-path-divergence'); expect(useServiceLocator).not.toHaveBeenCalled(); expect(useAuth).not.toHaveBeenCalled(); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); expect(document.body).not.toHaveTextContent(/history-divergent-.*-canary/); }); @@ -2411,7 +2728,6 @@ describe('Strava callback error boundary', () => { expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); expect(window.location.search).toBe(''); expect(window.location.hash).toBe(''); - expect(verifyStravaState).toHaveBeenCalledTimes(1); expect(stravaExchangeCode).toHaveBeenCalledTimes(1); expect(document.body).not.toHaveTextContent( /second-code-canary|second-state-canary|second-fragment-canary/, @@ -2442,7 +2758,6 @@ describe('Strava callback error boundary', () => { , ); - expect(verifyStravaState).toHaveBeenCalledTimes(1); expect(stravaExchangeCode).toHaveBeenCalledTimes(1); await act(async () => { finishExchange?.(); @@ -2519,7 +2834,6 @@ describe('Strava callback error boundary', () => { ); expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); - expect(verifyStravaState).toHaveBeenCalledTimes(1); expect(stravaExchangeCode).toHaveBeenCalledTimes(1); await act(async () => { settleExchange?.(); @@ -2594,7 +2908,6 @@ describe('Strava callback error boundary', () => { }); expect(screen.queryByText('Account destination')).not.toBeInTheDocument(); expect(screen.getByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); - expect(verifyStravaState).toHaveBeenCalledTimes(1); expect(stravaExchangeCode).toHaveBeenCalledTimes(1); }); @@ -2636,7 +2949,6 @@ describe('Strava callback error boundary', () => { renderStravaCallback(); expect(await screen.findByText('Connecting your Strava...')).toBeInTheDocument(); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); @@ -2653,7 +2965,6 @@ describe('Strava callback error boundary', () => { expect(await screen.findByText('You need to be signed in to connect Strava.')) .toBeInTheDocument(); expect(document.body).not.toHaveTextContent(/signed-out-provider-canary|hidden-code|hidden-state/); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); @@ -2667,41 +2978,32 @@ describe('Strava callback error boundary', () => { expect(alert).toHaveAttribute('aria-live', 'assertive'); expect(alert).toHaveAttribute('aria-atomic', 'true'); expect(document.body).not.toHaveTextContent(/private-query-canary|hidden-code|hidden-state/); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); expect(screen.getByRole('link', { name: 'Back to account' })) .toHaveAttribute('href', '/account'); }); - test('stops a missing code before state verification or exchange', async () => { + test('stops a missing code before exchange', async () => { renderStravaCallback('/account/strava/callback?state=synthetic-state'); expect(await screen.findByRole('alert')).toHaveTextContent( 'Missing authorization code from Strava.', ); - expect(verifyStravaState).not.toHaveBeenCalled(); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); - test('stops an invalid state before exchange', async () => { - (verifyStravaState as jest.Mock).mockReturnValue(false); - - renderStravaCallback(); + test.each([ + ['missing', '/account/strava/callback?code=synthetic-code'], + ['empty', '/account/strava/callback?code=synthetic-code&state='], + ])('stops a %s captured state before exchange', async (_case, entry) => { + renderStravaCallback(entry); - expect(await screen.findByRole('alert')).toHaveTextContent( - 'Security check failed (state mismatch). Please try connecting again.', - ); - expect(verifyStravaState).toHaveBeenCalledTimes(1); - expect(verifyStravaState).toHaveBeenCalledWith('synthetic-state'); + expect(await screen.findByRole('alert')).toHaveTextContent(STRAVA_CALLBACK_FAILURE); expect(stravaExchangeCode).not.toHaveBeenCalled(); }); - test('verifies state before one exact exchange and replaces the account route on success', async () => { + test('sends captured state in one exact exchange and replaces the account route', async () => { const calls: string[] = []; - (verifyStravaState as jest.Mock).mockImplementation(() => { - calls.push('verify'); - return true; - }); (stravaExchangeCode as jest.Mock).mockImplementation(async () => { calls.push('exchange'); return { ok: true, athleteId: null }; @@ -2710,9 +3012,12 @@ describe('Strava callback error boundary', () => { renderStravaCallback(); expect(await screen.findByText('Account destination')).toBeInTheDocument(); - expect(calls).toEqual(['verify', 'exchange']); - expect(verifyStravaState).toHaveBeenCalledWith('synthetic-state'); - expect(stravaExchangeCode).toHaveBeenCalledWith(app, 'synthetic-code'); + expect(calls).toEqual(['exchange']); + expect(stravaExchangeCode).toHaveBeenCalledWith( + app, + 'synthetic-code', + 'synthetic-state', + ); expect(stravaExchangeCode).toHaveBeenCalledTimes(1); expect(screen.getByTestId('callback-navigation-type')).toHaveTextContent('REPLACE'); }); @@ -2751,7 +3056,6 @@ describe('Strava callback error boundary', () => { expect(alert).toHaveTextContent(STRAVA_CALLBACK_FAILURE); expect(document.body).not.toHaveTextContent(/ordinary-provider-canary|message-getter-canary/); expect(screen.queryByText('Account destination')).not.toBeInTheDocument(); - expect(verifyStravaState).toHaveBeenCalledTimes(1); expect(stravaExchangeCode).toHaveBeenCalledTimes(1); consoleSpies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); }); diff --git a/src/pages/account/StravaCallback.tsx b/src/pages/account/StravaCallback.tsx index ca39c88..f1fe348 100644 --- a/src/pages/account/StravaCallback.tsx +++ b/src/pages/account/StravaCallback.tsx @@ -5,7 +5,7 @@ import { import { useServiceLocator } from '../../services/ServiceLocatorContext'; import { useAuth } from '../../services/hooks/useAuth'; import SEO from '../../components/SEO'; -import { stravaExchangeCode, verifyStravaState } from '../../services/strava/stravaService'; +import { stravaExchangeCode } from '../../services/strava/stravaService'; const STRAVA_CALLBACK_FAILURE = 'We could not connect Strava. Please return to My Account and try again.'; @@ -148,20 +148,15 @@ function CallbackAttempt({ snapshot }: { snapshot: CallbackSnapshot }) { setMessage('Missing authorization code from Strava.'); return; } - if (services === null || firebaseResources === null || app === null) return; - - decisionStartedRef.current = true; - try { - if (!verifyStravaState(snapshot.state)) { - setStatus('error'); - setMessage('Security check failed (state mismatch). Please try connecting again.'); - return; - } - } catch { + if (!snapshot.state) { + decisionStartedRef.current = true; setStatus('error'); - setMessage('Security check failed (state mismatch). Please try connecting again.'); + setMessage(STRAVA_CALLBACK_FAILURE); return; } + if (services === null || firebaseResources === null || app === null) return; + + decisionStartedRef.current = true; const run = Symbol('strava-callback-exchange'); runRef.current = run; @@ -174,7 +169,7 @@ function CallbackAttempt({ snapshot }: { snapshot: CallbackSnapshot }) { let exchange: ReturnType; try { - exchange = stravaExchangeCode(app, snapshot.code); + exchange = stravaExchangeCode(app, snapshot.code, snapshot.state); } catch { runRef.current = null; setStatus('error'); diff --git a/src/pages/account/StravaSection.tsx b/src/pages/account/StravaSection.tsx index e86707d..10efad2 100644 --- a/src/pages/account/StravaSection.tsx +++ b/src/pages/account/StravaSection.tsx @@ -5,6 +5,7 @@ import { formatDuration, getStravaConnection, metersToMiles, + stravaBeginAuthorization, stravaDisconnect, stravaFetchStats, StravaConnection, @@ -14,6 +15,7 @@ import { const STRAVA_CONNECTION_FAILURE = 'We could not check your Strava connection right now. Please refresh this page and try again.'; const STRAVA_ACTIVITY_FAILURE = 'We could not load your Strava activity right now. Please try again later.'; const STRAVA_DISCONNECT_FAILURE = 'We could not confirm the Strava disconnect. Please refresh this page before trying again.'; +const STRAVA_CONNECT_FAILURE = 'We could not start your Strava connection. Please try again.'; const objectIdentities = new WeakMap(); let nextObjectIdentity = 1; @@ -115,7 +117,18 @@ function Connected({ ); } -function Disconnected({ onConnect }: { onConnect: () => void }) { +function Disconnected({ + onConnect, + connecting, + connectFailed, +}: { + onConnect: () => void; + connecting: boolean; + connectFailed: boolean; +}) { + let connectLabel = 'Connect Strava'; + if (connecting) connectLabel = 'Connecting...'; + else if (connectFailed) connectLabel = 'Try again'; return (
@@ -128,11 +141,22 @@ function Disconnected({ onConnect }: { onConnect: () => void }) {
+ {connectFailed && ( +

+ {STRAVA_CONNECT_FAILURE} +

+ )}
); } @@ -153,6 +177,7 @@ type ActivityState = | { phase: 'resolved'; stats: StravaStatsResult }; type DisconnectState = 'idle' | 'pending' | 'failed'; +type ConnectState = 'idle' | 'pending' | 'failed'; function StravaAttempt({ uid, @@ -166,10 +191,12 @@ function StravaAttempt({ const [connectionState, setConnectionState] = useState({ phase: 'loading' }); const [activityState, setActivityState] = useState({ phase: 'idle' }); const [disconnectState, setDisconnectState] = useState('idle'); + const [connectState, setConnectState] = useState('idle'); const lifetimeRunRef = useRef(null); const connectionRunRef = useRef(null); const activityRunRef = useRef(null); const disconnectRunRef = useRef(null); + const connectRunRef = useRef(null); useEffect(() => { const run = Symbol('strava-lifetime'); @@ -179,6 +206,7 @@ function StravaAttempt({ connectionRunRef.current = null; activityRunRef.current = null; disconnectRunRef.current = null; + connectRunRef.current = null; }; }, []); @@ -247,15 +275,40 @@ function StravaAttempt({ }; }, [app, connectionState]); - function handleConnect() { + async function handleConnect() { + if (connectRunRef.current !== null) return; const clientId = process.env.REACT_APP_STRAVA_CLIENT_ID; if (!clientId) { // eslint-disable-next-line no-alert alert('Strava is not configured yet. Please contact a club admin.'); return; } - const redirectUri = `${window.location.origin}/account/strava/callback`; - window.location.href = buildStravaAuthorizeUrl(clientId, redirectUri); + const lifetimeRun = lifetimeRunRef.current; + if (lifetimeRun === null) return; + const connectRun = Symbol('strava-connect'); + connectRunRef.current = connectRun; + setConnectState('pending'); + try { + const challenge = await stravaBeginAuthorization(app); + if ( + lifetimeRunRef.current !== lifetimeRun + || connectRunRef.current !== connectRun + ) return; + const redirectUri = `${window.location.origin}/account/strava/callback`; + const authorizationUrl = buildStravaAuthorizeUrl(clientId, redirectUri, challenge); + if ( + lifetimeRunRef.current !== lifetimeRun + || connectRunRef.current !== connectRun + ) return; + window.location.href = authorizationUrl; + } catch { + if ( + lifetimeRunRef.current !== lifetimeRun + || connectRunRef.current !== connectRun + ) return; + connectRunRef.current = null; + setConnectState('failed'); + } } async function handleDisconnect() { @@ -307,7 +360,11 @@ function StravaAttempt({ /> )} {connectionState.phase === 'disconnected' && ( - + )} {connectionState.phase === 'unavailable' && (

diff --git a/src/services/strava/stravaService.ts b/src/services/strava/stravaService.ts index 78f6dd1..e85daf1 100644 --- a/src/services/strava/stravaService.ts +++ b/src/services/strava/stravaService.ts @@ -5,7 +5,13 @@ import { import { getFunctions, httpsCallable } from 'firebase/functions'; const STRAVA_AUTHORIZE = 'https://www.strava.com/oauth/authorize'; -const STATE_STORAGE_KEY = 'mprc_strava_oauth_state'; +const STRAVA_STATE_PATTERN = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/; +const STRAVA_AUTHORIZATION_LIFETIME_SECONDS = 600; + +export type StravaAuthorizationChallenge = Readonly<{ + state: string; + expiresInSeconds: 600; +}>; export interface StravaConnection { provider: 'strava'; @@ -47,15 +53,51 @@ export interface StravaStatsResult { } | null; } -function randomState() { - const buf = new Uint8Array(16); - crypto.getRandomValues(buf); - return Array.from(buf).map((b) => b.toString(16).padStart(2, '0')).join(''); +function readStravaAuthorizationChallenge(value: unknown): StravaAuthorizationChallenge { + try { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('invalid'); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype) { + throw new Error('invalid'); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== 2 + || !keys.includes('state') + || !keys.includes('expiresInSeconds') + ) { + throw new Error('invalid'); + } + const stateDescriptor = Object.getOwnPropertyDescriptor(value, 'state'); + const expiryDescriptor = Object.getOwnPropertyDescriptor(value, 'expiresInSeconds'); + if ( + stateDescriptor === undefined + || expiryDescriptor === undefined + || !Object.prototype.hasOwnProperty.call(stateDescriptor, 'value') + || !Object.prototype.hasOwnProperty.call(expiryDescriptor, 'value') + || typeof stateDescriptor.value !== 'string' + || !STRAVA_STATE_PATTERN.test(stateDescriptor.value) + || expiryDescriptor.value !== STRAVA_AUTHORIZATION_LIFETIME_SECONDS + ) { + throw new Error('invalid'); + } + return Object.freeze({ + state: stateDescriptor.value, + expiresInSeconds: STRAVA_AUTHORIZATION_LIFETIME_SECONDS, + }); + } catch { + throw new Error('Invalid Strava authorization response.'); + } } -export function buildStravaAuthorizeUrl(clientId: string, redirectUri: string): string { - const state = randomState(); - sessionStorage.setItem(STATE_STORAGE_KEY, state); +export function buildStravaAuthorizeUrl( + clientId: string, + redirectUri: string, + challenge: unknown, +): string { + const { state } = readStravaAuthorizationChallenge(challenge); const params = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, @@ -67,19 +109,29 @@ export function buildStravaAuthorizeUrl(clientId: string, redirectUri: string): return `${STRAVA_AUTHORIZE}?${params.toString()}`; } -export function verifyStravaState(received: string | null): boolean { - const expected = sessionStorage.getItem(STATE_STORAGE_KEY); - sessionStorage.removeItem(STATE_STORAGE_KEY); - return !!expected && expected === received; +export async function stravaBeginAuthorization( + app: FirebaseApp, +): Promise { + const functions = getFunctions(app); + const callable = httpsCallable, unknown>( + functions, + 'stravaBeginAuthorization', + ); + const result = await callable({}); + return readStravaAuthorizationChallenge(result.data); } export async function stravaExchangeCode( app: FirebaseApp, code: string, + state: string, ): Promise<{ ok: boolean; athleteId: number | null }> { const functions = getFunctions(app); - const callable = httpsCallable<{ code: string }, any>(functions, 'stravaExchangeCode'); - const result = await callable({ code }); + const callable = httpsCallable<{ code: string; state: string }, any>( + functions, + 'stravaExchangeCode', + ); + const result = await callable({ code, state }); return result.data; }