Tools
Tools: Implement Garmin Connect OAuth2 Authentication in React Native with Expo
2026-01-23
0 views
admin
Implement Garmin Connect OAuth2 Authentication in React Native with Expo ## Introduction ## What we'll build ## Prerequisites ## Setting up the Garmin Developer Portal ## Step 1: Create a Garmin Developer Account ## Step 2: Register Your Application ## Step 3: Configure OAuth2 Redirect URI ## Creating the Expo App ## Installing Dependencies ## Configuring the App ## Implementing the OAuth2 Flow ## Creating the Configuration ## Creating OAuth2 Constants ## Defining TypeScript Types ## Implementing PKCE Utilities ## Creating API Utilities ## Implementing the Authentication Modal ## Creating the Main Hook ## Building the User Interface ## Running the App ## Testing the OAuth Flow ## Tips and Tricks ## Automatic Token Refresh ## Manual Token Refresh ## Handling Token Expiration ## Environment Variables ## Deep Link Testing ## Common Issues and Solutions ## "Invalid Redirect URI" ## Browser Doesn't Open ## Token Not Persisting ## CORS Errors on Web ## Architecture Overview ## State Management ## API Endpoints ## Further Resources ## Conclusion When building fitness or health-related mobile applications, integrating with Garmin Connect can unlock a wealth of user data. However, implementing OAuth2 authentication with PKCE (Proof Key for Code Exchange) can be challenging, especially when dealing with token management, automatic refresh, and secure storage. In this article, we'll walk through building a complete Garmin Connect authentication flow in a React Native app using Expo. We'll cover OAuth2 with PKCE, automatic token refresh, secure storage, and deep linking – all with a clean, minimalist UI. Our example app will feature: The complete code for this tutorial can be found in this GitHub repository. Before starting, you'll need: First, visit the Garmin Developer Portal and create an account if you don't have one already. This is critical – the redirect URI must exactly match what's configured in your app: ⚠️ Important: No trailing slashes or spaces! After registration, you'll receive: Save these securely – the Consumer Secret is only shown once! Let's start by creating a new Expo app with TypeScript: Install the required packages: Update app.json to include the deep link scheme: Create a configuration file for your Garmin credentials at config/garmin.config.ts: Create hooks/useConnectGarmin/configs/constants.ts: Create hooks/useConnectGarmin/garmin.type.ts: The PKCE (Proof Key for Code Exchange) flow requires generating secure random codes. Create hooks/useConnectGarmin/utils/pkce.ts: Create hooks/useConnectGarmin/garmin.util.ts with the API functions: Create hooks/useConnectGarmin/components/GarminAuthenticationModal.tsx: Now, let's create the main hook that ties everything together at hooks/useConnectGarmin/useConnectGarmin.ts. This hook will handle: Due to length constraints, I'll show the key parts: Create a clean, minimalist UI in your main component: Initial screen – ready to connect to Garmin Start your Expo development server: OAuth2 authentication in the in-app browser using expo-web-browser The app automatically refreshes your access token 5 minutes before it expires. You can monitor this in the console: Successfully connected – showing user ID and token information Users can manually refresh their token at any time by tapping the "Refresh Token" button. This is useful for: If the token refresh fails (e.g., refresh token expired), the app automatically: This ensures security and prevents using expired or invalid tokens. For production, use environment variables instead of hardcoding credentials: Important: Add .env.local to your .gitignore! You can test the deep link callback locally: Problem: Token exchange fails with 401 error Solution: Ensure the redirect URI in Garmin Developer Portal exactly matches: No trailing slashes, no spaces! Problem: Nothing happens when tapping "Connect with Garmin" Problem: User needs to log in every time the app restarts Solution: Check that AsyncStorage is properly installed and the token is being saved: Problem: OAuth flow fails when testing on web Solution: OAuth2 flows with PKCE are designed for native apps. For web apps, you'll need a different approach (authorization code flow with a backend). Our implementation follows a clean architecture pattern: The app uses React's useReducer for predictable state management with actions: The app interacts with these Garmin APIs: Implementing OAuth2 authentication with PKCE for Garmin Connect might seem daunting at first, but by breaking it down into manageable steps, it becomes straightforward. The key components are: This implementation provides a solid foundation for building fitness and health apps that integrate with Garmin Connect. You can extend it by: The complete, working code is available in the GitHub repository. Feel free to use it as a starting point for your own Garmin-integrated applications! If you have any questions or suggestions, please leave a comment below. Happy coding! 🚀 Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
garminauthapp://oauth/callback Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
garminauthapp://oauth/callback CODE_BLOCK:
garminauthapp://oauth/callback CODE_BLOCK:
npx create-expo-app -t expo-template-blank-typescript garmin-auth-app
cd garmin-auth-app Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
npx create-expo-app -t expo-template-blank-typescript garmin-auth-app
cd garmin-auth-app CODE_BLOCK:
npx create-expo-app -t expo-template-blank-typescript garmin-auth-app
cd garmin-auth-app CODE_BLOCK:
npx expo install expo-web-browser expo-crypto expo-linking
npm install @react-native-async-storage/async-storage Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
npx expo install expo-web-browser expo-crypto expo-linking
npm install @react-native-async-storage/async-storage CODE_BLOCK:
npx expo install expo-web-browser expo-crypto expo-linking
npm install @react-native-async-storage/async-storage CODE_BLOCK:
{ "expo": { "name": "garmin-auth-app", "slug": "garmin-auth-app", "scheme": "garminauthapp", "version": "1.0.0" // ... other config }
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
{ "expo": { "name": "garmin-auth-app", "slug": "garmin-auth-app", "scheme": "garminauthapp", "version": "1.0.0" // ... other config }
} CODE_BLOCK:
{ "expo": { "name": "garmin-auth-app", "slug": "garmin-auth-app", "scheme": "garminauthapp", "version": "1.0.0" // ... other config }
} CODE_BLOCK:
export const GARMIN_CONFIG = { CONSUMER_KEY: process.env.EXPO_PUBLIC_GARMIN_CONSUMER_KEY || "YOUR_CONSUMER_KEY", CONSUMER_SECRET: process.env.EXPO_PUBLIC_GARMIN_CONSUMER_SECRET || "YOUR_CONSUMER_SECRET", REDIRECT_URI: "garminauthapp://oauth/callback",
}; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
export const GARMIN_CONFIG = { CONSUMER_KEY: process.env.EXPO_PUBLIC_GARMIN_CONSUMER_KEY || "YOUR_CONSUMER_KEY", CONSUMER_SECRET: process.env.EXPO_PUBLIC_GARMIN_CONSUMER_SECRET || "YOUR_CONSUMER_SECRET", REDIRECT_URI: "garminauthapp://oauth/callback",
}; CODE_BLOCK:
export const GARMIN_CONFIG = { CONSUMER_KEY: process.env.EXPO_PUBLIC_GARMIN_CONSUMER_KEY || "YOUR_CONSUMER_KEY", CONSUMER_SECRET: process.env.EXPO_PUBLIC_GARMIN_CONSUMER_SECRET || "YOUR_CONSUMER_SECRET", REDIRECT_URI: "garminauthapp://oauth/callback",
}; CODE_BLOCK:
export const OAUTH2_CONFIG = { CODE_CHALLENGE_METHOD: "S256", // SHA-256 (required for PKCE) REDIRECT_URI: "garminauthapp://oauth/callback", SCOPE: "", // Empty scope for basic access
}; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
export const OAUTH2_CONFIG = { CODE_CHALLENGE_METHOD: "S256", // SHA-256 (required for PKCE) REDIRECT_URI: "garminauthapp://oauth/callback", SCOPE: "", // Empty scope for basic access
}; CODE_BLOCK:
export const OAUTH2_CONFIG = { CODE_CHALLENGE_METHOD: "S256", // SHA-256 (required for PKCE) REDIRECT_URI: "garminauthapp://oauth/callback", SCOPE: "", // Empty scope for basic access
}; CODE_BLOCK:
// OAuth2 PKCE data stored during authentication flow
export type GarminOAuth2State = { codeVerifier: string; codeChallenge: string; state: string;
}; // OAuth2 token response from Garmin
export type GarminTokenResponse = { access_token: string; token_type: string; expires_in: number; refresh_token: string; refresh_token_expires_in: number;
}; // Complete user token with user ID
export type GarminUserToken = { accessToken: string; refreshToken: string; expiresIn: number; refreshTokenExpiresIn?: number; userId: string;
}; // Storage keys for AsyncStorage
export const STORAGE_KEYS = { USER_TOKEN: "@garmin_user_token", ACCESS_TOKEN: "@garmin_access_token", REFRESH_TOKEN: "@garmin_refresh_token", USER_ID: "@garmin_user_id",
} as const; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// OAuth2 PKCE data stored during authentication flow
export type GarminOAuth2State = { codeVerifier: string; codeChallenge: string; state: string;
}; // OAuth2 token response from Garmin
export type GarminTokenResponse = { access_token: string; token_type: string; expires_in: number; refresh_token: string; refresh_token_expires_in: number;
}; // Complete user token with user ID
export type GarminUserToken = { accessToken: string; refreshToken: string; expiresIn: number; refreshTokenExpiresIn?: number; userId: string;
}; // Storage keys for AsyncStorage
export const STORAGE_KEYS = { USER_TOKEN: "@garmin_user_token", ACCESS_TOKEN: "@garmin_access_token", REFRESH_TOKEN: "@garmin_refresh_token", USER_ID: "@garmin_user_id",
} as const; CODE_BLOCK:
// OAuth2 PKCE data stored during authentication flow
export type GarminOAuth2State = { codeVerifier: string; codeChallenge: string; state: string;
}; // OAuth2 token response from Garmin
export type GarminTokenResponse = { access_token: string; token_type: string; expires_in: number; refresh_token: string; refresh_token_expires_in: number;
}; // Complete user token with user ID
export type GarminUserToken = { accessToken: string; refreshToken: string; expiresIn: number; refreshTokenExpiresIn?: number; userId: string;
}; // Storage keys for AsyncStorage
export const STORAGE_KEYS = { USER_TOKEN: "@garmin_user_token", ACCESS_TOKEN: "@garmin_access_token", REFRESH_TOKEN: "@garmin_refresh_token", USER_ID: "@garmin_user_id",
} as const; COMMAND_BLOCK:
import * as Crypto from "expo-crypto"; /** * Converts a Uint8Array to base64url string */
const uint8ArrayToBase64Url = (bytes: Uint8Array): string => { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } const base64 = btoa(binary); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]+$/g, "");
}; /** * Generates a cryptographically secure random string for PKCE code_verifier */
export const generateCodeVerifier = (): string => { const randomBytes = Crypto.getRandomBytes(32); return uint8ArrayToBase64Url(randomBytes);
}; /** * Generates the code_challenge from code_verifier using SHA256 */
export const generateCodeChallenge = async ( verifier: string,
): Promise<string> => { const hashHex = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, verifier, { encoding: Crypto.CryptoEncoding.HEX }, ); const hashBytes = new Uint8Array( hashHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), ); return uint8ArrayToBase64Url(hashBytes);
}; /** * Generates a random state parameter for CSRF protection */
export const generateState = (): string => { const randomBytesArray = Crypto.getRandomBytes(32); return Array.from(randomBytesArray) .map((byte) => byte.toString(16).padStart(2, "0")) .join("");
}; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import * as Crypto from "expo-crypto"; /** * Converts a Uint8Array to base64url string */
const uint8ArrayToBase64Url = (bytes: Uint8Array): string => { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } const base64 = btoa(binary); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]+$/g, "");
}; /** * Generates a cryptographically secure random string for PKCE code_verifier */
export const generateCodeVerifier = (): string => { const randomBytes = Crypto.getRandomBytes(32); return uint8ArrayToBase64Url(randomBytes);
}; /** * Generates the code_challenge from code_verifier using SHA256 */
export const generateCodeChallenge = async ( verifier: string,
): Promise<string> => { const hashHex = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, verifier, { encoding: Crypto.CryptoEncoding.HEX }, ); const hashBytes = new Uint8Array( hashHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), ); return uint8ArrayToBase64Url(hashBytes);
}; /** * Generates a random state parameter for CSRF protection */
export const generateState = (): string => { const randomBytesArray = Crypto.getRandomBytes(32); return Array.from(randomBytesArray) .map((byte) => byte.toString(16).padStart(2, "0")) .join("");
}; COMMAND_BLOCK:
import * as Crypto from "expo-crypto"; /** * Converts a Uint8Array to base64url string */
const uint8ArrayToBase64Url = (bytes: Uint8Array): string => { let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } const base64 = btoa(binary); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]+$/g, "");
}; /** * Generates a cryptographically secure random string for PKCE code_verifier */
export const generateCodeVerifier = (): string => { const randomBytes = Crypto.getRandomBytes(32); return uint8ArrayToBase64Url(randomBytes);
}; /** * Generates the code_challenge from code_verifier using SHA256 */
export const generateCodeChallenge = async ( verifier: string,
): Promise<string> => { const hashHex = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, verifier, { encoding: Crypto.CryptoEncoding.HEX }, ); const hashBytes = new Uint8Array( hashHex.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)), ); return uint8ArrayToBase64Url(hashBytes);
}; /** * Generates a random state parameter for CSRF protection */
export const generateState = (): string => { const randomBytesArray = Crypto.getRandomBytes(32); return Array.from(randomBytesArray) .map((byte) => byte.toString(16).padStart(2, "0")) .join("");
}; COMMAND_BLOCK:
import { GARMIN_CONFIG } from "../../config/garmin.config";
import { OAUTH2_CONFIG } from "./configs/constants";
import { GarminTokenResponse } from "./garmin.type"; /** * Builds the OAuth2 authorization URL for Garmin */
export const buildAuthorizationUrl = ( codeChallenge: string, state: string,
): string => { const searchParams = new URLSearchParams({ client_id: GARMIN_CONFIG.CONSUMER_KEY, response_type: "code", redirect_uri: OAUTH2_CONFIG.REDIRECT_URI, state: state, code_challenge: codeChallenge, code_challenge_method: OAUTH2_CONFIG.CODE_CHALLENGE_METHOD, }); return `https://connect.garmin.com/oauth2Confirm?${searchParams.toString()}`;
}; /** * Exchanges authorization code for access token */
export const exchangeCodeForToken = async ( code: string, codeVerifier: string, state: string,
): Promise<GarminTokenResponse> => { const url = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; const searchParams = new URLSearchParams({ grant_type: "authorization_code", redirect_uri: OAUTH2_CONFIG.REDIRECT_URI, code: code, state: state, code_verifier: codeVerifier, client_id: GARMIN_CONFIG.CONSUMER_KEY, client_secret: GARMIN_CONFIG.CONSUMER_SECRET, }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: searchParams.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} - ${errorText}`); } return await response.json();
}; /** * Fetches the Garmin user ID using the access token */
export const fetchGarminUserId = async ( accessToken: string,
): Promise<string> => { const response = await fetch( "https://apis.garmin.com/wellness-api/rest/user/id", { method: "GET", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, }, }, ); if (!response.ok) { throw new Error(`Failed to fetch user ID: ${response.status}`); } const data = await response.json(); return data.userId as string;
}; /** * Refreshes the access token using the refresh token */
export const refreshAccessToken = async ( refreshToken: string,
): Promise<GarminTokenResponse> => { const url = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; const searchParams = new URLSearchParams({ grant_type: "refresh_token", client_id: GARMIN_CONFIG.CONSUMER_KEY, client_secret: GARMIN_CONFIG.CONSUMER_SECRET, refresh_token: refreshToken, }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: searchParams.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token refresh failed: ${response.status} - ${errorText}`); } return await response.json();
}; /** * Disconnects user from Garmin by deregistering the user */
export const disconnectGarminUser = async ( accessToken: string,
): Promise<void> => { const url = "https://apis.garmin.com/wellness-api/rest/user/registration"; const response = await fetch(url, { method: "DELETE", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Disconnect failed: ${response.status} - ${errorText}`); }
}; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { GARMIN_CONFIG } from "../../config/garmin.config";
import { OAUTH2_CONFIG } from "./configs/constants";
import { GarminTokenResponse } from "./garmin.type"; /** * Builds the OAuth2 authorization URL for Garmin */
export const buildAuthorizationUrl = ( codeChallenge: string, state: string,
): string => { const searchParams = new URLSearchParams({ client_id: GARMIN_CONFIG.CONSUMER_KEY, response_type: "code", redirect_uri: OAUTH2_CONFIG.REDIRECT_URI, state: state, code_challenge: codeChallenge, code_challenge_method: OAUTH2_CONFIG.CODE_CHALLENGE_METHOD, }); return `https://connect.garmin.com/oauth2Confirm?${searchParams.toString()}`;
}; /** * Exchanges authorization code for access token */
export const exchangeCodeForToken = async ( code: string, codeVerifier: string, state: string,
): Promise<GarminTokenResponse> => { const url = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; const searchParams = new URLSearchParams({ grant_type: "authorization_code", redirect_uri: OAUTH2_CONFIG.REDIRECT_URI, code: code, state: state, code_verifier: codeVerifier, client_id: GARMIN_CONFIG.CONSUMER_KEY, client_secret: GARMIN_CONFIG.CONSUMER_SECRET, }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: searchParams.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} - ${errorText}`); } return await response.json();
}; /** * Fetches the Garmin user ID using the access token */
export const fetchGarminUserId = async ( accessToken: string,
): Promise<string> => { const response = await fetch( "https://apis.garmin.com/wellness-api/rest/user/id", { method: "GET", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, }, }, ); if (!response.ok) { throw new Error(`Failed to fetch user ID: ${response.status}`); } const data = await response.json(); return data.userId as string;
}; /** * Refreshes the access token using the refresh token */
export const refreshAccessToken = async ( refreshToken: string,
): Promise<GarminTokenResponse> => { const url = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; const searchParams = new URLSearchParams({ grant_type: "refresh_token", client_id: GARMIN_CONFIG.CONSUMER_KEY, client_secret: GARMIN_CONFIG.CONSUMER_SECRET, refresh_token: refreshToken, }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: searchParams.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token refresh failed: ${response.status} - ${errorText}`); } return await response.json();
}; /** * Disconnects user from Garmin by deregistering the user */
export const disconnectGarminUser = async ( accessToken: string,
): Promise<void> => { const url = "https://apis.garmin.com/wellness-api/rest/user/registration"; const response = await fetch(url, { method: "DELETE", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Disconnect failed: ${response.status} - ${errorText}`); }
}; COMMAND_BLOCK:
import { GARMIN_CONFIG } from "../../config/garmin.config";
import { OAUTH2_CONFIG } from "./configs/constants";
import { GarminTokenResponse } from "./garmin.type"; /** * Builds the OAuth2 authorization URL for Garmin */
export const buildAuthorizationUrl = ( codeChallenge: string, state: string,
): string => { const searchParams = new URLSearchParams({ client_id: GARMIN_CONFIG.CONSUMER_KEY, response_type: "code", redirect_uri: OAUTH2_CONFIG.REDIRECT_URI, state: state, code_challenge: codeChallenge, code_challenge_method: OAUTH2_CONFIG.CODE_CHALLENGE_METHOD, }); return `https://connect.garmin.com/oauth2Confirm?${searchParams.toString()}`;
}; /** * Exchanges authorization code for access token */
export const exchangeCodeForToken = async ( code: string, codeVerifier: string, state: string,
): Promise<GarminTokenResponse> => { const url = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; const searchParams = new URLSearchParams({ grant_type: "authorization_code", redirect_uri: OAUTH2_CONFIG.REDIRECT_URI, code: code, state: state, code_verifier: codeVerifier, client_id: GARMIN_CONFIG.CONSUMER_KEY, client_secret: GARMIN_CONFIG.CONSUMER_SECRET, }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: searchParams.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} - ${errorText}`); } return await response.json();
}; /** * Fetches the Garmin user ID using the access token */
export const fetchGarminUserId = async ( accessToken: string,
): Promise<string> => { const response = await fetch( "https://apis.garmin.com/wellness-api/rest/user/id", { method: "GET", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, }, }, ); if (!response.ok) { throw new Error(`Failed to fetch user ID: ${response.status}`); } const data = await response.json(); return data.userId as string;
}; /** * Refreshes the access token using the refresh token */
export const refreshAccessToken = async ( refreshToken: string,
): Promise<GarminTokenResponse> => { const url = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; const searchParams = new URLSearchParams({ grant_type: "refresh_token", client_id: GARMIN_CONFIG.CONSUMER_KEY, client_secret: GARMIN_CONFIG.CONSUMER_SECRET, refresh_token: refreshToken, }); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: searchParams.toString(), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token refresh failed: ${response.status} - ${errorText}`); } return await response.json();
}; /** * Disconnects user from Garmin by deregistering the user */
export const disconnectGarminUser = async ( accessToken: string,
): Promise<void> => { const url = "https://apis.garmin.com/wellness-api/rest/user/registration"; const response = await fetch(url, { method: "DELETE", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Disconnect failed: ${response.status} - ${errorText}`); }
}; COMMAND_BLOCK:
import React, { FunctionComponent, useEffect, useRef } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import * as WebBrowser from "expo-web-browser";
import { GarminUserToken } from "../garmin.type";
import { RequestState } from "../state/garmin.state"; // Warm up the browser for better performance
WebBrowser.maybeCompleteAuthSession(); type Props = { onHandleSuccess: (token: GarminUserToken) => void; authState: RequestState; userToken: GarminUserToken | undefined; authorizationUrl: string | null; showModal: boolean; cancelAuthentication: () => void; handleAuthorizationCallback: (code: string, state: string) => void;
}; export const GarminAuthenticationModal: FunctionComponent<Props> = ({ onHandleSuccess, authState, userToken, authorizationUrl, showModal, cancelAuthentication, handleAuthorizationCallback,
}) => { const successHandledRef = useRef(false); const browserOpenedRef = useRef(false); // Handle successful authentication useEffect(() => { if (authState === "success" && userToken && !successHandledRef.current) { successHandledRef.current = true; onHandleSuccess(userToken); } }, [authState, userToken, onHandleSuccess]); // Open browser when modal shows and URL is available useEffect(() => { const openBrowser = async () => { if (showModal && authorizationUrl && !browserOpenedRef.current) { browserOpenedRef.current = true; try { const result = await WebBrowser.openAuthSessionAsync( authorizationUrl, "garminauthapp://oauth/callback" ); if (result.type === "success" && result.url) { const url = new URL(result.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (code && state) { handleAuthorizationCallback(code, state); } else { cancelAuthentication(); } } else if (result.type === "cancel" || result.type === "dismiss") { cancelAuthentication(); } } catch (error) { console.error("[GarminAuth] Browser error:", error); cancelAuthentication(); } } }; openBrowser(); }, [showModal, authorizationUrl, handleAuthorizationCallback, cancelAuthentication]); // Reset when modal closes useEffect(() => { if (!showModal) { browserOpenedRef.current = false; successHandledRef.current = false; } }, [showModal]); if (authState === "loading" && showModal) { return ( <View style={styles.loadingOverlay}> <ActivityIndicator size="large" color="#007AFF" /> </View> ); } return null;
}; const styles = StyleSheet.create({ loadingOverlay: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0, 0, 0, 0.3)", zIndex: 9999, },
}); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import React, { FunctionComponent, useEffect, useRef } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import * as WebBrowser from "expo-web-browser";
import { GarminUserToken } from "../garmin.type";
import { RequestState } from "../state/garmin.state"; // Warm up the browser for better performance
WebBrowser.maybeCompleteAuthSession(); type Props = { onHandleSuccess: (token: GarminUserToken) => void; authState: RequestState; userToken: GarminUserToken | undefined; authorizationUrl: string | null; showModal: boolean; cancelAuthentication: () => void; handleAuthorizationCallback: (code: string, state: string) => void;
}; export const GarminAuthenticationModal: FunctionComponent<Props> = ({ onHandleSuccess, authState, userToken, authorizationUrl, showModal, cancelAuthentication, handleAuthorizationCallback,
}) => { const successHandledRef = useRef(false); const browserOpenedRef = useRef(false); // Handle successful authentication useEffect(() => { if (authState === "success" && userToken && !successHandledRef.current) { successHandledRef.current = true; onHandleSuccess(userToken); } }, [authState, userToken, onHandleSuccess]); // Open browser when modal shows and URL is available useEffect(() => { const openBrowser = async () => { if (showModal && authorizationUrl && !browserOpenedRef.current) { browserOpenedRef.current = true; try { const result = await WebBrowser.openAuthSessionAsync( authorizationUrl, "garminauthapp://oauth/callback" ); if (result.type === "success" && result.url) { const url = new URL(result.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (code && state) { handleAuthorizationCallback(code, state); } else { cancelAuthentication(); } } else if (result.type === "cancel" || result.type === "dismiss") { cancelAuthentication(); } } catch (error) { console.error("[GarminAuth] Browser error:", error); cancelAuthentication(); } } }; openBrowser(); }, [showModal, authorizationUrl, handleAuthorizationCallback, cancelAuthentication]); // Reset when modal closes useEffect(() => { if (!showModal) { browserOpenedRef.current = false; successHandledRef.current = false; } }, [showModal]); if (authState === "loading" && showModal) { return ( <View style={styles.loadingOverlay}> <ActivityIndicator size="large" color="#007AFF" /> </View> ); } return null;
}; const styles = StyleSheet.create({ loadingOverlay: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0, 0, 0, 0.3)", zIndex: 9999, },
}); COMMAND_BLOCK:
import React, { FunctionComponent, useEffect, useRef } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import * as WebBrowser from "expo-web-browser";
import { GarminUserToken } from "../garmin.type";
import { RequestState } from "../state/garmin.state"; // Warm up the browser for better performance
WebBrowser.maybeCompleteAuthSession(); type Props = { onHandleSuccess: (token: GarminUserToken) => void; authState: RequestState; userToken: GarminUserToken | undefined; authorizationUrl: string | null; showModal: boolean; cancelAuthentication: () => void; handleAuthorizationCallback: (code: string, state: string) => void;
}; export const GarminAuthenticationModal: FunctionComponent<Props> = ({ onHandleSuccess, authState, userToken, authorizationUrl, showModal, cancelAuthentication, handleAuthorizationCallback,
}) => { const successHandledRef = useRef(false); const browserOpenedRef = useRef(false); // Handle successful authentication useEffect(() => { if (authState === "success" && userToken && !successHandledRef.current) { successHandledRef.current = true; onHandleSuccess(userToken); } }, [authState, userToken, onHandleSuccess]); // Open browser when modal shows and URL is available useEffect(() => { const openBrowser = async () => { if (showModal && authorizationUrl && !browserOpenedRef.current) { browserOpenedRef.current = true; try { const result = await WebBrowser.openAuthSessionAsync( authorizationUrl, "garminauthapp://oauth/callback" ); if (result.type === "success" && result.url) { const url = new URL(result.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (code && state) { handleAuthorizationCallback(code, state); } else { cancelAuthentication(); } } else if (result.type === "cancel" || result.type === "dismiss") { cancelAuthentication(); } } catch (error) { console.error("[GarminAuth] Browser error:", error); cancelAuthentication(); } } }; openBrowser(); }, [showModal, authorizationUrl, handleAuthorizationCallback, cancelAuthentication]); // Reset when modal closes useEffect(() => { if (!showModal) { browserOpenedRef.current = false; successHandledRef.current = false; } }, [showModal]); if (authState === "loading" && showModal) { return ( <View style={styles.loadingOverlay}> <ActivityIndicator size="large" color="#007AFF" /> </View> ); } return null;
}; const styles = StyleSheet.create({ loadingOverlay: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", backgroundColor: "rgba(0, 0, 0, 0.3)", zIndex: 9999, },
}); COMMAND_BLOCK:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { GarminUserToken, STORAGE_KEYS } from "./garmin.type";
import { buildAuthorizationUrl, disconnectGarminUser, exchangeCodeForToken, fetchGarminUserId, refreshAccessToken,
} from "./garmin.util";
import { garminReducer, initialState } from "./state/garmin.state";
import { generateCodeChallenge, generateCodeVerifier, generateState,
} from "./utils/pkce"; // Refresh token 5 minutes before it expires
const REFRESH_BUFFER_MS = 5 * 60 * 1000; export const useConnectGarmin = () => { const [state, dispatch] = useReducer(garminReducer, initialState); const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); /** * Load stored token on mount */ useEffect(() => { const loadStoredToken = async () => { try { const storedTokenJson = await AsyncStorage.getItem( STORAGE_KEYS.USER_TOKEN, ); if (storedTokenJson) { const storedToken: GarminUserToken = JSON.parse(storedTokenJson); dispatch({ type: "loadStoredToken", token: storedToken }); } else { dispatch({ type: "cancelAuthentication" }); } } catch (error) { console.error("[GarminAuth] Failed to load stored token:", error); dispatch({ type: "cancelAuthentication" }); } }; loadStoredToken(); }, []); /** * Setup automatic token refresh */ useEffect(() => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; } if (!state.userToken || !state.tokenTimestamp) { return; } const expiresInMs = state.userToken.expiresIn * 1000; const timeUntilRefresh = expiresInMs - REFRESH_BUFFER_MS; if (timeUntilRefresh > 0) { console.log( `[GarminAuth] Token will refresh in ${Math.floor(timeUntilRefresh / 1000 / 60)} minutes`, ); refreshTimeoutRef.current = setTimeout(() => { console.log("[GarminAuth] Auto-refreshing token..."); refreshToken(); }, timeUntilRefresh) as unknown as NodeJS.Timeout; } else { console.log("[GarminAuth] Token expired, refreshing now..."); refreshToken(); } return () => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } }; }, [state.userToken, state.tokenTimestamp]); // ... other methods (startAuthentication, disconnect, refreshToken, etc.) return { startAuthentication, handleAuthorizationCallback, cancelAuthentication, disconnect, refreshToken, authState: state.authState, disconnectState: state.disconnectState, refreshState: state.refreshState, userToken: state.userToken, showModal: state.showModal, isLoadingStoredToken: state.isLoadingStoredToken, isConnected: !!state.userToken, authorizationUrl: state.oauth2State ? buildAuthorizationUrl( state.oauth2State.codeChallenge, state.oauth2State.state, ) : null, };
}; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { GarminUserToken, STORAGE_KEYS } from "./garmin.type";
import { buildAuthorizationUrl, disconnectGarminUser, exchangeCodeForToken, fetchGarminUserId, refreshAccessToken,
} from "./garmin.util";
import { garminReducer, initialState } from "./state/garmin.state";
import { generateCodeChallenge, generateCodeVerifier, generateState,
} from "./utils/pkce"; // Refresh token 5 minutes before it expires
const REFRESH_BUFFER_MS = 5 * 60 * 1000; export const useConnectGarmin = () => { const [state, dispatch] = useReducer(garminReducer, initialState); const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); /** * Load stored token on mount */ useEffect(() => { const loadStoredToken = async () => { try { const storedTokenJson = await AsyncStorage.getItem( STORAGE_KEYS.USER_TOKEN, ); if (storedTokenJson) { const storedToken: GarminUserToken = JSON.parse(storedTokenJson); dispatch({ type: "loadStoredToken", token: storedToken }); } else { dispatch({ type: "cancelAuthentication" }); } } catch (error) { console.error("[GarminAuth] Failed to load stored token:", error); dispatch({ type: "cancelAuthentication" }); } }; loadStoredToken(); }, []); /** * Setup automatic token refresh */ useEffect(() => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; } if (!state.userToken || !state.tokenTimestamp) { return; } const expiresInMs = state.userToken.expiresIn * 1000; const timeUntilRefresh = expiresInMs - REFRESH_BUFFER_MS; if (timeUntilRefresh > 0) { console.log( `[GarminAuth] Token will refresh in ${Math.floor(timeUntilRefresh / 1000 / 60)} minutes`, ); refreshTimeoutRef.current = setTimeout(() => { console.log("[GarminAuth] Auto-refreshing token..."); refreshToken(); }, timeUntilRefresh) as unknown as NodeJS.Timeout; } else { console.log("[GarminAuth] Token expired, refreshing now..."); refreshToken(); } return () => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } }; }, [state.userToken, state.tokenTimestamp]); // ... other methods (startAuthentication, disconnect, refreshToken, etc.) return { startAuthentication, handleAuthorizationCallback, cancelAuthentication, disconnect, refreshToken, authState: state.authState, disconnectState: state.disconnectState, refreshState: state.refreshState, userToken: state.userToken, showModal: state.showModal, isLoadingStoredToken: state.isLoadingStoredToken, isConnected: !!state.userToken, authorizationUrl: state.oauth2State ? buildAuthorizationUrl( state.oauth2State.codeChallenge, state.oauth2State.state, ) : null, };
}; COMMAND_BLOCK:
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { GarminUserToken, STORAGE_KEYS } from "./garmin.type";
import { buildAuthorizationUrl, disconnectGarminUser, exchangeCodeForToken, fetchGarminUserId, refreshAccessToken,
} from "./garmin.util";
import { garminReducer, initialState } from "./state/garmin.state";
import { generateCodeChallenge, generateCodeVerifier, generateState,
} from "./utils/pkce"; // Refresh token 5 minutes before it expires
const REFRESH_BUFFER_MS = 5 * 60 * 1000; export const useConnectGarmin = () => { const [state, dispatch] = useReducer(garminReducer, initialState); const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); /** * Load stored token on mount */ useEffect(() => { const loadStoredToken = async () => { try { const storedTokenJson = await AsyncStorage.getItem( STORAGE_KEYS.USER_TOKEN, ); if (storedTokenJson) { const storedToken: GarminUserToken = JSON.parse(storedTokenJson); dispatch({ type: "loadStoredToken", token: storedToken }); } else { dispatch({ type: "cancelAuthentication" }); } } catch (error) { console.error("[GarminAuth] Failed to load stored token:", error); dispatch({ type: "cancelAuthentication" }); } }; loadStoredToken(); }, []); /** * Setup automatic token refresh */ useEffect(() => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; } if (!state.userToken || !state.tokenTimestamp) { return; } const expiresInMs = state.userToken.expiresIn * 1000; const timeUntilRefresh = expiresInMs - REFRESH_BUFFER_MS; if (timeUntilRefresh > 0) { console.log( `[GarminAuth] Token will refresh in ${Math.floor(timeUntilRefresh / 1000 / 60)} minutes`, ); refreshTimeoutRef.current = setTimeout(() => { console.log("[GarminAuth] Auto-refreshing token..."); refreshToken(); }, timeUntilRefresh) as unknown as NodeJS.Timeout; } else { console.log("[GarminAuth] Token expired, refreshing now..."); refreshToken(); } return () => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); } }; }, [state.userToken, state.tokenTimestamp]); // ... other methods (startAuthentication, disconnect, refreshToken, etc.) return { startAuthentication, handleAuthorizationCallback, cancelAuthentication, disconnect, refreshToken, authState: state.authState, disconnectState: state.disconnectState, refreshState: state.refreshState, userToken: state.userToken, showModal: state.showModal, isLoadingStoredToken: state.isLoadingStoredToken, isConnected: !!state.userToken, authorizationUrl: state.oauth2State ? buildAuthorizationUrl( state.oauth2State.codeChallenge, state.oauth2State.state, ) : null, };
}; COMMAND_BLOCK:
import { useConnectGarmin } from '@/hooks/useConnectGarmin/useConnectGarmin';
import { GarminAuthenticationModal } from '@/hooks/useConnectGarmin/components/GarminAuthenticationModal';
import { useState } from 'react';
import { ActivityIndicator, Alert, ScrollView, StyleSheet, TouchableOpacity, View, Text,
} from 'react-native'; export default function HomeScreen() { const { startAuthentication, handleAuthorizationCallback, cancelAuthentication, disconnect, refreshToken, authState, disconnectState, refreshState, userToken, showModal, isLoadingStoredToken, isConnected, authorizationUrl, } = useConnectGarmin(); const [showTokenDetails, setShowTokenDetails] = useState(false); const handleConnect = async () => { await startAuthentication(); }; const handleDisconnect = async () => { Alert.alert( "Disconnect from Garmin", "Are you sure you want to disconnect from Garmin?", [ { text: "Cancel", style: "cancel" }, { text: "Disconnect", style: "destructive", onPress: async () => await disconnect(), }, ] ); }; if (isLoadingStoredToken) { return ( <View style={styles.container}> <ActivityIndicator size="large" color="#000" /> <Text style={styles.loadingText}>Loading Garmin data...</Text> </View> ); } return ( <ScrollView style={styles.scrollView}> <View style={styles.container}> <Text style={styles.title}>Garmin Connect</Text> {!isConnected ? ( <> <Text style={styles.infoText}> Connect to Garmin Connect to sync your fitness data. </Text> <TouchableOpacity style={[styles.button, styles.connectButton]} onPress={handleConnect} disabled={authState === "loading"} > {authState === "loading" ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.buttonText}>Connect with Garmin</Text> )} </TouchableOpacity> </> ) : ( <> <Text style={styles.successText}> ✓ Successfully connected to Garmin </Text> {userToken && ( <View style={styles.tokenContainer}> <Text style={styles.tokenTitle}>User Information</Text> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>User ID</Text> <Text style={styles.tokenValue}>{userToken.userId}</Text> </View> <TouchableOpacity onPress={() => setShowTokenDetails(!showTokenDetails)} style={styles.toggleButton} > <Text style={styles.toggleButtonText}> {showTokenDetails ? "▼ Hide token details" : "▶ Show token details"} </Text> </TouchableOpacity> {showTokenDetails && ( <> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Access Token</Text> <Text style={styles.tokenValue} numberOfLines={1}> {userToken.accessToken} </Text> </View> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Refresh Token</Text> <Text style={styles.tokenValue} numberOfLines={1}> {userToken.refreshToken} </Text> </View> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Expires In</Text> <Text style={styles.tokenValue}> {Math.floor(userToken.expiresIn / 60)} minutes </Text> </View> </> )} </View> )} <TouchableOpacity style={[styles.button, styles.refreshButton]} onPress={refreshToken} disabled={refreshState === "loading"} > {refreshState === "loading" ? ( <ActivityIndicator color="#000" /> ) : ( <Text style={styles.refreshButtonText}>Refresh Token</Text> )} </TouchableOpacity> <TouchableOpacity style={[styles.button, styles.disconnectButton]} onPress={handleDisconnect} disabled={disconnectState === "loading"} > {disconnectState === "loading" ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.buttonText}>Disconnect from Garmin</Text> )} </TouchableOpacity> </> )} </View> <GarminAuthenticationModal onHandleSuccess={() => console.log("Auth successful!")} authState={authState} userToken={userToken} authorizationUrl={authorizationUrl} showModal={showModal} cancelAuthentication={cancelAuthentication} handleAuthorizationCallback={handleAuthorizationCallback} /> </ScrollView> );
} const styles = StyleSheet.create({ scrollView: { flex: 1, backgroundColor: "#fff", }, container: { flex: 1, alignItems: "center", padding: 24, paddingTop: 80, backgroundColor: "#fff", }, title: { fontSize: 32, fontWeight: "bold", marginBottom: 40, }, infoText: { textAlign: "center", marginBottom: 40, fontSize: 16, lineHeight: 24, color: "#333", }, button: { paddingVertical: 16, paddingHorizontal: 40, borderRadius: 8, minWidth: 240, alignItems: "center", marginVertical: 10, }, connectButton: { backgroundColor: "#000", }, refreshButton: { backgroundColor: "#fff", borderWidth: 2, borderColor: "#000", marginTop: 12, }, disconnectButton: { backgroundColor: "#000", marginTop: 24, }, buttonText: { color: "#fff", fontSize: 16, fontWeight: "600", }, refreshButtonText: { color: "#000", fontSize: 16, fontWeight: "600", }, successText: { fontSize: 18, fontWeight: "600", marginBottom: 32, }, tokenContainer: { width: "100%", backgroundColor: "#f8f8f8", borderRadius: 12, padding: 24, marginBottom: 16, borderWidth: 1, borderColor: "#e0e0e0", }, tokenTitle: { marginBottom: 20, fontSize: 18, fontWeight: "600", }, tokenRow: { marginBottom: 20, paddingVertical: 12, paddingHorizontal: 16, backgroundColor: "#fff", borderRadius: 8, borderWidth: 1, borderColor: "#e8e8e8", }, tokenLabel: { fontSize: 12, fontWeight: "600", marginBottom: 8, color: "#666", textTransform: "uppercase", }, tokenValue: { fontSize: 14, fontFamily: "monospace", color: "#000", }, toggleButton: { paddingVertical: 12, marginVertical: 8, alignItems: "center", }, toggleButtonText: { color: "#000", fontSize: 14, fontWeight: "500", }, loadingText: { marginTop: 20, fontSize: 16, color: "#333", },
}); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
import { useConnectGarmin } from '@/hooks/useConnectGarmin/useConnectGarmin';
import { GarminAuthenticationModal } from '@/hooks/useConnectGarmin/components/GarminAuthenticationModal';
import { useState } from 'react';
import { ActivityIndicator, Alert, ScrollView, StyleSheet, TouchableOpacity, View, Text,
} from 'react-native'; export default function HomeScreen() { const { startAuthentication, handleAuthorizationCallback, cancelAuthentication, disconnect, refreshToken, authState, disconnectState, refreshState, userToken, showModal, isLoadingStoredToken, isConnected, authorizationUrl, } = useConnectGarmin(); const [showTokenDetails, setShowTokenDetails] = useState(false); const handleConnect = async () => { await startAuthentication(); }; const handleDisconnect = async () => { Alert.alert( "Disconnect from Garmin", "Are you sure you want to disconnect from Garmin?", [ { text: "Cancel", style: "cancel" }, { text: "Disconnect", style: "destructive", onPress: async () => await disconnect(), }, ] ); }; if (isLoadingStoredToken) { return ( <View style={styles.container}> <ActivityIndicator size="large" color="#000" /> <Text style={styles.loadingText}>Loading Garmin data...</Text> </View> ); } return ( <ScrollView style={styles.scrollView}> <View style={styles.container}> <Text style={styles.title}>Garmin Connect</Text> {!isConnected ? ( <> <Text style={styles.infoText}> Connect to Garmin Connect to sync your fitness data. </Text> <TouchableOpacity style={[styles.button, styles.connectButton]} onPress={handleConnect} disabled={authState === "loading"} > {authState === "loading" ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.buttonText}>Connect with Garmin</Text> )} </TouchableOpacity> </> ) : ( <> <Text style={styles.successText}> ✓ Successfully connected to Garmin </Text> {userToken && ( <View style={styles.tokenContainer}> <Text style={styles.tokenTitle}>User Information</Text> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>User ID</Text> <Text style={styles.tokenValue}>{userToken.userId}</Text> </View> <TouchableOpacity onPress={() => setShowTokenDetails(!showTokenDetails)} style={styles.toggleButton} > <Text style={styles.toggleButtonText}> {showTokenDetails ? "▼ Hide token details" : "▶ Show token details"} </Text> </TouchableOpacity> {showTokenDetails && ( <> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Access Token</Text> <Text style={styles.tokenValue} numberOfLines={1}> {userToken.accessToken} </Text> </View> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Refresh Token</Text> <Text style={styles.tokenValue} numberOfLines={1}> {userToken.refreshToken} </Text> </View> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Expires In</Text> <Text style={styles.tokenValue}> {Math.floor(userToken.expiresIn / 60)} minutes </Text> </View> </> )} </View> )} <TouchableOpacity style={[styles.button, styles.refreshButton]} onPress={refreshToken} disabled={refreshState === "loading"} > {refreshState === "loading" ? ( <ActivityIndicator color="#000" /> ) : ( <Text style={styles.refreshButtonText}>Refresh Token</Text> )} </TouchableOpacity> <TouchableOpacity style={[styles.button, styles.disconnectButton]} onPress={handleDisconnect} disabled={disconnectState === "loading"} > {disconnectState === "loading" ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.buttonText}>Disconnect from Garmin</Text> )} </TouchableOpacity> </> )} </View> <GarminAuthenticationModal onHandleSuccess={() => console.log("Auth successful!")} authState={authState} userToken={userToken} authorizationUrl={authorizationUrl} showModal={showModal} cancelAuthentication={cancelAuthentication} handleAuthorizationCallback={handleAuthorizationCallback} /> </ScrollView> );
} const styles = StyleSheet.create({ scrollView: { flex: 1, backgroundColor: "#fff", }, container: { flex: 1, alignItems: "center", padding: 24, paddingTop: 80, backgroundColor: "#fff", }, title: { fontSize: 32, fontWeight: "bold", marginBottom: 40, }, infoText: { textAlign: "center", marginBottom: 40, fontSize: 16, lineHeight: 24, color: "#333", }, button: { paddingVertical: 16, paddingHorizontal: 40, borderRadius: 8, minWidth: 240, alignItems: "center", marginVertical: 10, }, connectButton: { backgroundColor: "#000", }, refreshButton: { backgroundColor: "#fff", borderWidth: 2, borderColor: "#000", marginTop: 12, }, disconnectButton: { backgroundColor: "#000", marginTop: 24, }, buttonText: { color: "#fff", fontSize: 16, fontWeight: "600", }, refreshButtonText: { color: "#000", fontSize: 16, fontWeight: "600", }, successText: { fontSize: 18, fontWeight: "600", marginBottom: 32, }, tokenContainer: { width: "100%", backgroundColor: "#f8f8f8", borderRadius: 12, padding: 24, marginBottom: 16, borderWidth: 1, borderColor: "#e0e0e0", }, tokenTitle: { marginBottom: 20, fontSize: 18, fontWeight: "600", }, tokenRow: { marginBottom: 20, paddingVertical: 12, paddingHorizontal: 16, backgroundColor: "#fff", borderRadius: 8, borderWidth: 1, borderColor: "#e8e8e8", }, tokenLabel: { fontSize: 12, fontWeight: "600", marginBottom: 8, color: "#666", textTransform: "uppercase", }, tokenValue: { fontSize: 14, fontFamily: "monospace", color: "#000", }, toggleButton: { paddingVertical: 12, marginVertical: 8, alignItems: "center", }, toggleButtonText: { color: "#000", fontSize: 14, fontWeight: "500", }, loadingText: { marginTop: 20, fontSize: 16, color: "#333", },
}); COMMAND_BLOCK:
import { useConnectGarmin } from '@/hooks/useConnectGarmin/useConnectGarmin';
import { GarminAuthenticationModal } from '@/hooks/useConnectGarmin/components/GarminAuthenticationModal';
import { useState } from 'react';
import { ActivityIndicator, Alert, ScrollView, StyleSheet, TouchableOpacity, View, Text,
} from 'react-native'; export default function HomeScreen() { const { startAuthentication, handleAuthorizationCallback, cancelAuthentication, disconnect, refreshToken, authState, disconnectState, refreshState, userToken, showModal, isLoadingStoredToken, isConnected, authorizationUrl, } = useConnectGarmin(); const [showTokenDetails, setShowTokenDetails] = useState(false); const handleConnect = async () => { await startAuthentication(); }; const handleDisconnect = async () => { Alert.alert( "Disconnect from Garmin", "Are you sure you want to disconnect from Garmin?", [ { text: "Cancel", style: "cancel" }, { text: "Disconnect", style: "destructive", onPress: async () => await disconnect(), }, ] ); }; if (isLoadingStoredToken) { return ( <View style={styles.container}> <ActivityIndicator size="large" color="#000" /> <Text style={styles.loadingText}>Loading Garmin data...</Text> </View> ); } return ( <ScrollView style={styles.scrollView}> <View style={styles.container}> <Text style={styles.title}>Garmin Connect</Text> {!isConnected ? ( <> <Text style={styles.infoText}> Connect to Garmin Connect to sync your fitness data. </Text> <TouchableOpacity style={[styles.button, styles.connectButton]} onPress={handleConnect} disabled={authState === "loading"} > {authState === "loading" ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.buttonText}>Connect with Garmin</Text> )} </TouchableOpacity> </> ) : ( <> <Text style={styles.successText}> ✓ Successfully connected to Garmin </Text> {userToken && ( <View style={styles.tokenContainer}> <Text style={styles.tokenTitle}>User Information</Text> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>User ID</Text> <Text style={styles.tokenValue}>{userToken.userId}</Text> </View> <TouchableOpacity onPress={() => setShowTokenDetails(!showTokenDetails)} style={styles.toggleButton} > <Text style={styles.toggleButtonText}> {showTokenDetails ? "▼ Hide token details" : "▶ Show token details"} </Text> </TouchableOpacity> {showTokenDetails && ( <> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Access Token</Text> <Text style={styles.tokenValue} numberOfLines={1}> {userToken.accessToken} </Text> </View> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Refresh Token</Text> <Text style={styles.tokenValue} numberOfLines={1}> {userToken.refreshToken} </Text> </View> <View style={styles.tokenRow}> <Text style={styles.tokenLabel}>Expires In</Text> <Text style={styles.tokenValue}> {Math.floor(userToken.expiresIn / 60)} minutes </Text> </View> </> )} </View> )} <TouchableOpacity style={[styles.button, styles.refreshButton]} onPress={refreshToken} disabled={refreshState === "loading"} > {refreshState === "loading" ? ( <ActivityIndicator color="#000" /> ) : ( <Text style={styles.refreshButtonText}>Refresh Token</Text> )} </TouchableOpacity> <TouchableOpacity style={[styles.button, styles.disconnectButton]} onPress={handleDisconnect} disabled={disconnectState === "loading"} > {disconnectState === "loading" ? ( <ActivityIndicator color="#fff" /> ) : ( <Text style={styles.buttonText}>Disconnect from Garmin</Text> )} </TouchableOpacity> </> )} </View> <GarminAuthenticationModal onHandleSuccess={() => console.log("Auth successful!")} authState={authState} userToken={userToken} authorizationUrl={authorizationUrl} showModal={showModal} cancelAuthentication={cancelAuthentication} handleAuthorizationCallback={handleAuthorizationCallback} /> </ScrollView> );
} const styles = StyleSheet.create({ scrollView: { flex: 1, backgroundColor: "#fff", }, container: { flex: 1, alignItems: "center", padding: 24, paddingTop: 80, backgroundColor: "#fff", }, title: { fontSize: 32, fontWeight: "bold", marginBottom: 40, }, infoText: { textAlign: "center", marginBottom: 40, fontSize: 16, lineHeight: 24, color: "#333", }, button: { paddingVertical: 16, paddingHorizontal: 40, borderRadius: 8, minWidth: 240, alignItems: "center", marginVertical: 10, }, connectButton: { backgroundColor: "#000", }, refreshButton: { backgroundColor: "#fff", borderWidth: 2, borderColor: "#000", marginTop: 12, }, disconnectButton: { backgroundColor: "#000", marginTop: 24, }, buttonText: { color: "#fff", fontSize: 16, fontWeight: "600", }, refreshButtonText: { color: "#000", fontSize: 16, fontWeight: "600", }, successText: { fontSize: 18, fontWeight: "600", marginBottom: 32, }, tokenContainer: { width: "100%", backgroundColor: "#f8f8f8", borderRadius: 12, padding: 24, marginBottom: 16, borderWidth: 1, borderColor: "#e0e0e0", }, tokenTitle: { marginBottom: 20, fontSize: 18, fontWeight: "600", }, tokenRow: { marginBottom: 20, paddingVertical: 12, paddingHorizontal: 16, backgroundColor: "#fff", borderRadius: 8, borderWidth: 1, borderColor: "#e8e8e8", }, tokenLabel: { fontSize: 12, fontWeight: "600", marginBottom: 8, color: "#666", textTransform: "uppercase", }, tokenValue: { fontSize: 14, fontFamily: "monospace", color: "#000", }, toggleButton: { paddingVertical: 12, marginVertical: 8, alignItems: "center", }, toggleButtonText: { color: "#000", fontSize: 14, fontWeight: "500", }, loadingText: { marginTop: 20, fontSize: 16, color: "#333", },
}); CODE_BLOCK:
npx expo start Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
npx expo start CODE_BLOCK:
npx expo start CODE_BLOCK:
[GarminAuth] Token will refresh in 1435 minutes
[GarminAuth] Auto-refreshing token... Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
[GarminAuth] Token will refresh in 1435 minutes
[GarminAuth] Auto-refreshing token... CODE_BLOCK:
[GarminAuth] Token will refresh in 1435 minutes
[GarminAuth] Auto-refreshing token... COMMAND_BLOCK:
#### .env.local
EXPO_PUBLIC_GARMIN_CONSUMER_KEY=your_key_here
EXPO_PUBLIC_GARMIN_CONSUMER_SECRET=your_secret_here Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
#### .env.local
EXPO_PUBLIC_GARMIN_CONSUMER_KEY=your_key_here
EXPO_PUBLIC_GARMIN_CONSUMER_SECRET=your_secret_here COMMAND_BLOCK:
#### .env.local
EXPO_PUBLIC_GARMIN_CONSUMER_KEY=your_key_here
EXPO_PUBLIC_GARMIN_CONSUMER_SECRET=your_secret_here CODE_BLOCK:
xcrun simctl openurl booted "garminauthapp://oauth/callback?code=test&state=test" Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
xcrun simctl openurl booted "garminauthapp://oauth/callback?code=test&state=test" CODE_BLOCK:
xcrun simctl openurl booted "garminauthapp://oauth/callback?code=test&state=test" CODE_BLOCK:
adb shell am start -W -a android.intent.action.VIEW -d "garminauthapp://oauth/callback?code=test&state=test" Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
adb shell am start -W -a android.intent.action.VIEW -d "garminauthapp://oauth/callback?code=test&state=test" CODE_BLOCK:
adb shell am start -W -a android.intent.action.VIEW -d "garminauthapp://oauth/callback?code=test&state=test" CODE_BLOCK:
garminauthapp://oauth/callback Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
garminauthapp://oauth/callback CODE_BLOCK:
garminauthapp://oauth/callback CODE_BLOCK:
await AsyncStorage.setItem(STORAGE_KEYS.USER_TOKEN, JSON.stringify(userToken)); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
await AsyncStorage.setItem(STORAGE_KEYS.USER_TOKEN, JSON.stringify(userToken)); CODE_BLOCK:
await AsyncStorage.setItem(STORAGE_KEYS.USER_TOKEN, JSON.stringify(userToken)); COMMAND_BLOCK:
hooks/useConnectGarmin/
├── useConnectGarmin.ts # Main hook - orchestrates everything
├── garmin.type.ts # TypeScript type definitions
├── garmin.util.ts # API utility functions
├── components/
│ └── GarminAuthenticationModal.tsx # Handles OAuth browser flow
├── configs/
│ └── constants.ts # OAuth2 configuration
├── state/
│ ├── garmin.state.ts # Reducer for state management
│ └── garmin.action.ts # Action type definitions
└── utils/ └── pkce.ts # PKCE helper functions Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
hooks/useConnectGarmin/
├── useConnectGarmin.ts # Main hook - orchestrates everything
├── garmin.type.ts # TypeScript type definitions
├── garmin.util.ts # API utility functions
├── components/
│ └── GarminAuthenticationModal.tsx # Handles OAuth browser flow
├── configs/
│ └── constants.ts # OAuth2 configuration
├── state/
│ ├── garmin.state.ts # Reducer for state management
│ └── garmin.action.ts # Action type definitions
└── utils/ └── pkce.ts # PKCE helper functions COMMAND_BLOCK:
hooks/useConnectGarmin/
├── useConnectGarmin.ts # Main hook - orchestrates everything
├── garmin.type.ts # TypeScript type definitions
├── garmin.util.ts # API utility functions
├── components/
│ └── GarminAuthenticationModal.tsx # Handles OAuth browser flow
├── configs/
│ └── constants.ts # OAuth2 configuration
├── state/
│ ├── garmin.state.ts # Reducer for state management
│ └── garmin.action.ts # Action type definitions
└── utils/ └── pkce.ts # PKCE helper functions - OAuth2 PKCE authentication flow
- Automatic token refresh (5 minutes before expiration)
- Manual token refresh option
- Secure token storage with AsyncStorage
- Display of user information and tokens
- Disconnect functionality
- Clean black & white UI design - Node.js installed
- A Garmin Developer account
- Basic knowledge of React Native and TypeScript
- Expo CLI installed (npm install -g expo-cli) - Navigate to Garmin Connect API
- Click on "Register an Application"
- Fill in the application details: Application Name: Garmin Auth App Application Description: Your app description Application Type: Wellness
- Application Name: Garmin Auth App
- Application Description: Your app description
- Application Type: Wellness - Application Name: Garmin Auth App
- Application Description: Your app description
- Application Type: Wellness - Consumer Key (Client ID)
- Consumer Secret - OAuth2 flow initiation
- Token exchange
- Automatic token refresh
- Token storage and loading
- Disconnect functionality - i for iOS simulator
- a for Android emulator
- Scan the QR code with Expo Go app - Connect: Tap "Connect with Garmin"
- Authenticate: The browser opens automatically with Garmin's login page
- Authorize: Sign in to your Garmin account and authorize the app
- Return: You're automatically redirected back to the app
- Success: Your user ID and tokens are displayed - Testing the refresh flow
- Ensuring the token is up-to-date before making API calls
- Recovering from a failed automatic refresh - Clears all stored tokens
- Logs the user out
- Requires re-authentication - Make sure expo-web-browser is installed: npx expo install expo-web-browser
- Restart the Expo development server
- Check the console for errors - initAuth - Start authentication flow
- authSuccess - Authorization code received
- tokenLoading - Fetching token
- tokenSuccess - Token obtained successfully
- refreshTokenLoading - Refreshing token
- refreshTokenSuccess - Token refreshed
- disconnectSuccess - User disconnected
- loadStoredToken - Loaded token from storage - Garmin Connect API Documentation
- OAuth2 PKCE Specification (RFC 7636)
- Expo Web Browser Documentation
- Expo Linking Documentation
- Complete example repository - PKCE Flow: Secure authentication without exposing secrets
- Deep Linking: Seamless return to the app after authentication
- Token Management: Automatic refresh and secure storage
- Clean UI: Minimalist design focused on functionality - Adding more Garmin API calls (activities, health metrics, etc.)
- Implementing data synchronization
- Adding offline support
- Building a comprehensive fitness tracking dashboard
how-totutorialguidedev.toaimlservershellnodegitgithub