(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;
}