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:
- Spins up a local HTTP listener
- Opens the browser to ClientCasa's authorize URL with PKCE
- Captures the redirect, exchanges the code for an access token
- Calls
/api/v1/clientswith 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/callbackexactly. HTTPS is required for production;http://localhostis 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.mjsA 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
-
PKCE challenge — your script generates a random
code_verifierand its SHA-256code_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. -
Authorize URL — built from
client_id,redirect_uri,scope,state,code_challenge. The user sees a consent screen showing your app's metadata. -
Redirect with code — after the user approves, ClientCasa redirects to
http://localhost:53111/callback?code=...&state=.... Your script verifiesstatematches (CSRF protection) and grabs the code. -
Token exchange — POST to
/api/auth/oauth2/tokenwith the code, your client credentials, and the originalcode_verifier. You get back anaccess_token(JWT, 1-hour lifetime) and arefresh_token(30-day lifetime). -
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/callbackwith an HTTPS redirect URI in your app's OAuth Apps settings - Validate the
stateparameter 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
- OAuth Apps reference — full developer guide
- Authentication — when to pick API key vs OAuth
- Consent screen — what users see at step 3
Integrations
Connect ClientCasa with Zapier, n8n, Make, Slack, and other tools using webhooks and the REST API.
Webhook receiver with HMAC verification
Build a webhook receiver that verifies HMAC-SHA256 signatures, rejects replays, dedupes by event ID, and handles retries. Works on Node, Edge runtimes, and Cloudflare Workers.