Skip to main content

Overview

The OAuth flow allows end users to securely connect their wearable devices and fitness platforms to your Open Wearables instance. Once connected, data automatically syncs in the background without requiring users to share their credentials with you.
Open Wearables handles the complete OAuth flow, including token management, refresh, and revocation.

How It Works

1

Initiate Connection

Your app requests an authorization URL for a specific provider and user
2

User Authorization

User is redirected to the provider’s login page to grant permissions
3

OAuth Callback

Provider redirects back with an authorization code
4

Token Exchange

Backend exchanges the code for access and refresh tokens
5

Data Sync

Background job automatically syncs user’s health data

OAuth Architecture

Provider Strategy Pattern

Each provider implements the BaseProviderStrategy interface:
# backend/app/services/providers/base_strategy.py
class BaseProviderStrategy(ABC):
    """Abstract base class for all fitness data providers."""
    
    def __init__(self):
        self.user_repo = UserRepository(User)
        self.connection_repo = UserConnectionRepository()
        self.workout_repo = EventRecordRepository(EventRecord)
        
        # Components initialized by subclasses
        self.oauth: BaseOAuthTemplate | None = None
        self.workouts: BaseWorkoutsTemplate | None = None
        self.data_247: Base247DataTemplate | None = None
    
    @property
    @abstractmethod
    def name(self) -> str:
        """Provider name (e.g., 'garmin', 'polar')"""
        pass
    
    @property
    @abstractmethod
    def api_base_url(self) -> str:
        """Base URL for provider's API"""
        pass

OAuth Template

The BaseOAuthTemplate provides reusable OAuth 2.0 logic:
# backend/app/services/providers/templates/base_oauth.py
class BaseOAuthTemplate(ABC):
    def __init__(
        self,
        user_repo: UserRepository,
        connection_repo: UserConnectionRepository,
        provider_name: str,
        api_base_url: str,
    ):
        self.provider_name = provider_name
        self.redis_client = get_redis_client()
        self.state_ttl = 900  # 15 minutes
    
    use_pkce: bool = False  # PKCE for extra security
    auth_method: AuthenticationMethod = AuthenticationMethod.BASIC_AUTH

Initiating OAuth Flow

Step 1: Get Authorization URL

Request an authorization URL from the API:
GET /api/v1/oauth/{provider}/authorize?user_id={user_id}&redirect_uri={redirect_uri}
Path Parameters:
ParameterTypeDescription
providerstringProvider name (garmin, polar, suunto, strava, whoop)
Query Parameters:
ParameterTypeRequiredDescription
user_idUUIDYesUser’s unique identifier
redirect_uristringNoCustom redirect after authorization
Response:
{
  "authorization_url": "https://connect.garmin.com/oauth/authorize?client_id=...&state=...",
  "state": "random-secure-state-token"
}
Example:
curl -X GET "http://localhost:8000/api/v1/oauth/garmin/authorize?user_id=550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer YOUR_API_KEY"

Step 2: Redirect User

Redirect the user to the authorization_url returned in Step 1:
// Frontend redirect
window.location.href = authorizationUrl;
The user will see the provider’s login page where they can:
  1. Sign in with their provider credentials
  2. Review requested permissions
  3. Authorize your application

Step 3: OAuth Callback

After authorization, the provider redirects to:
http://localhost:8000/api/v1/oauth/{provider}/callback?code=AUTH_CODE&state=STATE_TOKEN
The backend automatically:
  1. Validates the state (CSRF protection)
  2. Exchanges the code for access and refresh tokens
  3. Saves the connection to the database
  4. Queues a sync task to fetch health data
  5. Redirects to success page or custom redirect_uri
