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
Initiate Connection
Your app requests an authorization URL for a specific provider and user
User Authorization
User is redirected to the provider’s login page to grant permissions
OAuth Callback
Provider redirects back with an authorization code
Token Exchange
Backend exchanges the code for access and refresh tokens
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:
Parameter Type Description providerstring Provider name (garmin, polar, suunto, strava, whoop)
Query Parameters:
Parameter Type Required Description user_idUUID Yes User’s unique identifier redirect_uristring No Custom 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:
Sign in with their provider credentials
Review requested permissions
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:
Validates the state (CSRF protection)
Exchanges the code for access and refresh tokens
Saves the connection to the database
Queues a sync task to fetch health data
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
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
User Denies Authorization
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
Connection Already Exists
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:
Status Description Action activeConnection working normally Continue syncing expiredToken expired and refresh failed User must reconnect revokedUser or admin disconnected Stop 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:
Revoke OAuth tokens with provider
Update connection status to revoked
Stop background syncing
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:
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