--- alexander - Implement Garmin Connect OAuth2 Authentication in React Native with Expo Zum Hauptinhalt springen
Home

Implement Garmin Connect OAuth2 Authentication in React Native with Expo

23. Januar 2026

reactnative oauth expo garmin

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

  1. Navigate to Garmin Connect API
  2. Click on "Register an Application"
  3. 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:

plaintext
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:

bash
npx create-expo-app -t expo-template-blank-typescript garmin-auth-app
cd garmin-auth-app

Installing Dependencies

Install the required packages:

bash
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:

json
{
  "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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 with Connect button

Initial screen – ready to connect to Garmin

Running the App

Start your Expo development server:

bash
npx expo start

Then press:

  • i for iOS simulator
  • a for Android emulator
  • Scan the QR code with Expo Go app

Testing the OAuth Flow

  1. Connect: Tap "Connect with Garmin"
  2. Authenticate: The browser opens automatically with Garmin's login page
  3. Authorize: Sign in to your Garmin account and authorize the app
  4. Return: You're automatically redirected back to the app
  5. Success: Your user ID and tokens are displayed

OAuth browser flow 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:

plaintext
[GarminAuth] Token will refresh in 1435 minutes
[GarminAuth] Auto-refreshing token...

Connected screen with token details 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:

  1. Clears all stored tokens
  2. Logs the user out
  3. Requires re-authentication

This ensures security and prevents using expired or invalid tokens.

Environment Variables

For production, use environment variables instead of hardcoding credentials:

bash
#### .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!

You can test the deep link callback locally:

iOS Simulator:

bash
xcrun simctl openurl booted "garminauthapp://oauth/callback?code=test&state=test"

Android Emulator:

bash
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:

plaintext
garminauthapp://oauth/callback

No trailing slashes, no spaces!

Browser Doesn't Open

Problem: Nothing happens when tapping "Connect with Garmin"

Solution:

  1. Make sure expo-web-browser is installed: npx expo install expo-web-browser
  2. Restart the Expo development server
  3. 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:

typescript
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:

plaintext
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:

EndpointMethodPurpose
https://connect.garmin.com/oauth2ConfirmGETOAuth2 authorization
https://diauth.garmin.com/di-oauth2-service/oauth/tokenPOSTToken exchange & refresh
https://apis.garmin.com/wellness-api/rest/user/idGETFetch user ID
https://apis.garmin.com/wellness-api/rest/user/registrationDELETEDisconnect user

Further Resources

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:

  1. PKCE Flow: Secure authentication without exposing secrets
  2. Deep Linking: Seamless return to the app after authentication
  3. Token Management: Automatic refresh and secure storage
  4. 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! 🚀