Callback Handler:
# backend/app/api/routes/v1/oauth.py
@router.get("/{provider}/callback")
async def oauth_callback(
    provider: ProviderName,
    db: DbSession,
    code: str | None = None,
    state: str | None = None,
    error: str | None = None,
):
    if error:
        return RedirectResponse(
            url=f"/api/v1/oauth/error?message={error}",
            status_code=303,
        )
    
    strategy = get_oauth_strategy(provider)
    oauth_state = strategy.oauth.handle_callback(db, code, state)
    
    # Schedule background sync
    sync_vendor_data.delay(
        user_id=str(oauth_state.user_id),
        providers=[provider.value],
    )
    
    # Redirect to success or custom URI
    if oauth_state.redirect_uri:
        return RedirectResponse(url=oauth_state.redirect_uri, status_code=303)
    
    return RedirectResponse(
        url=f"/api/v1/oauth/success?provider={provider.value}",
        status_code=303,
    )

State Management

OAuth state is stored in Redis with 15-minute TTL:
# State structure
class OAuthState(BaseModel):
    user_id: UUID
    provider: str
    redirect_uri: str | None = None
    created_at: datetime
Security features:
  • Random 32-byte state tokens (urlsafe Base64)
  • One-time use (deleted after validation)
  • Automatic expiration after 15 minutes
  • CSRF protection via state parameter

PKCE Support

Some providers use PKCE (Proof Key for Code Exchange) for additional security:
def _generate_pkce_pair(self) -> tuple[str, str]:
    """Generates PKCE code verifier and challenge."""
    code_verifier = secrets.token_urlsafe(43)
    challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
    code_challenge = urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
    
    return code_verifier, code_challenge
PKCE-enabled providers:
  • Strava
  • Polar
  • (More coming soon)

Token Management

Access Token Storage

Tokens are securely stored in the database:
# backend/app/models/user_connection.py
class UserConnection(BaseDbModel):
    id: Mapped[PrimaryKey[UUID]]
    user_id: Mapped[FKUser]
    provider: Mapped[str_50]
    access_token: Mapped[str | None]  # Encrypted at rest
    refresh_token: Mapped[str | None]  # Encrypted at rest
    token_expires_at: Mapped[datetime_tz | None]
    status: Mapped[ConnectionStatus]  # active, revoked, expired
    last_synced_at: Mapped[datetime_tz | None]

Token Refresh

Tokens are automatically refreshed before expiration:
def refresh_access_token(
    self, 
    db: DbSession, 
    user_id: UUID, 
    refresh_token: str
) -> OAuthTokenResponse:
    """Refreshes the access token using the refresh token."""
    data, headers = self._prepare_refresh_request(refresh_token)
    
    response = httpx.post(
        self.endpoints.token_url,
        data=data,
        headers=headers,
        timeout=30.0,
    )
    response.raise_for_status()
    token_response = OAuthTokenResponse.model_validate(response.json())
    
    # Update stored tokens
    connection = self.connection_repo.get_by_user_and_provider(
        db, user_id, self.provider_name
    )
    if connection:
        self.connection_repo.update_tokens(
            db,
            connection,
            token_response.access_token,
            token_response.refresh_token or refresh_token,
            token_response.expires_in,
        )
    
    return token_response

Connection Widget

The developer portal includes a pre-built connection widget:
// frontend/src/routes/widget.connect.tsx
export function ConnectWidget() {
  const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
  const { data: providers } = useOAuthProviders(true); // Only enabled
  
  const handleConnect = async (providerId: string) => {
    const response = await apiClient.get<AuthorizationURLResponse>(
      `/api/v1/oauth/${providerId}/authorize`,
      {
        params: {
          user_id: userId,
          redirect_uri: window.location.origin + '/dashboard'
        }
      }
    );
    
    // Redirect to provider
    window.location.href = response.authorization_url;
  };
  
  return (
    <div className="grid gap-4">
      {providers?.map((provider) => (
        <ProviderCard
          key={provider.provider}
          provider={provider}
          onConnect={() => handleConnect(provider.provider)}
        />
      ))}
    </div>
  );
}
Widget features:
  • Lists all enabled providers
  • Provider icons and branding
  • Connection status indicators
  • One-click connection flow
  • Responsive design
  • Embeddable in any app

Supported Providers

OAuth 2.0 Providers

Garmin

