Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.corsair.dev/llms.txt

Use this file to discover all available pages before exploring further.

OAuth lets your users connect their own accounts to your app. Corsair handles state signing, token exchange, and encrypted storage — you just wire up two routes. Corsair handles security details automatically — CSRF protection, HMAC state verification, and token encryption. Tokens are refreshed automatically before every API call.
1

Configure your app

Add an OAuth plugin to your corsair instance. Make sure database and kek are set — both are required for token storage.
OAuth is supported by plugins like Gmail, Google Calendar, Notion, Spotify, Dropbox, and others.
corsair.ts
import { createCorsair } from 'corsair';
import { gmail } from '@corsair-dev/gmail';

export const corsair = createCorsair({
    plugins: [gmail()],
    kek: process.env.CORSAIR_KEK!,
    database: db,
});
Then store your OAuth app credentials once:
pnpm corsair setup --plugin=gmail client_id=YOUR_CLIENT_ID client_secret=YOUR_CLIENT_SECRET
2

The connect route

/api/connect generates the provider’s authorization URL and stores the HMAC-signed state in a cookie. The user is then redirected to the provider to approve access.
This route must be authenticated.If left open, anyone can call /api/connect?plugin=gmail&tenantId=victim-id and bind their OAuth grant to your victim’s account, silently overwriting that tenant’s stored tokens. Always read tenantId from your own session, never from a query parameter.
app/api/connect/route.ts
import { generateOAuthUrl } from 'corsair/oauth';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { corsair } from '@/server/corsair';
import { getSessionTenantId } from '@/server/auth'; // your auth logic

const REDIRECT_URI = `${process.env.APP_URL}/api/auth`;

export async function GET(request: NextRequest) {
    const tenantId = await getSessionTenantId(request);
    if (!tenantId) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const plugin = new URL(request.url).searchParams.get('plugin');
    if (!plugin) {
        return NextResponse.json({ error: 'Missing plugin param' }, { status: 400 });
    }

    const { url, state } = await generateOAuthUrl(corsair, plugin, {
        tenantId,
        redirectUri: REDIRECT_URI,
    });

    const response = NextResponse.redirect(url);
    response.cookies.set('oauth_state', state, {
        httpOnly: true,   // not readable by JavaScript
        sameSite: 'lax',  // safe for provider redirects
        secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
        maxAge: 60 * 10,  // expires in 10 minutes
    });
    return response;
}
3

The callback route

After the user approves, the provider redirects to /api/auth with ?code= and ?state=. This route verifies the state, exchanges the code for tokens, and stores them encrypted for the tenant.
This route must be authenticated. Verify the oauth_state cookie matches the query param before calling processOAuthCallback. Always clear the cookie on both success and failure — a stale state cookie is a security risk.
app/api/auth/route.ts
import { processOAuthCallback } from 'corsair/oauth';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { corsair } from '@/server/corsair';

const REDIRECT_URI = `${process.env.APP_URL}/api/auth`;

function escapeHtml(value: string): string {
    return value
        .replace(/&/g, '&')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#x27;');
}

export async function GET(request: NextRequest) {
    const { searchParams } = new URL(request.url);
    const code = searchParams.get('code');
    const state = searchParams.get('state');
    const error = searchParams.get('error');

    // Always clear the cookie — every exit path below must do this
    const clearCookie = {
        'Set-Cookie': 'oauth_state=; HttpOnly; Path=/; Max-Age=0',
        'Content-Type': 'text/html',
    };

    if (error) {
        return new NextResponse(
            `<html><body><h2>Authorization failed</h2><p>${escapeHtml(error)}</p></body></html>`,
            { status: 400, headers: clearCookie },
        );
    }

    if (!code || !state) {
        return new NextResponse('<p>Missing code or state.</p>', {
            status: 400,
            headers: clearCookie,
        });
    }

    const storedState = request.cookies.get('oauth_state')?.value;

    if (!storedState || storedState !== state) {
        return new NextResponse('<p>Invalid state. Possible CSRF attempt.</p>', {
            status: 400,
            headers: clearCookie,
        });
    }

    try {
        const result = await processOAuthCallback(corsair, {
            code,
            state,
            redirectUri: REDIRECT_URI,
        });

        const response = new NextResponse(
            `<html><body>
                <h2>Connected!</h2>
                <p><strong>${escapeHtml(result.plugin)}</strong> authorized for tenant
                <strong>${escapeHtml(result.tenantId)}</strong>.</p>
                <p><a href="/">Back to home</a></p>
            </body></html>`,
            { status: 200, headers: { 'Content-Type': 'text/html' } },
        );
        response.cookies.delete('oauth_state');
        return response;
    } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const response = new NextResponse(
            `<html><body><h2>OAuth error</h2><p>${escapeHtml(message)}</p></body></html>`,
            { status: 500, headers: { 'Content-Type': 'text/html' } },
        );
        response.cookies.delete('oauth_state');
        return response;
    }
}

Environment variables

VariableDescription
CORSAIR_KEKKey-encryption key — generate with openssl rand -hex 32. Never rotate without re-encrypting stored DEKs.
APP_URLYour app’s public base URL (e.g. https://myapp.com). Used to build REDIRECT_URI.
NODE_ENVSet to production to enable the secure flag on cookies and other hardening.

Security checklist

Both /api/connect and /api/auth are authenticated routes
tenantId is read from your session — never from a query parameter
oauth_state cookie uses httpOnly, sameSite: 'lax', and secure in production
oauth_state cookie is cleared on every exit path — success, failure, and CSRF mismatch
All user-controlled values are HTML-escaped before rendering
APP_URL points to your HTTPS domain in production
State store is Redis or DB-backed for multi-instance deployments

What’s next

OAuth 2.0 Concepts

How Corsair encrypts and stores tokens, and handles automatic refresh.

Multi-Tenancy

Scoping every API call and database query per user with withTenant().

Gmail Plugin

Set up Gmail OAuth — a common starting point.

Google Calendar Plugin

Another popular OAuth plugin to connect.