OAuth 2.0 + PKCE

Delegate authentication to AuthSaas via a secure redirect flow — no password handling in your app.

Overview#

AuthSaas implements the Authorization Code Flow with PKCE (RFC 7636). Your application redirects the user to a hosted AuthSaas login page; after the user authenticates, AuthSaas redirects back to your redirect_uri with a short-lived authorization code. Your server exchanges the code for access and refresh tokens.

Your application never handles passwords. All credential validation happens on the AuthSaas server.

When to use OAuth vs. direct login#

ScenarioRecommended approach
You want a hosted login UI with no extra dev workOAuth + PKCE ← this page
You need a fully custom-branded login formDirect SDK login
Your app is an SPA with no backendDirect SDK login
Multiple apps share the same user sessionOAuth + PKCE (SSO)
Strict password isolation requirementOAuth + PKCE

PKCE flow — step by step#

The full flow involves five steps. Steps 1 and 3–4 run in your application; step 2 is handled entirely by AuthSaas.

1 · Start the flow (your app)#

Generate a random code_verifier, derive a code_challenge from it (SHA-256 → base64url), and build an authorization URL. Save the verifier and a random state token in short-lived cookies for CSRF protection.

app/api/auth/login-start/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

const AUTH_URL   = process.env.NEXT_PUBLIC_AUTH_URL!.trim(); // e.g. https://auth.royalda.com
const CLIENT_ID  = process.env.AUTH_CLIENT_ID!;
const APP_URL    = process.env.NEXT_PUBLIC_APP_URL!.trim();

function generateVerifier(): string {
  return crypto.randomBytes(40).toString('base64url');
}

function deriveChallenge(verifier: string): string {
  return crypto.createHash('sha256').update(verifier).digest('base64url');
}

export async function GET(req: NextRequest) {
  const verifier   = generateVerifier();
  const challenge  = deriveChallenge(verifier);
  const state      = crypto.randomBytes(16).toString('hex');
  const redirectUri = `${APP_URL}/api/auth/callback`;

  const url = new URL(`${AUTH_URL}/api/v1/oauth/authorize`);
  url.searchParams.set('response_type',          'code');
  url.searchParams.set('client_id',              CLIENT_ID);
  url.searchParams.set('redirect_uri',           redirectUri);
  url.searchParams.set('code_challenge',         challenge);
  url.searchParams.set('code_challenge_method',  'S256');
  url.searchParams.set('state',                  state);

  const res = NextResponse.redirect(url.toString());

  const cookieBase = `Path=/; HttpOnly; SameSite=Lax; Max-Age=300${
    process.env.NODE_ENV === 'production' ? '; Secure' : ''
  }`;
  res.headers.append('Set-Cookie', `oauth_code_verifier=${verifier}; ${cookieBase}`);
  res.headers.append('Set-Cookie', `oauth_state=${state}; ${cookieBase}`);
  return res;
}

2 · Hosted login page (AuthSaas)#

AuthSaas validates the request parameters and redirects the user to /oauth/login. The user enters their credentials on the AuthSaas-hosted form. On success, AuthSaas redirects back to your redirect_uri with ?code=xxx&state=xxx.

Note

No code changes needed for this step — it is handled entirely by AuthSaas.

3 · Handle the callback (your app)#

Your callback route verifies the state, then hands the code and code_verifier to the token exchange. Set cookies in a 200 HTML response — cookie headers on 302 redirects are unreliable in Next.js route handlers.

