ClientCasa
Integrations

OAuth quick-start with PKCE

End-to-end authorization-code-with-PKCE flow against the ClientCasa OAuth provider. Copy-pasteable Node.js example, ~50 lines.

Use this guide when your integration acts on behalf of a user — e.g., a Zapier-class automation, a desktop or mobile app, or any flow where the end user signs in to ClientCasa and authorizes your app to act for them.

For pure server-to-server calls without a user, use an API key instead — simpler, no flow to implement. See Authentication.

What you'll build

A Node.js script that:

  1. Spins up a local HTTP listener
  2. Opens the browser to ClientCasa's authorize URL with PKCE
  3. Captures the redirect, exchanges the code for an access token
  4. Calls /api/v1/clients with the bearer token

This is the same script we use to validate the OAuth provider end-to-end — apps/app/scripts/test-oauth-flow.ts in the main repo, if you want the full file.

Already use Next.js?

In a Next.js app, the same flow runs in your existing routes — kick off the authorize redirect from a server action, handle the callback in an /api/oauth/callback route. The structure is identical to what's below.

Setup

Register an OAuth app

In your dashboard at Settings → OAuth Apps, click Create app. Fill in:

  • App name: Anything descriptive — shown on the consent screen
  • Description: What your app does (also shown at consent)
  • Logo URL: Optional, 40×40 image
  • Homepage URL: Optional
  • Redirect URIs: For this guide, add http://localhost:53111/callback exactly. HTTPS is required for production; http://localhost is allowed for development.
  • Scopes: Pick the minimum needed — for this guide we use openid, profile, email, offline_access, read:clients.

Save. You'll see your client_id and client_secret exactly once — copy both somewhere safe. The secret cannot be recovered later; rotate it from the same page if you ever need a new one.

Set environment variables

export OAUTH_CLIENT_ID="..."
export OAUTH_CLIENT_SECRET="..."

Write the script

Save as oauth-quickstart.mjs. No dependencies — only Node's standard library.

import { createHash, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
import { execFile } from 'node:child_process'

const CLIENT_ID = process.env.OAUTH_CLIENT_ID
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET
const BASE_URL = 'https://www.clientcasa.com'
const REDIRECT_URI = 'http://localhost:53111/callback'
const SCOPE = 'openid profile email offline_access read:clients'

const b64url = (buf) =>
  buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')

const codeVerifier = b64url(randomBytes(48))
const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest())
const state = b64url(randomBytes(16))

const authorizeUrl = new URL(`${BASE_URL}/api/auth/oauth2/authorize`)
authorizeUrl.searchParams.set('client_id', CLIENT_ID)
authorizeUrl.searchParams.set('response_type', 'code')
authorizeUrl.searchParams.set('redirect_uri', REDIRECT_URI)
authorizeUrl.searchParams.set('scope', SCOPE)
authorizeUrl.searchParams.set('state', state)
authorizeUrl.searchParams.set('code_challenge', codeChallenge)
authorizeUrl.searchParams.set('code_challenge_method', 'S256')

const server = createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost:53111')
  if (url.pathname !== '/callback') { res.writeHead(404).end(); return }

  if (url.searchParams.get('state') !== state) {
    res.writeHead(400).end('state mismatch')
    process.exit(1)
  }

  const code = url.searchParams.get('code')
  const tokenRes = await fetch(`${BASE_URL}/api/auth/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code_verifier: codeVerifier,
    }),
  })
  const tokens = await tokenRes.json()
  console.log('access_token:', tokens.access_token.slice(0, 40), '...')
  console.log('expires_in:', tokens.expires_in, 's')

  const apiRes = await fetch(`${BASE_URL}/api/v1/clients?pageSize=5`, {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  })
  const data = await apiRes.json()
  console.log('clients:', data.data.map((c) => c.name))

  res.writeHead(200).end('OK — check your terminal')
  server.close()
})

server.listen(53111, () => {
  console.log('Opening browser to:', authorizeUrl.toString())
  execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [authorizeUrl.toString()])
})

Run it

node oauth-quickstart.mjs

A browser tab opens to the consent screen showing your app's name, logo, and the scopes it's requesting. Approve, and you'll see a list of clients printed in your terminal.

What's happening, step by step

  1. PKCE challenge — your script generates a random code_verifier and its SHA-256 code_challenge. Only the challenge is sent in the authorize URL; the verifier stays in your process. This prevents an attacker who intercepts the authorization code from being able to exchange it for tokens.

  2. Authorize URL — built from client_id, redirect_uri, scope, state, code_challenge. The user sees a consent screen showing your app's metadata.

  3. Redirect with code — after the user approves, ClientCasa redirects to http://localhost:53111/callback?code=...&state=.... Your script verifies state matches (CSRF protection) and grabs the code.

  4. Token exchange — POST to /api/auth/oauth2/token with the code, your client credentials, and the original code_verifier. You get back an access_token (JWT, 1-hour lifetime) and a refresh_token (30-day lifetime).

  5. API call — use the access token as a Bearer credential. The token's payload embeds the organization ID, so every API call is automatically scoped to the user's active org.

Refreshing access tokens

Access tokens expire after 1 hour. Use the refresh token to get a new pair:

const refreshRes = await fetch(`${BASE_URL}/api/auth/oauth2/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: storedRefreshToken,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  }),
})

A new refresh token is issued each time — rotate to it. The old one is invalidated.

Storage

Store refresh tokens encrypted at rest. Never send the client_secret to a browser or mobile bundle — it's a backend credential.

Production checklist

  • Replace http://localhost:53111/callback with an HTTPS redirect URI in your app's OAuth Apps settings
  • Validate the state parameter on every callback (CSRF protection)
  • Store refresh tokens encrypted (e.g., AWS KMS, Vercel Encrypted Env)
  • Rotate the client secret if you suspect a leak — users keep their connections
  • Handle the token-revoked case — when a user revokes from their dashboard, your refresh token returns invalid_grant; re-run the authorize flow

See also

On this page