OAuth 2.0 con Garmin Connect: Guía completa de implementación

Implementación paso a paso de OAuth 2.0 con Garmin Connect API para apps fitness. De 3 días de debugging a 8 horas con esta guía.

El Problema

Cuando construí ChuletonApp V2, necesitaba acceso a datos de fitness de mis usuarios. Strava había cerrado su API para casos de uso como el mío (mostrar rankings entre amigos), así que Garmin se convirtió en la única opción viable.

El problema real: Garmin usa OAuth 2.0 estándar, pero la documentación es dispersa y los detalles de implementación no están claros. Me tomó 3 días completos tener el flujo funcionando correctamente, incluyendo refresh tokens automático y manejo de errores.

Este post documenta exactamente cómo hacerlo bien desde el inicio.

La Solución

OAuth 2.0 con Garmin Connect siguiendo Authorization Code Flow. Esta implementación maneja:

  • Auto-refresh de tokens antes de expirar
  • Manejo robusto de errores (401, 429, 5xx)
  • Encriptación de credenciales
  • Sincronización automática de actividades

Tiempo de implementación siguiendo esta guía: 8 horas vs. 3 días descubriendo por tu cuenta.

Tech Stack

gem 'oauth2', '~> 2.0'      # Cliente OAuth
gem 'httparty', '~> 0.21'   # Llamadas API
gem 'dotenv-rails'          # Variables entorno

Setup Inicial

1. Registro en Garmin Developer Program

Proceso:

  1. Aplica: developer.garmin.com/gc-developer-program
  2. Tipo: “Business Developer” (gratis)
  3. Justificación: “Fitness competition app”
  4. Aprobación: 2-5 días

Recibes:

GARMIN_CLIENT_ID=abc123...
GARMIN_CLIENT_SECRET=xyz789...

Configura URLs callback:

http://localhost:3000/auth/garmin/callback
https://tuapp.com/auth/garmin/callback

2. Base de Datos

# db/migrate/xxx_create_garmin_connections.rb
class CreateGarminConnections < ActiveRecord::Migration[7.1]
  def change
    create_table :garmin_connections do |t|
      t.references :user, null: false, foreign_key: true
      t.string :access_token, null: false
      t.string :refresh_token, null: false
      t.datetime :expires_at, null: false
      t.string :garmin_user_id
      t.jsonb :token_metadata, default: {}
      t.datetime :last_synced_at

      t.timestamps
    end

    add_index :garmin_connections, :garmin_user_id, unique: true
  end
end

Detalles importantes:

  • access_token: Válido 1 hora
  • refresh_token: Válido 12 meses
  • expires_at: Para auto-refresh preventivo

Implementación Core

Servicio OAuth

# app/services/garmin_oauth_service.rb
class GarminOauthService
  AUTH_URL = 'https://connect.garmin.com/oauthConfirm'
  TOKEN_URL = 'https://connectapi.garmin.com/oauth-service/oauth/access_token'
  
  def initialize
    @client = OAuth2::Client.new(
      ENV['GARMIN_CLIENT_ID'],
      ENV['GARMIN_CLIENT_SECRET'],
      site: 'https://connectapi.garmin.com',
      authorize_url: AUTH_URL,
      token_url: TOKEN_URL
    )
  end

  def authorization_url(state)
    @client.auth_code.authorize_url(
      redirect_uri: callback_url,
      state: state,
      scope: 'ACTIVITY_READ'
    )
  end

  def exchange_code_for_token(code)
    token = @client.auth_code.get_token(code, redirect_uri: callback_url)
    
    {
      access_token: token.token,
      refresh_token: token.refresh_token,
      expires_at: Time.at(token.expires_at)
    }
  rescue OAuth2::Error => e
    Rails.logger.error "Garmin OAuth error: #{e.message}"
    raise
  end

  def refresh_access_token(refresh_token)
    new_token = OAuth2::AccessToken.from_hash(
      @client, 
      refresh_token: refresh_token
    ).refresh!

    {
      access_token: new_token.token,
      refresh_token: new_token.refresh_token,
      expires_at: Time.at(new_token.expires_at)
    }
  end

  private

  def callback_url
    Rails.env.production? ? 
      'https://tuapp.com/auth/garmin/callback' : 
      'http://localhost:3000/auth/garmin/callback'
  end
end

Controlador

