Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

OAuth Client (Fitness Providers)

Pierre acts as an oauth 2.0 client to connect to fitness providers (strava, fitbit, garmin, whoop, coros, terra) on behalf of users.

Overview

oauth2_client module (src/oauth2_client/):

  • pierre connects TO fitness providers as oauth client
  • handles user authorization and token management
  • supports pkce for enhanced security
  • multi-tenant credential isolation

separate from oauth2_server:

  • oauth2_server: mcp clients connect TO pierre
  • oauth2_client: pierre connects TO fitness providers

Supported Providers

provideroauth versionpkcestatusscopesimplementation
stravaoauth 2.0requiredactiveactivity:read_allsrc/providers/strava.rs
fitbitoauth 2.0requiredactiveactivity,heartrate,location,nutrition,profile,settings,sleep,social,weightsrc/providers/fitbit.rs
garminoauth 2.0requiredactivewellness:read,activities:readsrc/providers/garmin_provider.rs
whoopoauth 2.0requiredactiveread:profile,read:body_measurement,read:workout,read:sleep,read:recovery,read:cyclessrc/providers/whoop_provider.rs
corosoauth 2.0requiredactiveread:workouts,read:sleep,read:dailysrc/providers/coros_provider.rs
terraoauth 2.0requiredactivedevice-dependent (150+ wearables)src/providers/terra_provider.rs

note: providers require compile-time feature flags (provider-strava, provider-fitbit, provider-whoop, provider-terra, etc.).

Implementation: src/oauth2_client/mod.rs

Configuration

Environment Variables

strava:

export STRAVA_CLIENT_ID=your_client_id
export STRAVA_CLIENT_SECRET=your_client_secret
export STRAVA_REDIRECT_URI=http://localhost:8081/api/oauth/callback/strava  # dev

fitbit:

export FITBIT_CLIENT_ID=your_client_id
export FITBIT_CLIENT_SECRET=your_client_secret
export FITBIT_REDIRECT_URI=http://localhost:8081/api/oauth/callback/fitbit  # dev

garmin:

export GARMIN_CLIENT_ID=your_consumer_key
export GARMIN_CLIENT_SECRET=your_consumer_secret
export GARMIN_REDIRECT_URI=http://localhost:8081/api/oauth/callback/garmin  # dev

whoop:

export WHOOP_CLIENT_ID=your_client_id
export WHOOP_CLIENT_SECRET=your_client_secret
export WHOOP_REDIRECT_URI=http://localhost:8081/api/oauth/callback/whoop  # dev

coros:

export COROS_CLIENT_ID=your_client_id
export COROS_CLIENT_SECRET=your_client_secret
export COROS_REDIRECT_URI=http://localhost:8081/api/oauth/callback/coros  # dev

production: use https redirect urls:

export STRAVA_REDIRECT_URI=https://api.example.com/api/oauth/callback/strava
export FITBIT_REDIRECT_URI=https://api.example.com/api/oauth/callback/fitbit
export GARMIN_REDIRECT_URI=https://api.example.com/api/oauth/callback/garmin
export WHOOP_REDIRECT_URI=https://api.example.com/api/oauth/callback/whoop
export COROS_REDIRECT_URI=https://api.example.com/api/oauth/callback/coros

Constants: src/constants/oauth/providers.rs

Multi-tenant Architecture

Credential Hierarchy

Credentials resolved in priority order:

  1. tenant-specific credentials (database, encrypted)
  2. server-level credentials (environment variables)

Implementation: src/oauth2_client/tenant_client.rs

Tenant OAuth Client

TenantOAuthClient (src/oauth2_client/tenant_client.rs:36-49):

#![allow(unused)]
fn main() {
pub struct TenantOAuthClient {
    pub oauth_manager: Arc<Mutex<TenantOAuthManager>>,
}
}

features:

  • tenant-specific credential isolation
  • rate limiting per tenant per provider
  • automatic credential fallback to server config

Storing Tenant Credentials

via authorization request headers:

curl -X GET "http://localhost:8081/api/oauth/auth/strava/uuid" \
  -H "x-strava-client-id: tenant_client_id" \
  -H "x-strava-client_secret: tenant_client_secret"

Credentials stored encrypted in database, bound to tenant.

via api:

#![allow(unused)]
fn main() {
tenant_oauth_client.store_credentials(
    tenant_id,
    "strava",
    StoreCredentialsRequest {
        client_id: "tenant_client_id".to_string(),
        client_secret: "tenant_client_secret".to_string(),
        redirect_uri: "https://tenant.example.com/callback/strava".to_string(),
        scopes: vec!["activity:read_all".to_string()],
        configured_by: user_id,
    }
).await?;
}

Implementation: src/oauth2_client/tenant_client.rs:21-34

