Implement Garmin Connect OAuth2 Authentication in React Native with Expo
Introduction
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.
What we'll build
Our example app will feature:
- 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
The complete code for this tutorial can be found in this GitHub repository.
Prerequisites
Before starting, you'll need:
- Node.js installed
- A Garmin Developer account
- Basic knowledge of React Native and TypeScript
- Expo CLI installed (
npm install -g expo-cli)
Setting up the Garmin Developer Portal
Step 1: Create a Garmin Developer Account
First, visit the Garmin Developer Portal and create an account if you don't have one already.
Step 2: Register Your Application
- 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
Step 3: Configure OAuth2 Redirect URI
This is critical – the redirect URI must exactly match what's configured in your app:
garminauthapp://oauth/callback
⚠️ Important: No trailing slashes or spaces!
After registration, you'll receive:
- Consumer Key (Client ID)
- Consumer Secret
Save these securely – the Consumer Secret is only shown once!
Creating the Expo App
Let's start by creating a new Expo app with TypeScript:
npx create-expo-app -t expo-template-blank-typescript garmin-auth-app
cd garmin-auth-app
Installing Dependencies
Install the required packages:
npx expo install expo-web-browser expo-crypto expo-linking
npm install @react-native-async-storage/async-storage
Configuring the App
Update app.json to include the deep link scheme:
{
"expo": {
"name": "garmin-auth-app",
"slug": "garmin-auth-app",
"scheme": "garminauthapp",
"version": "1.0.0"
// ... other config
}
}
Implementing the OAuth2 Flow
Creating the Configuration
Create a configuration file for your Garmin credentials at config/garmin.config.ts:
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",
};
Creating OAuth2 Constants
Create hooks/useConnectGarmin/configs/constants.ts:
export const OAUTH2_CONFIG = {
CODE_CHALLENGE_METHOD: "S256", // SHA-256 (required for PKCE)
REDIRECT_URI: "garminauthapp://oauth/callback",
SCOPE: "", // Empty scope for basic access
};
Defining TypeScript Types
Create hooks/useConnectGarmin/garmin.type.ts:
// 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;
Implementing PKCE Utilities
The PKCE (Proof Key for Code Exchange) flow requires generating secure random codes. Create hooks/useConnectGarmin/utils/pkce.ts:
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("");
};
Creating API Utilities
Create hooks/useConnectGarmin/garmin.util.ts with the API functions:
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}`);
}
};
Implementing the Authentication Modal
Create hooks/useConnectGarmin/components/GarminAuthenticationModal.tsx:
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,
},
});
Creating the Main Hook
Now, let's create the main hook that ties everything together at hooks/useConnectGarmin/useConnectGarmin.ts. This hook will handle:
- OAuth2 flow initiation
- Token exchange
- Automatic token refresh
- Token storage and loading
- Disconnect functionality
Due to length constraints, I'll show the key parts:
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,
};
};
Building the User Interface
Create a clean, minimalist UI in your main component:
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",
},
});
Initial screen – ready to connect to Garmin
Running the App
Start your Expo development server:
npx expo start
Then press:
-
ifor iOS simulator -
afor Android emulator - Scan the QR code with Expo Go app
Testing the OAuth Flow
- 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

OAuth2 authentication in the in-app browser using expo-web-browser
Tips and Tricks
Automatic Token Refresh
The app automatically refreshes your access token 5 minutes before it expires. You can monitor this in the console:
[GarminAuth] Token will refresh in 1435 minutes
[GarminAuth] Auto-refreshing token...

Successfully connected – showing user ID and token information
Manual Token Refresh
Users can manually refresh their token at any time by tapping the "Refresh Token" button. This is useful for:
- Testing the refresh flow
- Ensuring the token is up-to-date before making API calls
- Recovering from a failed automatic refresh
Handling Token Expiration
If the token refresh fails (e.g., refresh token expired), the app automatically:
- Clears all stored tokens
- Logs the user out
- Requires re-authentication
This ensures security and prevents using expired or invalid tokens.
Environment Variables
For production, use environment variables instead of hardcoding credentials:
#### .env.local
EXPO_PUBLIC_GARMIN_CONSUMER_KEY=your_key_here
EXPO_PUBLIC_GARMIN_CONSUMER_SECRET=your_secret_here
Important: Add .env.local to your .gitignore!
Deep Link Testing
You can test the deep link callback locally:
iOS Simulator:
xcrun simctl openurl booted "garminauthapp://oauth/callback?code=test&state=test"
Android Emulator:
adb shell am start -W -a android.intent.action.VIEW -d "garminauthapp://oauth/callback?code=test&state=test"
Common Issues and Solutions
"Invalid Redirect URI"
Problem: Token exchange fails with 401 error
Solution: Ensure the redirect URI in Garmin Developer Portal exactly matches:
garminauthapp://oauth/callback
No trailing slashes, no spaces!
Browser Doesn't Open
Problem: Nothing happens when tapping "Connect with Garmin"
Solution:
- Make sure
expo-web-browseris installed:npx expo install expo-web-browser - Restart the Expo development server
- Check the console for errors
Token Not Persisting
Problem: User needs to log in every time the app restarts
Solution: Check that AsyncStorage is properly installed and the token is being saved:
await AsyncStorage.setItem(STORAGE_KEYS.USER_TOKEN, JSON.stringify(userToken));
CORS Errors on Web
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).
Architecture Overview
Our implementation follows a clean architecture pattern:
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
State Management
The app uses React's useReducer for predictable state management with actions:
-
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
API Endpoints
The app interacts with these Garmin APIs:
| Endpoint | Method | Purpose |
|---|---|---|
https://connect.garmin.com/oauth2Confirm |
GET | OAuth2 authorization |
https://diauth.garmin.com/di-oauth2-service/oauth/token |
POST | Token exchange & refresh |
https://apis.garmin.com/wellness-api/rest/user/id |
GET | Fetch user ID |
https://apis.garmin.com/wellness-api/rest/user/registration |
DELETE | Disconnect user |
Further Resources
- Garmin Connect API Documentation
- OAuth2 PKCE Specification (RFC 7636)
- Expo Web Browser Documentation
- Expo Linking Documentation
- Complete example repository
Conclusion
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:
- 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
This implementation provides a solid foundation for building fitness and health apps that integrate with Garmin Connect. You can extend it by:
- Adding more Garmin API calls (activities, health metrics, etc.)
- Implementing data synchronization
- Adding offline support
- Building a comprehensive fitness tracking dashboard
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! 🚀

Top comments (0)