Imported from alfadocs/ai-harness-instructions (
lovable/skills/alfadocs-oauth-token-refresh/SKILL.md). Install upstream withnpx skills add alfadocs/ai-harness-instructions --skill alfadocs-oauth-token-refresh. Copyright stays with the author.
Refreshing AlfaDocs OAuth tokens safely
AlfaDocs access tokens are short-lived. You renew them with the OAuth2 refresh_token grant. AlfaDocs uses refresh-token rotation: every successful refresh revokes the old refresh token and issues a brand-new one. A single mishandled refresh — a double-refresh from two concurrent requests, or a fallback to an already-revoked token — permanently kills the connection and forces the user to re-authenticate.
This skill is the safe pattern. Follow it exactly.
Non-negotiables (read first)
-
Tokens live server-side only. Store
access_tokenandrefresh_tokenin a Supabase table, read/written exclusively from Edge Functions using the service-role key. Never send a refresh token to the browser, never put any token in.envcommitted to git, never expose the OAuth client secret to the frontend. Secrets go in Supabase Edge Function secrets.Build the service-role client (
supabaseAdmin) fromAUTH_SUPABASE_URL || SUPABASE_URLandAUTH_SUPABASE_KEY || SUPABASE_SERVICE_ROLE_KEY— Lovable Cloud auto-injectsSUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, while a standalone Supabase project uses theAUTH_*names, so reading both makes the token store work in either setup.const supabaseAdmin = createClient( Deno.env.get("AUTH_SUPABASE_URL") ?? Deno.env.get("SUPABASE_URL")!, Deno.env.get("AUTH_SUPABASE_KEY") ?? Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, ); -
One token per practice/archive. Each AlfaDocs practice (and its archive) has its own token. Never use one practice's token to call the API for another practice. Scope every read and write by
practice_id+archive_id. -
Persist the rotated refresh token with
??, never||. Rotation may legitimately return an empty-string field;||would silently fall back to the now-revoked old token and brick the connection.
// ❌ empty string falls back to the already-revoked old token
refresh_token: data.refresh_token || row.refresh_token
// ✅ only null/undefined fall back
refresh_token: data.refresh_token ?? row.refresh_token
Database shape
Table oauth_tokens, one row per practice/archive connection:
| Column | Type | Notes |
|---|---|---|
practice_id |
text/int | from /me — scopes the token |
archive_id |
text/int | from /me — scopes the token |
access_token |
text | short-lived bearer used for API calls |
refresh_token |
text | rotated on every refresh |
expires_at |
timestamptz | when the access token dies |
status |
text | default active; set revoked when dead |
refresh_locked_at |
timestamptz | concurrency mutex, default null |
practice_id + archive_id come from calling GET /me on https://app.alfadocs.com/api/v1 while authenticated (see https://app.alfadocs.com/api.html). They are required for every other API call — store them with the token.
The concurrency lock — why and how
If two requests notice the token is expired at the same time, they both refresh, both rotate, and the second rotation invalidates the first's new refresh token. The connection dies. Prevent this with a row-level lock: an atomic conditional update on refresh_locked_at.
// Acquire the lock: succeeds only if no one holds it (or the lock is stale > 2 min)
const staleCutoff = new Date(Date.now() - 2 * 60 * 1000).toISOString();
const { data: lockedRow } = await supabaseAdmin
.from("oauth_tokens")
.update({ refresh_locked_at: new Date().toISOString() })
.eq("practice_id", practiceId)
.eq("archive_id", archiveId) // ← scope to THIS practice
.or(`refresh_locked_at.is.null,refresh_locked_at.lt.${staleCutoff}`)
.select("*")
.single();
- Lock acquired (
lockedRowreturned): you own the refresh. Call AlfaDocs, persist, then release infinally. - Lock NOT acquired: another process is refreshing. Wait ~3s, re-read the row, and only return the token if
expires_atis actually in the future. After 2 failed re-checks, throw a transient error — never hand back a known-stale token. - Stale lock (
refresh_locked_atolder than 2 minutes): treat as abandoned; the.or(... .lt.)clause lets you overwrite it. - Never call
/oauth2/tokenwithout holding the lock.
Always release the lock:
try {
accessToken = await refreshOAuthToken(supabaseAdmin, lockedRow);
} finally {
await supabaseAdmin
.from("oauth_tokens")
.update({ refresh_locked_at: null })
.eq("id", lockedRow.id);
}
Error classification — don't retry dead tokens
| Upstream status | Meaning | Recoverable? |
|---|---|---|
| 401 | Revoked / invalid refresh token | No — needs re-auth |
| 400 | Expired auth code | No — needs re-auth |
| 403 | Forbidden | No — needs re-auth |
| 429 | Rate limited | Yes, backoff |
| 5xx | Server error | Yes, retry |
On 401/400/403, mark the row revoked, clear the lock, and signal needsReauth — do not retry:
if ([400, 401, 403].includes(res.status)) {
await supabaseAdmin
.from("oauth_tokens")
.update({ status: "revoked", refresh_locked_at: null })
.eq("id", tokenRow.id);
const err = new Error(`Token revoked (${res.status}). Re-authentication required.`);
(err as any).needsReauth = true;
throw err;
}
On 429/5xx/network errors, retry with exponential backoff (max ~3 attempts, 1000 * 2 ** attempt ms). Skip retries entirely when needsReauth is set.
Handling a 401 from the AlfaDocs API (the common case)
When a normal API call (e.g. fetching a patient) returns 401 despite a token that looked valid, the access token expired between your check and the call. Do this — never prompt the user for re-auth on the first 401:
- Call your token manager with
{ forceRefresh: true }(bypasses the expiry-buffer check, goes straight to lock + refresh). - Retry the original API call once with the fresh token.
- If the retry succeeds, return the result.
- If it still fails:
- Token manager threw with
needsReauth: true→ return{ needs_reauth: true }(genuine revocation; now it's safe to tell the user to reconnect). - Otherwise → return HTTP 502 with
{ needs_reauth: false }(transient; tell the user "retrying shortly").
- Token manager threw with
Never map a raw 401/403 straight to a "re-authenticate" prompt. Trust the needs_reauth signal after the force-refresh+retry has run.
Full reference
The complete flow (step-by-step token-manager logic, retry helper, proactive cron refresh via pg_cron + non-blocking EdgeRuntime.waitUntil(), rate limiting, OAuth-callback state reset, webhook error classification, and the pitfalls table) is in:
Quick checklist
- Tokens stored in Supabase, read/written only from Edge Functions; nothing token-related in the browser or committed
.env. -
supabaseAdminbuilt fromAUTH_SUPABASE_URL || SUPABASE_URLandAUTH_SUPABASE_KEY || SUPABASE_SERVICE_ROLE_KEY(works on Lovable Cloud and standalone Supabase). - Every query scoped by
practice_id+archive_id; no cross-practice token reuse. - Refresh persisted with
??, never||. - Row-level lock on
refresh_locked_at(2-min stale threshold); lock released infinally. - Lock-wait path re-validates
expires_atbefore returning. - 401/400/403 →
status: "revoked"+needsReauth; never retried. - 429/5xx → retry with exponential backoff.
- API 401 → force-refresh + single retry before any re-auth prompt.
- OAuth callback resets
status: "active"andrefresh_locked_at: null.