Rate Limiting

default limits (src/tenant/oauth_manager.rs):

  • strava: 1000 requests/day per tenant
  • fitbit: 150 requests/day per tenant
  • garmin: 1000 requests/day per tenant
  • whoop: 1000 requests/day per tenant
  • coros: 1000 requests/day per tenant

rate limit enforcement:

#![allow(unused)]
fn main() {
let (current_usage, daily_limit) = manager
    .check_rate_limit(tenant_id, provider)?;

if current_usage >= daily_limit {
    return Err(AppError::invalid_input(format!(
        "Tenant {} exceeded daily rate limit for {}: {}/{}",
        tenant_id, provider, current_usage, daily_limit
    )));
}
}

Implementation: src/oauth2_client/tenant_client.rs:64-75

OAuth Flow

Step 1: Initiate Authorization

via mcp tool:

user: "connect to strava"

via rest api:

curl -H "Authorization: Bearer <jwt>" \
  "http://localhost:8081/api/oauth/auth/strava/<user_id>"

flow manager (src/oauth2_client/flow_manager.rs:29-105):

  1. Validates user_id and tenant_id
  2. Processes optional tenant credentials from headers
  3. Generates authorization redirect url
  4. Returns http 302 redirect to provider

Step 2: User Authorizes at Provider

Pierre generates authorization url with:

  • pkce s256 challenge (128-character verifier)
  • state parameter for csrf protection ({user_id}:{random_uuid})
  • provider scopes (activity read, heartrate, etc.)

pkce generation (src/oauth2_client/client.rs:35-58):

#![allow(unused)]
fn main() {
pub fn generate() -> PkceParams {
    // 128-character random verifier (43-128 allowed by RFC)
    let code_verifier: String = (0..128)
        .map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
        .collect();

    // S256 challenge: base64url(sha256(code_verifier))
    let mut hasher = Sha256::new();
    hasher.update(code_verifier.as_bytes());
    let hash = hasher.finalize();
    let code_challenge = URL_SAFE_NO_PAD.encode(hash);

    PkceParams {
        code_verifier,
        code_challenge,
        code_challenge_method: "S256".into(),
    }
}
}

User authenticates with provider and grants permissions.

Step 3: OAuth Callback

Provider redirects to pierre callback:

http://localhost:8081/api/oauth/callback/strava?
  code=authorization_code&
  state=user_id:random_uuid

callback handling (src/routes/auth.rs):

  1. Validates state parameter (csrf protection)
  2. Extracts user_id from state
  3. Exchanges authorization code for access token
  4. Encrypts tokens with aes-256-gcm
  5. Stores in database (tenant-isolated)
  6. Renders success page

Step 4: Success Page

User sees branded html page:

  • provider name and connection status
  • user identifier
  • pierre logo
  • instructions to return to mcp client

Template: templates/oauth_success.html Renderer: src/oauth2_client/flow_manager.rs:350-393

Token Management

OAuth2Token Structure

OAuth2Token (src/oauth2_client/client.rs:61-82):

#![allow(unused)]
fn main() {
pub struct OAuth2Token {
    pub access_token: String,
    pub token_type: String,
    pub expires_at: Option<DateTime<Utc>>,
    pub refresh_token: Option<String>,
    pub scope: Option<String>,
}

impl OAuth2Token {
    pub fn is_expired(&self) -> bool {
        self.expires_at
            .is_some_and(|expires_at| expires_at <= Utc::now())
    }

    pub fn will_expire_soon(&self) -> bool {
        self.expires_at
            .is_some_and(|expires_at| expires_at <= Utc::now() + Duration::minutes(5))
    }
}
}

Storage

Tokens stored in users table with provider-specific columns:

-- strava example
strava_access_token     TEXT      -- encrypted
strava_refresh_token    TEXT      -- encrypted
strava_expires_at       TIMESTAMP
strava_scope            TEXT      -- comma-separated

encryption:

  • algorithm: aes-256-gcm
  • key: tenant-specific (derived from PIERRE_MASTER_ENCRYPTION_KEY)
  • unique key per tenant ensures isolation

Implementation: src/database/tokens.rs, src/crypto/, src/key_management.rs

Automatic Refresh

Pierre refreshes expired tokens before api requests:

refresh criteria:

  • access token expired or expiring within 5 minutes
  • refresh token available and valid

refresh flow (src/oauth2_client/client.rs:272-302):

#![allow(unused)]
fn main() {
pub async fn refresh_token(&self, refresh_token: &str) -> AppResult<OAuth2Token> {
    let params = [
        ("client_id", self.config.client_id.as_str()),
        ("client_secret", self.config.client_secret.as_str()),
        ("refresh_token", refresh_token),
        ("grant_type", "refresh_token"),
    ];

    let response: TokenResponse = self
        .client
        .post(&self.config.token_url)
        .form(&params)
        .send()
        .await?
        .json()
        .await?;

    Ok(Self::token_from_response(response))
}
}

