AuthenticationOAuth 2.0

OAuth 2.1

Authorize third-party apps with OAuth 2.1, PKCE, and resource indicators.

OAuth lets your app act on behalf of a Zoop user — reading their customers, creating jobs, or sending invoices — without ever seeing their password. The user approves access through a consent screen; you get a short-lived access token (a credential your app includes on every API request) and a refresh token (used to get a new access token when the first one expires).

Use OAuth when your app serves multiple Zoop accounts or when you need access tied to a specific user's identity. If you are building a server-to-server integration that runs unattended and does not need a user identity, API keys are simpler.

How it works

Zoop uses OAuth 2.1 with PKCE (Proof Key for Code Exchange — a security extension that stops attackers from hijacking the authorization code in transit). The auth server is Supabase — Zoop owns the consent screen and the grant table, but the actual token exchange happens against the Supabase auth endpoint.

The access token is a JWT (JSON Web Token) — a signed string that encodes the user's identity, tenant, and allowed scopes. Zoop verifies it on every request without a database lookup. The signature algorithm is ES256.

Your app              Supabase auth             Zoop
   |                       |                      |
   |-- GET /authorize -->   |                      |
   |                       |-- 302 → consent -->  |
   |                       |                      |
   |   (user approves)     |                      |
   |                       |                      |
   |-- POST /token ------> |                      |
   |                       |                      |
   |<-- access_token + refresh_token -------------|
   |                                              |
   |-- POST /api/mcp (Bearer <access_token>) ---> |

Prerequisites

Before you run this flow, you need three things set up by a Zoop admin.

  1. A registered OAuth client. Ask your Zoop instance admin to register one via the Supabase dashboard or Management API. You get back a client_id. Server-side (confidential) clients also get a client_secret.

  2. Allowed scopes. Zoop uses a default-deny scope model — a new client has no permissions until an admin adds specific scopes to the oauth_client_registrations table. The consent screen shows exactly those registered scopes, regardless of what you put in the scope query parameter.

  3. A pre-registered redirect URI. The URL in your app where Zoop sends the user after they approve access. It must be registered with the client in advance — unregistered URIs are rejected.

If you are building an MCP integration, the OAuth flow is the same. The token you receive can be sent directly to POST /api/mcp with an Authorization: Bearer header.

Discovery

Before you build any URLs, find out which authorization server your Zoop instance uses. Do not hardcode it — resolve it from the protected-resource metadata endpoint:

GET https://app.zoop.example/.well-known/oauth-protected-resource

Response:

{
  "resource": "https://app.zoop.example",
  "authorization_servers": ["https://<project>.supabase.co/auth/v1"],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://docs.zoop.work/api"
}

The resource value is environment-specific (production, staging, local). Always read it from the discovery response — do not hardcode https://app.zoop.example. You will need the exact resource string when you build the authorization URL and the token request.

The authorization_servers array tells you which Supabase project handles auth for this instance. Use that URL to fetch the authorization server metadata — this gives you the exact authorization_endpoint and token_endpoint you will need in the steps below:

GET https://<project>.supabase.co/auth/v1/.well-known/oauth-authorization-server

This endpoint returns standard OAuth 2.0 server metadata including supported grants, PKCE methods, and scopes.

The authorization flow

Generate PKCE parameters and state

You need two random values before you redirect the user.

  • code_verifier — a random secret only your server knows. You hash it into a code_challenge and send the hash to the authorization endpoint. Later, you prove you hold the original secret by sending the verifier to the token endpoint. This stops an attacker who intercepts the authorization code from using it.
  • state — a random value you tie to the user's session. You check it matches in the callback to block cross-site request forgery (CSRF) attacks.
import { randomBytes, createHash } from 'crypto'

const codeVerifier = randomBytes(64).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(32).toString('base64url')

// Store both in the user's session — you need them in the callback.
session.codeVerifier = codeVerifier
session.oauthState = state

Store codeVerifier and state server-side (signed cookie, Redis session, or similar) — you need both in steps 3 and 4.

Redirect the user to the authorization endpoint

Build the authorization URL using the authorization_endpoint from discovery, then redirect the user's browser to it.

GET https://<project>.supabase.co/auth/v1/oauth/authorize
  ?client_id=<your_client_id>
  &redirect_uri=<your_registered_redirect_uri>
  &response_type=code
  &code_challenge=<codeChallenge>
  &code_challenge_method=S256
  &state=<state>
  &scope=openid
  &resource=https://app.zoop.example

Key parameters:

path
parameter

Your registered client ID.

path
parameter

Must exactly match a URI pre-registered with the client.

path
parameter

Always code.

path
parameter

The base64url-encoded SHA-256 hash of your code_verifier.

path
parameter

Always S256. Plain is not supported.

path
parameter

Your CSRF token. Verified in the callback.

path
parameter

OIDC scopes (openid, email). Custom Zoop scopes come from the client's registered scope list — what you send here does not change what appears on the consent screen.

path
parameter

The resource value from /.well-known/oauth-protected-resource (RFC 8707). Required by MCP-spec-compliant clients. Must match exactly.

Supabase validates the parameters and redirects the user to the Zoop consent screen:

https://app.zoop.example/oauth/consent?authorization_id=<opaque_id>

If the user is not signed in, they see the login page first and are returned here after. If they have multiple Zoop tenants, they choose which one to give your app access to.

One grant per client and user. If a user re-consents for a different tenant, the previous grant is overwritten — your app loses access to the old tenant and gains access to the new one. You cannot hold grants for multiple tenants from the same user simultaneously.