Auth: OAuth 1.0aScopes: Activities, sleep, heart rate, body compositionSpecial: Automatic 30-day backfill on connection

Polar

Auth: OAuth 2.0Scopes: Workouts, sleep, heart ratePKCE: Supported

Suunto

Auth: OAuth 2.0Scopes: Workouts, activitiesSpecial: Requires subscription key

Strava

Auth: OAuth 2.0Scopes: Activities, profilePKCE: Supported

Whoop

Auth: OAuth 2.0Scopes: Workouts, recovery, sleepSpecial: Read-only access

SDK-Based Providers

Apple Health

Auth: N/A (local device data)Integration: Mobile SDK with HealthKitSync: Background and manual

Background Sync

After successful OAuth connection, data syncing happens automatically:
# Celery task for syncing
@celery_app.task
def sync_vendor_data(
    user_id: str,
    start_date: str | None = None,
    end_date: str | None = None,
    providers: list[str] | None = None,
):
    """Background task to sync data from wearable providers."""
    db = next(get_db())
    
    # Get user connections
    connections = get_user_connections(db, UUID(user_id))
    
    # Filter by requested providers
    if providers:
        connections = [c for c in connections if c.provider in providers]
    
    # Sync each provider
    for connection in connections:
        try:
            strategy = ProviderFactory().get_provider(connection.provider)
            
            # Sync workouts
            if strategy.workouts:
                strategy.workouts.sync_workouts(db, UUID(user_id), start_date, end_date)
            
            # Sync continuous data (heart rate, steps, etc.)
            if strategy.data_247:
                strategy.data_247.sync_data(db, UUID(user_id), start_date, end_date)
            
        except Exception as e:
            log_and_capture_error(e, logger, f"Failed to sync {connection.provider}")
            continue
Sync frequency:
  • Initial sync: Immediately after OAuth
  • Ongoing: Every 15 minutes (configurable)
  • Manual: Via API or portal

Handling Errors

Common OAuth Errors

Error: access_deniedResponse: Redirect to error page with messageAction: Display friendly message and allow retry
Error: invalid_stateCause: State expired or doesn’t matchAction: Restart OAuth flow
Error: invalid_grantCause: Code already used or expiredAction: Restart OAuth flow
Behavior: Updates existing connection tokensNo Error: Seamlessly handles reconnection

Error Redirect

OAuth errors redirect to:
GET /api/v1/oauth/error?message=Error+description
Response:
{
  "success": false,
  "message": "OAuth authentication failed"
}

Connection Status

Connections can have three statuses:
StatusDescriptionAction
activeConnection working normallyContinue syncing
expiredToken expired and refresh failedUser must reconnect
revokedUser or admin disconnectedStop syncing
Check connection status:
GET /api/v1/users/{user_id}/connections

Disconnecting Providers

Users can disconnect providers at any time:
DELETE /api/v1/users/{user_id}/connections/{provider}
This will:
  1. Revoke OAuth tokens with provider
  2. Update connection status to revoked
  3. Stop background syncing
  4. Preserve historical data
Historical health data is retained after disconnection. To delete data, use the user delete endpoint.

Testing OAuth Locally

Provider Credentials

Set up OAuth credentials in backend/config/.env:
# Garmin
GARMIN_CLIENT_ID=your-client-id
GARMIN_CLIENT_SECRET=your-client-secret
GARMIN_REDIRECT_URI=http://localhost:8000/api/v1/oauth/garmin/callback

# Polar
POLAR_CLIENT_ID=your-client-id
POLAR_CLIENT_SECRET=your-client-secret
POLAR_REDIRECT_URI=http://localhost:8000/api/v1/oauth/polar/callback

Ngrok for Testing

Providers require HTTPS callbacks. Use ngrok for local testing:
ngrok http 8000
Update redirect URIs in provider settings to use ngrok URL:
https://your-subdomain.ngrok.io/api/v1/oauth/garmin/callback

Next Steps

Provider Support

Detailed information about each supported provider

Add New Provider

Guide for implementing additional providers

Build docs developers (and LLMs) love