Note: PKCE (code_verifier) is only used during authorization code exchange, not token refresh per RFC 7636.

Manual Token Operations

get token:

#![allow(unused)]
fn main() {
let token = database.get_oauth_token(user_id, "strava").await?;
}

update token:

#![allow(unused)]
fn main() {
database.update_oauth_token(
    user_id,
    "strava",
    OAuthToken {
        access_token: "new_token".to_string(),
        refresh_token: Some("new_refresh".to_string()),
        expires_at: Utc::now() + Duration::hours(6),
        scope: "activity:read_all".to_string(),
    }
).await?;
}

clear token (disconnect):

#![allow(unused)]
fn main() {
database.clear_oauth_token(user_id, "strava").await?;
}

Implementation: src/database/tokens.rs

Connection Status

check connection:

curl -H "Authorization: Bearer <jwt>" \
  http://localhost:8081/api/oauth/status

Response:

{
  "connected_providers": ["strava", "fitbit"],
  "providers": {
    "strava": {
      "connected": true,
      "expires_at": "2024-01-01T12:00:00Z",
      "scope": "activity:read_all",
      "auto_refresh": true
    },
    "fitbit": {
      "connected": true,
      "expires_at": "2024-01-01T14:00:00Z",
      "scope": "activity heartrate location",
      "auto_refresh": true
    },
    "garmin": {
      "connected": false
    }
  }
}

disconnect provider:

Use the disconnect_provider MCP tool to revoke a provider connection; there is no standalone REST DELETE /api/oauth/disconnect/{provider} endpoint.

Implementation: src/routes/auth.rs

Security Features

PKCE (Proof Key for Code Exchange)

implementation (src/oauth2_client/client.rs:27-59):

All provider oauth flows use pkce (rfc 7636):

code verifier:

  • 128 characters
  • cryptographically random
  • allowed characters: A-Z a-z 0-9 - . _ ~

code challenge:

  • sha256 hash of code verifier
  • base64url encoded (no padding)
  • method: s256 only

Prevents authorization code interception attacks.

State Parameter Validation

state format: {user_id}:{random_uuid}

validation (src/oauth2_client/flow_manager.rs:162-215):

  1. Extract user_id from state
  2. Verify user exists and belongs to tenant
  3. Ensure state not reused (single-use)

Invalid state results in authorization rejection.

Token Encryption

encryption (src/crypto/, src/key_management.rs):

  • algorithm: aes-256-gcm
  • key derivation:
    • master key: PIERRE_MASTER_ENCRYPTION_KEY (base64, 32 bytes)
    • tenant keys: derived from master key using tenant_id
    • unique key per tenant ensures isolation

encrypted fields:

  • access_token
  • refresh_token
  • client_secret (for tenant credentials)

Decryption requires:

  1. Correct master key
  2. Correct tenant_id
  3. Valid encryption nonce

Tenant Isolation

Oauth artifacts never shared between tenants:

  • credentials stored per tenant_id
  • tokens bound to user and tenant
  • rate limits enforced per tenant
  • database queries include tenant_id filter

Cross-tenant access prevented at database layer.

Implementation: src/tenant/oauth_manager.rs

Provider-specific Details

Strava

auth url: https://www.strava.com/oauth/authorize token url: https://www.strava.com/oauth/token api base: https://www.strava.com/api/v3

default scopes: activity:read_all

available scopes:

  • read - read public profile
  • activity:read - read non-private activities
  • activity:read_all - read all activities (public and private)
  • activity:write - create and update activities

rate limits:

  • 100 requests per 15 minutes per access token
  • 1000 requests per day per application

token lifetime:

  • access token: 6 hours
  • refresh token: permanent (until revoked)

Implementation: src/providers/strava.rs, src/providers/strava_provider.rs

Fitbit

auth url: https://www.fitbit.com/oauth2/authorize token url: https://api.fitbit.com/oauth2/token api base: https://api.fitbit.com/1

default scopes: activity heartrate location nutrition profile settings sleep social weight

scope details:

  • activity - steps, distance, calories, floors
  • heartrate - heart rate data
  • location - gps data
  • nutrition - food and water logs
  • profile - personal information
  • settings - user preferences
  • sleep - sleep logs
  • social - friends and leaderboards
  • weight - weight and body measurements

rate limits:

  • 150 requests per hour per user

token lifetime:

  • access token: 8 hours
  • refresh token: 1 year

Implementation: src/providers/fitbit.rs

Garmin