app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const AUTH_URL  = process.env.NEXT_PUBLIC_AUTH_URL!.trim();
const CLIENT_ID = process.env.AUTH_CLIENT_ID!;
const APP_URL   = process.env.NEXT_PUBLIC_APP_URL!.trim();

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const code  = searchParams.get('code');
  const state = searchParams.get('state');

  const jar          = await cookies();
  const savedState   = jar.get('oauth_state')?.value;
  const codeVerifier = jar.get('oauth_code_verifier')?.value;

  if (!code || !state || state !== savedState || !codeVerifier) {
    return NextResponse.redirect(`${APP_URL}?auth_error=invalid_state`);
  }

  // Exchange code for tokens (server-to-server)
  const tokenRes = await fetch(`${AUTH_URL}/api/v1/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type:    'authorization_code',
      code,
      client_id:     CLIENT_ID,
      code_verifier: codeVerifier,
      redirect_uri:  `${APP_URL}/api/auth/callback`,
    }),
  });

  if (!tokenRes.ok) {
    return NextResponse.redirect(`${APP_URL}?auth_error=token_exchange_failed`);
  }

  const { data } = await tokenRes.json();
  const { access_token, refresh_token } = data;

  // Return 200 HTML + explicit Set-Cookie headers (more reliable than 302 + cookies)
  const isProduction = process.env.NODE_ENV === 'production';
  const secure       = isProduction ? '; Secure' : '';

  const html = `<!DOCTYPE html><html><head>
    <meta http-equiv="refresh" content="0;url=${APP_URL}">
    <title>Signing in...</title>
  </head><body></body></html>`;

  const res = new NextResponse(html, {
    status: 200,
    headers: { 'Content-Type': 'text/html; charset=utf-8' },
  });

  // httpOnly refresh token — readable only by /api routes
  res.headers.append('Set-Cookie',
    `refresh_token=${refresh_token}; HttpOnly; SameSite=Lax; Path=/api; Max-Age=604800${secure}`);

  // Short-lived readable cookie — consumed once on client init
  res.headers.append('Set-Cookie',
    `_at_init=${encodeURIComponent(access_token)}; SameSite=Lax; Path=/; Max-Age=30${secure}`);

  // Clear PKCE cookies
  res.headers.append('Set-Cookie', 'oauth_state=; Path=/; Max-Age=0');
  res.headers.append('Set-Cookie', 'oauth_code_verifier=; Path=/; Max-Age=0');

  return res;
}

4 · Exchange the code (server-to-server)#

The POST /api/v1/oauth/token call in step 3 is a server-to-server request — it must never be called directly from the browser. The endpoint has no CORS headers. Sending the code_verifier proves possession of the original challenge without needing a client secret.

5 · Store tokens (your client)#

Read the _at_init cookie on first load, store the access token in sessionStorage, then clear the cookie. For subsequent page loads, use therefresh endpoint to silently obtain a new access token from the httpOnlyrefresh_token cookie.

lib/auth-client.ts (excerpt)
// Called once on app init to consume the _at_init cookie
export function consumeInitToken(): string | null {
  const match = document.cookie.match(/(?:^|; )_at_init=([^;]*)/);
  if (!match) return null;
  const token = decodeURIComponent(match[1]);
  // Clear the cookie immediately
  document.cookie = '_at_init=; Path=/; Max-Age=0';
  return token;
}

// Silent refresh via your own proxy route
export async function refreshAccessToken(): Promise<string | null> {
  const res = await fetch('/api/auth/refresh', { method: 'POST' });
  if (!res.ok) return null;
  const { data } = await res.json();
  return data.accessToken ?? null;
}

Warning

Never store tokens in localStorage — XSS attacks can steal them. Use sessionStorage for the access token and an httpOnly cookie for the refresh token.

Endpoint reference#

GET /oauth/authorize#

Validates OAuth parameters and redirects to the hosted login page. Called directly by your login-start route via browser redirect — not via fetch.

Query parameters
client_id               string   required  Your app's clientId
redirect_uri            string   required  Must match an allowedOrigin for the app
response_type           string   required  Must be "code"
code_challenge          string   required  BASE64URL(SHA256(code_verifier))
code_challenge_method   string   required  Must be "S256"
state                   string   required  Random CSRF token — echo back to your callback
Success
302 → /oauth/login?client_id=...&redirect_uri=...&code_challenge=...&state=...
Error · 400 / 403
{ "success": false, "error": "redirect_uri not allowed", "code": "INVALID_REDIRECT_URI" }

POST /oauth/authorize#

Authenticates user credentials and issues an authorization code. Called by the AuthSaas hosted login form — you do not call this directly.

Request body
{
  "client_id":             "your_client_id",
  "email":                 "user@example.com",
  "password":              "secret",
  "code_challenge":        "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  "code_challenge_method": "S256",
  "redirect_uri":          "https://your-app.com/api/auth/callback",
  "state":                 "a1b2c3d4e5f6"
}
Response · 200
{
  "success": true,
  "data": {
    "redirectTo": "https://your-app.com/api/auth/callback?code=abc123&state=a1b2c3d4e5f6"
  }
}

Note

The response is JSON (not a 302). The hosted login page reads data.redirectTo and navigates via window.location.href. This sidesteps the opaqueredirect fetch limitation.

POST /oauth/token#

Exchanges an authorization code for access and refresh tokens. Server-to-server only — no CORS headers, never call from a browser.

Request body
{
  "grant_type":    "authorization_code",
  "code":          "abc123",
  "client_id":     "your_client_id",
  "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
  "redirect_uri":  "https://your-app.com/api/auth/callback"
}
Response · 200
{
  "success": true,
  "data": {
    "access_token":  "eyJ...",
    "refresh_token": "eyJ...",
    "token_type":    "Bearer",
    "expires_in":    900
  }
}

The refresh token is also set as an httpOnly cookie (Path=/api/v1/auth, 7 days) for server-side use.

Error codes
INVALID_GRANT         400   Code not found, already used, expired, or verifier mismatch
INVALID_CLIENT        401   client_id mismatch
UNSUPPORTED_GRANT_TYPE 400  grant_type must be authorization_code

Security notes#

  • PKCE replaces client secrets for public clients. The code_verifier is never transmitted until step 4, so a stolen code alone cannot be exchanged.
  • State prevents CSRF. Always verify that the state in the callback matches the cookie you set in step 1.
  • Authorization codes expire in 10 minutes and are single-use. Replaying a used code returns INVALID_GRANT.
  • redirect_uri validation — only URIs that start with an allowedOrigin configured for your app (or localhost during development) are accepted.
  • Keep POST /oauth/token server-side. It has no CORS headers intentionally to prevent browser access.

Next.js full example#

The complete reference implementation (login-start, callback, refresh, logout) is available in the TaskFlow open-source project under app/api/auth/.

File layout
app/
  api/
    auth/
      login-start/route.ts   ← Step 1: generate PKCE params, redirect to AuthSaas
      callback/route.ts      ← Step 3: verify state, exchange code, set cookies
      refresh/route.ts       ← Proxy: reads httpOnly cookie, returns new access token
      logout/route.ts        ← Clears httpOnly refresh_token cookie