DEV Community

Alexander Hodes
Alexander Hodes

Posted on

Implement Garmin Connect OAuth2 Authentication in React Native with Expo

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:

garminauthapp://oauth/callback
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

Installing Dependencies

Install the required packages:

npx expo install expo-web-browser expo-crypto expo-linking
npm install @react-native-async-storage/async-storage
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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",
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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("");
};
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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",
  },
});
Enter fullscreen mode Exit fullscreen mode

Initial screen with Connect button

Initial screen – ready to connect to Garmin

Running the App

Start your Expo development server:

npx expo start
Enter fullscreen mode Exit fullscreen mode

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:

[GarminAuth] Token will refresh in 1435 minutes
[GarminAuth] Auto-refreshing token...
Enter fullscreen mode Exit fullscreen mode

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:

#### .env.local
EXPO_PUBLIC_GARMIN_CONSUMER_KEY=your_key_here
EXPO_PUBLIC_GARMIN_CONSUMER_SECRET=your_secret_here
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Android Emulator:

adb shell am start -W -a android.intent.action.VIEW -d "garminauthapp://oauth/callback?code=test&state=test"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

await AsyncStorage.setItem(STORAGE_KEYS.USER_TOKEN, JSON.stringify(userToken));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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! 🚀

Top comments (0)