auth url: https://connect.garmin.com/oauthConfirm token url: https://connectapi.garmin.com/oauth-service/oauth/access_token api base: https://apis.garmin.com

default scopes: wellness:read activities:read

scope details:

  • wellness:read - health metrics (sleep, stress, hrv)
  • activities:read - workout and activity data
  • wellness:write - update health data
  • activities:write - create activities

rate limits:

  • varies by api endpoint
  • typically 1000 requests per day

token lifetime:

  • access token: 1 year
  • refresh token: not provided (long-lived access token)

Implementation: src/providers/garmin_provider.rs

WHOOP

auth url: https://api.prod.whoop.com/oauth/oauth2/auth token url: https://api.prod.whoop.com/oauth/oauth2/token api base: https://api.prod.whoop.com/developer/v1

default scopes: offline read:profile read:body_measurement read:workout read:sleep read:recovery read:cycles

scope details:

  • offline - offline access for token refresh
  • read:profile - user profile information
  • read:body_measurement - body measurements (weight, height)
  • read:workout - workout/activity data with strain scores
  • read:sleep - sleep sessions and metrics
  • read:recovery - daily recovery scores
  • read:cycles - physiological cycle data

rate limits:

  • varies by endpoint
  • standard api rate limiting applies

token lifetime:

  • access token: 1 hour
  • refresh token: long-lived (requires offline scope)

Implementation: src/providers/whoop_provider.rs

COROS

auth url: https://open.coros.com/oauth2/authorize (placeholder - update when API docs received) token url: https://open.coros.com/oauth2/token (placeholder - update when API docs received) api base: https://open.coros.com/api/v1 (placeholder - update when API docs received)

note: COROS API documentation is private. Apply for developer access at COROS Developer Portal.

default scopes: read:workouts read:sleep read:daily

scope details:

  • read:workouts - workout/activity data
  • read:sleep - sleep sessions and metrics
  • read:daily - daily summaries (steps, heart rate, recovery)

rate limits:

  • varies by endpoint
  • standard api rate limiting applies

token lifetime:

  • access token: varies (update when API docs received)
  • refresh token: varies (update when API docs received)

Implementation: src/providers/coros_provider.rs

Error Handling

Authorization Errors

Displayed on html error page (templates/oauth_error.html):

common errors:

  • access_denied - user denied authorization
  • invalid_request - missing or invalid parameters
  • invalid_scope - requested scope not available
  • server_error - provider api error

Renderer: src/oauth2_client/flow_manager.rs:329-347

Callback Errors

Returned as query parameters:

http://localhost:8081/api/oauth/callback/strava?
  error=access_denied&
  error_description=User+declined+authorization

Token Errors

expired token:

  • automatically refreshed before api request
  • no user action required

invalid refresh token:

  • user must re-authorize
  • connection status shows disconnected

rate limit exceeded:

{
  "error": "rate_limit_exceeded",
  "provider": "strava",
  "retry_after_secs": 3600,
  "limit_type": "daily quota"
}

Implementation: src/providers/errors.rs

Troubleshooting

Authorization Fails

symptom: redirect to provider fails or returns error

solutions:

  • verify provider credentials (client_id, client_secret)
  • check redirect_uri matches provider configuration exactly
  • ensure redirect_uri uses https in production
  • confirm provider api credentials active and approved

Callback Error: State Validation Failed

symptom: invalid state parameter error on callback

solutions:

  • ensure user_id in authorization request matches authenticated user
  • check user exists in database
  • verify tenant association correct
  • confirm no url encoding issues in state parameter

Token Refresh Fails

symptom: api requests fail with authentication error

solutions:

  • check refresh token not expired or revoked
  • verify provider credentials still valid
  • ensure network connectivity to provider api
  • re-authorize user to obtain new tokens

Rate Limit Exceeded

symptom: api requests rejected with rate limit error

solutions:

  • check current usage via tenant_oauth_manager
  • wait for daily reset (midnight utc)
  • request rate limit increase from provider
  • optimize api call patterns to reduce requests

Encryption Key Mismatch

symptom: cannot decrypt stored tokens

solutions:

  • verify PIERRE_MASTER_ENCRYPTION_KEY unchanged
  • check key is valid base64 (32 bytes decoded)
  • ensure key not rotated without token re-encryption
  • re-authorize users if key changed

Implementation References

  • oauth2 client: src/oauth2_client/client.rs
  • oauth flow manager: src/oauth2_client/flow_manager.rs
  • tenant client: src/oauth2_client/tenant_client.rs
  • tenant oauth manager: src/tenant/oauth_manager.rs
  • provider implementations: src/providers/
  • token storage: src/database/tokens.rs
  • route handlers: src/routes/auth.rs
  • templates: templates/oauth_success.html, templates/oauth_error.html

See Also