CorrConnectDocs
Early accessRequest access

Guides

Connect a user

The Quickstart did this by hand. Here is the same flow as your server would run it, with the checks that keep it safe.

What your server keeps

For each authorization attempt, keep two values until the callback arrives: the code_verifier and the state. Tie them to the browser session that started the attempt (a server-side session or a short-lived, signed, HttpOnly cookie). Never put the verifier in the authorize link.

For each connected athlete, keep:

  • sub from GET /v1/profile (your key for the athlete),
  • the current access token and when it expires,
  • the current refresh token,
  • the scopes you were granted.

Encrypt the tokens at rest. They read health-adjacent data.

1. Start the authorization

When the athlete clicks Connect CorrConnect in your app, make a fresh verifier and state, store them, and redirect:

Text
302 Location: CORRCONNECT_BASE/oauth/authorize?response_type=code&client_id=app_...&redirect_uri=https%3A%2F%2Fdeveloper.example%2Fcallback&scope=activities%3Aread%20profile%3Aread&state=...&code_challenge=...&code_challenge_method=S256

The Quickstart's PKCE helper builds this link in JavaScript, Python and Swift.

2. Handle the callback

Your callback gets either code and state, or error=access_denied and state. Check in this order:

  1. Load the stored state and verifier for this browser. If there is none, or state doesn't match exactly, stop. Don't exchange the code.
  2. Delete the stored state and verifier so the callback can't be replayed.
  3. If error=access_denied, the athlete said no. Show them a way to try again.
  4. Exchange the code within 5 minutes, once.
callback.mjs
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";

// Exchange a callback code. Call it once per code; never retry automatically.
export async function exchangeCode({ code, verifier }) {
  const response = await fetch(`${process.env.CORRCONNECT_BASE}/oauth/token`, {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: process.env.CLIENT_ID,
      code,
      redirect_uri: process.env.REDIRECT_URI,
      code_verifier: verifier,
    }),
  });
  const body = await response.json();
  if (!response.ok) {
    throw new Error(`token exchange failed: ${body.error} (${body.request_id})`);
  }
  const profile = await fetch(`${process.env.CORRCONNECT_BASE}/v1/profile`, {
    headers: { Authorization: `Bearer ${body.access_token}` },
  }).then((r) => r.json());
  return {
    sub: profile.sub,
    accessToken: body.access_token,
    accessExpiresAt: Date.now() + body.expires_in * 1000,
    refreshToken: body.refresh_token ?? null,
    scopes: body.scope.split(" "),
  };
}

if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
  console.log(await exchangeCode({ code: process.env.CODE, verifier: process.env.CODE_VERIFIER }));
}
callback.py
import os
import time

import requests

BASE = os.environ["CORRCONNECT_BASE"]


def exchange_code(code: str, verifier: str) -> dict:
    """Exchange a callback code. Call it once per code; never retry automatically."""
    response = requests.post(f"{BASE}/oauth/token", data={
        "grant_type": "authorization_code",
        "client_id": os.environ["CLIENT_ID"],
        "code": code,
        "redirect_uri": os.environ["REDIRECT_URI"],
        "code_verifier": verifier,
    }, timeout=10)
    body = response.json()
    if response.status_code != 200:
        raise RuntimeError(f"token exchange failed: {body['error']} ({body['request_id']})")
    profile = requests.get(f"{BASE}/v1/profile", timeout=10,
                           headers={"Authorization": f"Bearer {body['access_token']}"}).json()
    return {
        "sub": profile["sub"],
        "access_token": body["access_token"],
        "access_expires_at": time.time() + body["expires_in"],
        "refresh_token": body.get("refresh_token"),
        "scopes": body["scope"].split(),
    }


if __name__ == "__main__":
    print(exchange_code(os.environ["CODE"], os.environ["CODE_VERIFIER"]))

profile:read is what lets you call /v1/profile here. If you don't ask for it, key the athlete on your own user ID instead; webhook events still carry the same sub either way.

3. Refresh before the token runs out

Refresh when the access token is within a minute or so of expires_in, or when a call returns 401 invalid_access_token. Only one refresh per athlete may be in flight: a spent refresh token that comes back more than 10 seconds later ends the grant. Serialize with a lock keyed on sub, or a job queue with one worker per athlete.

refresh.mjs
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";

const inFlight = new Map(); // sub -> Promise, so one refresh per athlete at a time

export function refreshFor(sub, refreshToken) {
  if (!inFlight.has(sub)) {
    const attempt = (async () => {
      const response = await fetch(`${process.env.CORRCONNECT_BASE}/oauth/token`, {
        method: "POST",
        body: new URLSearchParams({
          grant_type: "refresh_token",
          client_id: process.env.CLIENT_ID,
          refresh_token: refreshToken,
        }),
      });
      const body = await response.json();
      if (!response.ok) {
        // invalid_grant: the grant ended or the token was spent. Ask the athlete to connect again.
        throw Object.assign(new Error(body.error), { oauthError: body.error });
      }
      return body; // save body.refresh_token before using body.access_token
    })().finally(() => inFlight.delete(sub));
    inFlight.set(sub, attempt);
  }
  return inFlight.get(sub);
}