Handle the callback

After the user approves on the consent screen, Supabase redirects them back to your redirect_uri with two query parameters:

https://your-app.example.com/callback?code=<authorization_code>&state=<your_state>

Verify state before doing anything else. Compare it to the value you stored in step 1. If they do not match, reject the request — this is a sign of a CSRF or authorization code injection attempt.

const { code, state } = new URL(request.url).searchParams

if (state !== session.oauthState) {
  throw new Error('State mismatch — possible CSRF attack')
}

// Safe to proceed.

If the user has already approved this client before, Zoop skips the consent screen and redirects immediately.

Exchange the code for tokens

Send the authorization code and your code_verifier to the token endpoint. The server checks that the verifier hashes to the code_challenge you sent in step 2 — this proves your app made both requests.

Confidential clients have a client_secret. Send it as HTTP Basic auth.

curl -X POST https://<project>.supabase.co/auth/v1/oauth/token \
  -H "Authorization: Basic $(echo -n '<client_id>:<client_secret>' | base64)" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=<authorization_code>" \
  --data-urlencode "redirect_uri=<your_registered_redirect_uri>" \
  --data-urlencode "code_verifier=<codeVerifier>" \
  --data-urlencode "resource=https://app.zoop.example"

The resource parameter must appear in both the authorization request (step 2) and this token request.

Successful response:

{
  "access_token": "<es256-signed-jwt>",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "<opaque-refresh-token>"
}

The access token expires in 3600 seconds (1 hour). Store the refresh token somewhere safe — you use it to get a new access token without sending the user through the browser flow again. See Refreshing tokens below.

Call the API

Send the access token in the Authorization header on every API request, using the Bearer scheme.

curl -X POST https://app.zoop.example/api/mcp \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

The Accept: application/json, text/event-stream header is required — Zoop's MCP endpoint uses the streamable HTTP transport and needs both content types declared.

const res = await fetch('https://app.zoop.example/api/mcp', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
    'Accept': 'application/json, text/event-stream',
  },
  body: JSON.stringify({
    jsonrpc: '2.0',
    id: 1,
    method: 'tools/call',
    params: {
      name: 'customers.search',
      arguments: { q: 'Henderson' },
    },
  }),
})

The access token

The access token is a JWT signed with ES256. If you want to inspect what's inside — for example, to check which tenant or scopes were granted — paste it into jwt.io. Zoop validates these claims on every request.

{
  "iss": "https://<project>.supabase.co/auth/v1",
  "sub": "<user_uuid>",
  "tenant_id": "<tenant_uuid>",
  "scopes": ["read:customers", "read:jobs"],
  "client_id": "<your_client_id>",
  "role": "owner"
}
iss

Issuer. Pinned to the Supabase project's auth endpoint. Tokens from any other issuer are rejected.

sub

The Supabase user UUID of the user who granted consent.

tenant_id

The UUID of the Zoop tenant the user bound the grant to. All API calls with this token are scoped to this tenant.

scopes

The scopes granted at consent time. Comes from the oauth_client_grants row written when the user approved the consent screen — not from whatever was in the scope parameter at authorization.

client_id

Your OAuth client ID.

role

The user's role in the tenant: owner, office, or tech. Zoop coerces unrecognized values to tech (least-privileged).

Refreshing tokens

Access tokens expire after one hour. When that happens, use the refresh token to get a new one — no browser redirect needed.

curl -X POST https://<project>.supabase.co/auth/v1/oauth/token \
  -H "Authorization: Basic $(echo -n '<client_id>:<client_secret>' | base64)" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=<refresh_token>" \
  --data-urlencode "resource=https://app.zoop.example"

A successful response has the same shape as the original token response — a new access_token and expires_in, and sometimes a new refresh_token. If you get a new refresh token, store it and discard the old one.

If the refresh request returns 401 with error: "invalid_token", the user's session has been terminated (for example, they signed out). You need to send them through the full authorization flow again.

Scopes

Scopes control what your app can read or write. They are set by the client's registration in oauth_client_registrations, not by the scope parameter you send in the authorization request. A new client has no scopes by default — a Zoop admin must add them before users can grant access.

Common scope sets:

Use caseScopes
Read-only field service dataread:customers read:jobs read:invoices read:quotes
Full field service read/writeread:customers write:customers read:jobs write:jobs read:invoices write:invoices
Catalog managementread:catalog write:catalog

Request only the scopes your integration actually needs. See scopes for the full catalog.

Error reference

These are the errors you are most likely to see from the API after you have a token.

SituationStatusBody
Token expired401{"error":"expired"} — use the refresh token
OAuth token invalidated (session terminated)401{"error":"invalid_token"} — restart the authorization flow
Insufficient scope403{"error":"insufficient_scope"} — the token does not hold the required scope
Wrong tenant403{"error":"wrong_tenant"} — the token's tenant_id does not match the requested tenant
Rate limited429Retry-After header included
JWKS endpoint down5xxRetryable — Zoop rethrows JWKS infrastructure failures so you get a retryable 5xx, not a misleading 401

Retry 5xx responses with exponential backoff. Do not discard your token on a 5xx — it may be valid and the error transient.

  • API keys — simpler M2M authentication without a browser flow
  • Scopes — full scope catalog
  • MCP — calling the MCP endpoint with your access token
  • Errors — full error code reference
  • Rate limits — per-credential and per-tenant limits