Let your app act on a signed-in user’s PickFu account with OAuth 2.0 on WorkOS AuthKit, using PKCE and RFC 7591 Dynamic Client Registration.
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.
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 exactlyconst SCOPE = "openid email profile offline_access"// PKCE helpersconst randomToken = (bytes = 32) => randomBytes(bytes).toString("base64url")const pkceChallenge = (verifier: string) => createHash("sha256").update(verifier).digest("base64url")// 1. Discovery — fetch once, cachelet _disc: anyasync 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 issuerasync 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 redirectingexport 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. refreshasync 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 expiryexport 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)}
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.
Public client, but a secret may appear
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.