> ## Documentation Index
> Fetch the complete documentation index at: https://www.pickfu.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build with PickFu OAuth

> 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](https://workos.com/docs/authkit), so any OAuth library works.

<Note>
  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.
</Note>

<img className="block dark:hidden" src="https://mintcdn.com/pickfu/QJcxZCx_vR2qxODa/images/oauth-light.svg?fit=max&auto=format&n=QJcxZCx_vR2qxODa&q=85&s=79b20085bde0a8e0a56284df68430333" alt="OAuth flow: your app registers via Dynamic Client Registration, sends the user to authorize with PKCE (S256), exchanges the code for access and refresh tokens, then calls api.pickfu.com/v1 with a Bearer token — refreshing before the ~1h expiry." noZoom width="1040" height="400" data-path="images/oauth-light.svg" />

<img className="hidden dark:block" src="https://mintcdn.com/pickfu/QJcxZCx_vR2qxODa/images/oauth-dark.svg?fit=max&auto=format&n=QJcxZCx_vR2qxODa&q=85&s=9aa7f3d6ae8a06f08f86b6b6a2bb6634" alt="OAuth flow: your app registers via Dynamic Client Registration, sends the user to authorize with PKCE (S256), exchanges the code for access and refresh tokens, then calls api.pickfu.com/v1 with a Bearer token — refreshing before the ~1h expiry." noZoom width="1040" height="400" data-path="images/oauth-dark.svg" />

## 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](https://datatracker.ietf.org/doc/html/rfc7591) — 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
```

| Purpose                    | Source                                         |
| -------------------------- | ---------------------------------------------- |
| Register a client (DCR)    | `registration_endpoint`                        |
| Authorization              | `authorization_endpoint`                       |
| Token (exchange + refresh) | `token_endpoint`                               |
| User info                  | `userinfo_endpoint` (or decode the `id_token`) |

### Scopes

```
openid email profile offline_access
```

<Warning>
  `offline_access` is required to receive a **refresh token**. Without it, users must re-authenticate every \~hour.
</Warning>

## 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.

```ts theme={null}
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:

```ts theme={null}
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

<AccordionGroup>
  <Accordion title="Discovery path">
    Use `/.well-known/oauth-authorization-server` — the canonical WorkOS path. `/.well-known/openid-configuration` also resolves.
  </Accordion>

  <Accordion title="PKCE must be S256">
    `code_challenge_method=S256` only. `plain` is rejected. Verifier \~64 bytes base64url; challenge = SHA-256(verifier).
  </Accordion>

  <Accordion title="redirect_uri must match character-for-character">
    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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

<Note>
  Creating surveys with this token (draft → publish, `mediaUrl` options, AI image generation) is covered in the [API Reference](/docs/api-reference/introduction) and [Guides](/docs/guides/surveys-overview) — those rules are the same regardless of how you authenticate.
</Note>