# app/controllers/auth/garmin_controller.rb
class Auth::GarminController < ApplicationController
  before_action :authenticate_user!

  def connect
    state = SecureRandom.hex(16)
    session[:garmin_oauth_state] = state
    
    oauth_service = GarminOauthService.new
    redirect_to oauth_service.authorization_url(state), allow_other_host: true
  end

  def callback
    # Verificar state (protección CSRF)
    unless params[:state] == session[:garmin_oauth_state]
      redirect_to root_path, alert: 'Invalid OAuth state' and return
    end

    # Manejar errores
    if params[:error].present?
      redirect_to root_path, alert: "Authorization failed: #{params[:error]}" and return
    end

    # Intercambiar código por tokens
    oauth_service = GarminOauthService.new
    token_data = oauth_service.exchange_code_for_token(params[:code])

    # Guardar conexión
    current_user.create_garmin_connection!(
      access_token: token_data[:access_token],
      refresh_token: token_data[:refresh_token],
      expires_at: token_data[:expires_at]
    )

    # Sincronizar perfil
    GarminSyncJob.perform_later(current_user.garmin_connection.id)

    redirect_to settings_path, notice: 'Garmin connected!'
  rescue => e
    Rails.logger.error "Garmin callback error: #{e.message}"
    redirect_to root_path, alert: 'Failed to connect'
  ensure
    session.delete(:garmin_oauth_state)
  end

  def disconnect
    current_user.garmin_connection&.destroy
    redirect_to settings_path, notice: 'Garmin disconnected'
  end
end

Modelo con Auto-Refresh

# app/models/garmin_connection.rb
class GarminConnection < ApplicationRecord
  belongs_to :user

  encrypts :access_token, :refresh_token

  # CRÍTICO: Usa este método, no access_token directo
  def valid_access_token
    return access_token if expires_at > 5.minutes.from_now

    refresh_token!
    access_token
  end

  def refresh_token!
    oauth_service = GarminOauthService.new
    new_tokens = oauth_service.refresh_access_token(refresh_token)

    update!(
      access_token: new_tokens[:access_token],
      refresh_token: new_tokens[:refresh_token],
      expires_at: new_tokens[:expires_at]
    )

    Rails.logger.info "Refreshed token for user #{user_id}"
  rescue => e
    Rails.logger.error "Token refresh failed: #{e.message}"
    UserMailer.garmin_reconnect_required(user).deliver_later
    raise
  end
end

Cliente API

# app/services/garmin_api_client.rb
class GarminApiClient
  BASE_URL = 'https://apis.garmin.com/wellness-api/rest'

  def initialize(garmin_connection)
    @connection = garmin_connection
  end

  def fetch_activities(start_date: 30.days.ago, end_date: Date.today)
    response = HTTParty.get(
      "#{BASE_URL}/activities",
      headers: auth_headers,
      query: {
        uploadStartTimeInSeconds: start_date.to_i,
        uploadEndTimeInSeconds: end_date.to_i
      }
    )

    response.success? ? JSON.parse(response.body) : handle_error(response)
  end

  private

  def auth_headers
    { 'Authorization' => "Bearer #{@connection.valid_access_token}" }
  end

  def handle_error(response)
    case response.code
    when 401
      @connection.refresh_token!
      raise RetryableError, 'Token refreshed, retry'
    when 429
      raise RateLimitError, 'Rate limit exceeded'
    else
      raise APIError, "API error: #{response.code}"
    end
  end
end

Job de Sincronización

# app/jobs/garmin_sync_job.rb
class GarminSyncJob < ApplicationJob
  queue_as :default
  retry_on RetryableError, wait: 5.seconds, attempts: 3

  def perform(connection_id)
    connection = GarminConnection.find(connection_id)
    client = GarminApiClient.new(connection)

    activities = client.fetch_activities(start_date: 7.days.ago)

    activities.each do |data|
      Activity.find_or_create_by(
        user: connection.user,
        external_id: data['activityId'],
        provider: 'garmin'
      ) do |activity|
        activity.activity_type = data['activityType']
        activity.started_at = Time.at(data['startTimeInSeconds'])
        activity.distance = data['distanceInMeters']
        activity.duration = data['durationInSeconds']
      end
    end

    connection.update(last_synced_at: Time.current)
  end
end

Troubleshooting Real

Token inválido tras 1 hora

Síntoma: API devuelve 401 después de ~1 hora

Solución: Usa SIEMPRE valid_access_token, nunca access_token directo

# ❌ Malo
headers: { 'Authorization' => "Bearer #{connection.access_token}" }

