Skip to main content
PickFu OAuth lets your app create and read surveys on behalf of a signed-in PickFu user — no API keys to copy, no client to pre-register. It’s standard OpenID Connect on WorkOS AuthKit, so any OAuth library works.
You need a backend (a server, serverless function, or a Replit/Render-style host). The flow stores access and refresh tokens, which must never live in browser-only code. Pure front-end apps (e.g. a Lovable site with no backend) can’t hold tokens safely — pair them with an edge/serverless function.

What you’re integrating against

  • Provider: WorkOS AuthKit at https://connect.pickfu.com
  • Flow: OpenID Connect with PKCE — public client, no shared secret
  • Client provisioning: RFC 7591 Dynamic Client Registration — your app registers itself at boot. There’s no dashboard to pre-create a client.
  • API base: https://api.pickfu.com/v1 (not www.pickfu.com)

Endpoints

All endpoints come from the discovery document — fetch it once and cache it:
https://connect.pickfu.com/.well-known/oauth-authorization-server
PurposeSource
Register a client (DCR)registration_endpoint
Authorizationauthorization_endpoint
Token (exchange + refresh)token_endpoint
User infouserinfo_endpoint (or decode the id_token)

Scopes

openid email profile offline_access
offline_access is required to receive a refresh token. Without it, users must re-authenticate every ~hour.

Minimal implementation

One self-contained module (Node, framework-agnostic). Swap fetch/crypto for your platform’s equivalents. encrypt/decrypt are your own AES-256-GCM-at-rest helpers; db is any store.
import { createHash, randomBytes } from "crypto"

const ISSUER = "https://connect.pickfu.com"
const REDIRECT_URI = `${process.env.APP_BASE_URL}/auth/callback` // must match DCR exactly
const SCOPE = "openid email profile offline_access"

// PKCE helpers
const randomToken = (bytes = 32) => randomBytes(bytes).toString("base64url")
const pkceChallenge = (verifier: string) =>
  createHash("sha256").update(verifier).digest("base64url")

// 1. Discovery — fetch once, cache
let _disc: any
async function discovery() {
  if (_disc) return _disc
  const r = await fetch(`${ISSUER}/.well-known/oauth-authorization-server`)
  if (!r.ok) throw new Error(`discovery ${r.status}`)
  return (_disc = await r.json())
}

// 2. Dynamic Client Registration — once at boot, cache the client_id by issuer
async function ensureClient() {
  const cached = await db.getOAuthClient(ISSUER)
  if (cached) return cached
  const d = await discovery()
  const r = await fetch(d.registration_endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      client_name: "Your App Name",
      redirect_uris: [REDIRECT_URI],
      grant_types: ["authorization_code", "refresh_token"],
      response_types: ["code"],
      token_endpoint_auth_method: "none", // public client
      scope: SCOPE,
    }),
  })
  if (!r.ok) throw new Error(`DCR ${r.status}: ${await r.text()}`)
  const j = await r.json()
  // WorkOS may still return a client_secret — store it encrypted and send if present.
  return db.saveOAuthClient(ISSUER, j.client_id, j.client_secret ?? null)
}

// 3. Authorization URL — store `verifier` keyed by `state` (10-min TTL) before redirecting
export async function authorizationUrl(state: string, verifier: string) {
  const d = await discovery()
  const { clientId } = await ensureClient()
  const u = new URL(d.authorization_endpoint)
  u.searchParams.set("client_id", clientId)
  u.searchParams.set("redirect_uri", REDIRECT_URI)
  u.searchParams.set("response_type", "code")
  u.searchParams.set("scope", SCOPE)
  u.searchParams.set("code_challenge", pkceChallenge(verifier))
  u.searchParams.set("code_challenge_method", "S256") // S256 only; `plain` is rejected
  u.searchParams.set("state", state)
  return u.toString()
}

// 4. Token exchange (callback) + 5. refresh
async function tokenRequest(form: Record<string, string>) {
  const d = await discovery()
  const { clientId, clientSecret } = await ensureClient()
  form.client_id = clientId
  if (clientSecret) form.client_secret = clientSecret
  const r = await fetch(d.token_endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams(form).toString(),
  })
  if (!r.ok) throw new Error(`token ${r.status}: ${await r.text()}`)
  return r.json() // { access_token, refresh_token, expires_in, id_token, ... }
}

export const exchangeCode = (code: string, verifier: string) =>
  tokenRequest({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: verifier })

export const refresh = (refreshToken: string) =>
  tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken })

// 6. Always call before a PickFu request — refreshes proactively within 60s of expiry
export async function getValidAccessToken(userId: string) {
  const row = await db.getTokens(userId)
  if (!row) throw new Error("Not connected to PickFu")
  if (row.expiresAt - Date.now() < 60_000 && row.refreshTokenEnc) {
    const t = await refresh(decrypt(row.refreshTokenEnc))
    await db.saveTokens(userId, {
      accessTokenEnc: encrypt(t.access_token),
      refreshTokenEnc: t.refresh_token ? encrypt(t.refresh_token) : row.refreshTokenEnc,
      expiresAt: Date.now() + t.expires_in * 1000,
    })
    return t.access_token
  }
  return decrypt(row.accessTokenEnc)
}
Then every PickFu call sends the token:
const token = await getValidAccessToken(userId)
await fetch("https://api.pickfu.com/v1/surveys", {
  headers: { Authorization: `Bearer ${token}` },
})

Token lifecycle & security

  • Access tokens expire in ~1 hour. Refresh proactively (within 60s of expiry), not reactively on a 401.
  • A refresh response may include a new refresh token — replace the stored one when it does.
  • Encrypt both tokens at rest (AES-256-GCM) — they grant full PickFu account access. Never log raw tokens.

Gotchas

Use /.well-known/oauth-authorization-server — the canonical WorkOS path. /.well-known/openid-configuration also resolves.
code_challenge_method=S256 only. plain is rejected. Verifier ~64 bytes base64url; challenge = SHA-256(verifier).
The URI registered via DCR must exactly match what you send at authorization time. If your public URL changes (custom domain), delete the cached client registration and let DCR re-run.
token_endpoint_auth_method: "none" means no secret is required. WorkOS may still return a client_secret — store it encrypted and include it if present; it isn’t mandatory.
Creating surveys with this token (draft → publish, mediaUrl options, AI image generation) is covered in the API Reference and Guides — those rules are the same regardless of how you authenticate.