if (import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
  console.log(await refreshFor("example-sub", process.env.REFRESH_TOKEN));
}
refresh.py
import os
import threading
from collections import defaultdict

import requests

_locks: defaultdict[str, threading.Lock] = defaultdict(threading.Lock)


def refresh_for(sub: str, load_refresh_token, save_tokens) -> dict:
    """Refresh one athlete's tokens; concurrent callers for the same sub wait their turn."""
    with _locks[sub]:
        response = requests.post(os.environ["CORRCONNECT_BASE"] + "/oauth/token", data={
            "grant_type": "refresh_token",
            "client_id": os.environ["CLIENT_ID"],
            "refresh_token": load_refresh_token(sub),  # read inside the lock: a waiter gets the new one
        }, timeout=10)
        body = response.json()
        if response.status_code != 200:
            raise PermissionError(body["error"])  # invalid_grant: ask the athlete to connect again
        save_tokens(sub, body)  # store the new refresh token before using the access token
        return body


if __name__ == "__main__":
    store = {"example-sub": os.environ["REFRESH_TOKEN"]}
    print(refresh_for("example-sub", store.get, lambda sub, body: store.update({sub: body["refresh_token"]})))
TokenStore.swift
import Foundation

/// Keeps refresh tokens and runs at most one refresh per athlete at a time.
actor TokenStore {
    struct Tokens: Decodable {
        let access_token: String
        let expires_in: Int
        let scope: String
        let refresh_token: String?
        let refresh_token_expires_in: Int?
    }

    private var refreshTokens: [String: String] = [:]
    private var inFlight: [String: Task<Tokens, Error>] = [:]
    private let base: URL
    private let clientID: String

    init(base: URL, clientID: String) {
        self.base = base
        self.clientID = clientID
    }

    func save(sub: String, refreshToken: String) {
        refreshTokens[sub] = refreshToken
    }

    func refresh(sub: String) async throws -> Tokens {
        if let running = inFlight[sub] { return try await running.value }
        guard let current = refreshTokens[sub] else { throw URLError(.userAuthenticationRequired) }
        let task = Task { try await Self.post(base: base, clientID: clientID, refreshToken: current) }
        inFlight[sub] = task
        defer { inFlight[sub] = nil }
        do {
            let tokens = try await task.value
            refreshTokens[sub] = tokens.refresh_token  // save before using the access token
            return tokens
        } catch {
            refreshTokens[sub] = nil  // invalid_grant: the athlete has to connect again
            throw error
        }
    }

    private static func post(base: URL, clientID: String, refreshToken: String) async throws -> Tokens {
        var form = URLComponents()
        form.queryItems = [
            URLQueryItem(name: "grant_type", value: "refresh_token"),
            URLQueryItem(name: "client_id", value: clientID),
            URLQueryItem(name: "refresh_token", value: refreshToken),
        ]
        var request = URLRequest(url: base.appendingPathComponent("oauth/token"))
        request.httpMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        request.httpBody = form.percentEncodedQuery!.replacingOccurrences(of: "+", with: "%2B").data(using: .utf8)
        let (data, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
            throw URLError(.userAuthenticationRequired)
        }
        return try JSONDecoder().decode(Tokens.self, from: data)
    }
}

let env = ProcessInfo.processInfo.environment
let store = TokenStore(base: URL(string: env["CORRCONNECT_BASE"]!)!, clientID: env["CLIENT_ID"]!)
await store.save(sub: "example-sub", refreshToken: env["REFRESH_TOKEN"]!)
async let first = store.refresh(sub: "example-sub")
async let second = store.refresh(sub: "example-sub")  // joins the first request
let (a, b) = try await (first, second)
print(a.access_token == b.access_token, a.scope)

4. Handle the endings

  • invalid_grant on refresh: the grant ended or a refresh token was reused. Delete the stored tokens and show Reconnect CorrConnect.
  • 401 invalid_access_token right after a refresh: same thing.
  • 403 insufficient_scope: the athlete approved fewer scopes than you need, or a later approval replaced them. Ask again with the scopes you need.
  • grant.revoked webhook: delete the tokens for that sub. See revocation and deletion.

Checklist

  • New verifier and state for every attempt, stored server-side, never in the link.
  • Exact redirect_uri in both the link and the exchange.
  • State compared before the code is used; stored values deleted after one use.
  • Code exchanged once, never retried automatically.
  • New refresh token saved before the new access token is used.
  • One refresh at a time per athlete.
  • Tokens encrypted at rest and kept out of logs.
  • A disconnect button that calls POST /oauth/revoke.