# ✅ Bueno
headers: { 'Authorization' => "Bearer #{connection.valid_access_token}" }

Redirect URI mismatch

Síntoma: Error redirect_uri_mismatch en callback

Causa: URLs deben coincidir EXACTAMENTE

# Dashboard Garmin: https://tuapp.com/auth/garmin/callback
# ❌ https://www.tuapp.com/auth/garmin/callback (www)
# ❌ http://tuapp.com/auth/garmin/callback (http)
# ✅ https://tuapp.com/auth/garmin/callback

State CSRF inválido

Síntoma: “Invalid OAuth state” al volver

Solución: Verifica configuración de sesiones

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: '_app_session',
  same_site: :lax,  # Permite cookies en redirects
  secure: Rails.env.production?

Refresh token expirado

Solución: Job preventivo que renueva antes de expirar

# Ejecutar diario
GarminConnection
  .where('expires_at < ?', 7.days.from_now)
  .find_each(&:refresh_token!)

Resultados

Implementación en producción (ChuletonApp):

  • ⏱️ Tiempo dev: 8 horas (vs. 3 días experimentando)
  • 📈 Tasa éxito: 97.3% conexiones exitosas
  • 🔄 Auto-refresh: 100% automático
  • 🚫 Desconexiones: <1% mensual
  • Velocidad sync: ~2.5s para 7 días de actividades

Casos edge manejados:

  • Token expirado durante request → Auto-refresh + retry
  • Rate limit → Exponential backoff
  • Usuario revoca acceso → Notificación + cleanup

Lecciones Aprendidas

✅ Lo que funcionó

  1. Encriptación desde día 1: Rails 7 encrypts es trivial, cero overhead de rendimiento

  2. Auto-refresh transparente: Método valid_access_token centraliza lógica, ningún código llama access_token directamente

  3. Manejo errores granular: 401 → refresh automático, 429 → backoff, 5xx → retry con delay

❌ Errores que cometí

  1. No verificar state inicialmente: Primer deploy vulnerable a CSRF, agregué validación después

  2. Hardcodear redirect URL: Rompía entre dev/producción, movido a constante según Rails.env

  3. No manejar revocaciones: Usuario revocaba en Garmin, app no se enteraba. Agregué job de verificación cada 7 días

Configuración Completa

Variables de Entorno

# .env
GARMIN_CLIENT_ID=tu_client_id
GARMIN_CLIENT_SECRET=tu_secret

Rutas

# config/routes.rb
namespace :auth do
  get 'garmin', to: 'garmin#connect'
  get 'garmin/callback', to: 'garmin#callback'
  delete 'garmin/disconnect', to: 'garmin#disconnect'
end

Scopes Disponibles

'ACTIVITY_READ'           # Leer actividades
'WELLNESS_READ'           # Datos salud
'USER_READ'               # Perfil usuario
'ACTIVITY_WRITE'          # Crear actividades

# Múltiples scopes
scope: 'ACTIVITY_READ WELLNESS_READ'

Vista de Usuario

<!-- app/views/settings/index.html.erb -->
<% if current_user.garmin_connection.present? %>
  <div class="connected">
    ✅ Connected to Garmin
    <p>Last sync: <%= current_user.garmin_connection.last_synced_at&.strftime("%Y-%m-%d %H:%M") %></p>
    <%= button_to "Disconnect", auth_garmin_disconnect_path, method: :delete %>
    <%= button_to "Sync Now", sync_garmin_path, method: :post %>
  </div>
<% else %>
  <%= link_to "Connect with Garmin", auth_garmin_connect_path, class: "btn" %>
<% end %>

Próximos Pasos

Semana 1: Setup OAuth básico (pasos 1-4)
Semana 2: Auto-refresh y errores (pasos 5-6)
Semana 3: Sincronización actividades (paso 7)
Semana 4: Polish UI y testing

Recursos útiles:

Conclusión

OAuth con Garmin es directo siguiendo el flujo correcto. Las claves son auto-refresh transparente, manejo robusto de errores, y encriptación desde día 1.

El código compartido está en producción manejando ~100 usuarios activos sin incidencias. Implementándolo tendrás OAuth funcionando en días, no semanas.


Tech Stack: Ruby on Rails 7.1, OAuth2 gem, HTTParty, PostgreSQL
Tiempo de desarrollo: 8 horas
Líneas de código: ~450 LOC
Estado: En producción (ChuletonApp)