ข้ามไปยังเนื้อหา
Developers

Integration guides

Working code for server-rendered web apps, SPAs, mobile apps, and client libraries.

Pick the one that matches your app. There is really only one question behind the choice: can your code keep a secret? If it can, use confidential. If it cannot, use public.

Web app with a backend

Confidential client — the client_secret stays on your server. This is the safest arrangement, and the one to choose whenever you can.

typescriptapp/auth/login/route.ts — starting the sign-in
import { redirect } from "next/navigation";
import { cookies } from "next/headers";

const ISSUER = "https://visitkorat.com";

function base64url(b: Uint8Array) {
  return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export async function GET() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const state    = base64url(crypto.getRandomValues(new Uint8Array(16)));
  const nonce    = base64url(crypto.getRandomValues(new Uint8Array(16)));

  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
  const challenge = base64url(new Uint8Array(digest));

  // httpOnly: the browser must not be able to read these back, and 10 minutes
  // is longer than any real login takes.
  const jar = await cookies();
  const opts = { httpOnly: true, secure: true, sameSite: "lax" as const, path: "/", maxAge: 600 };
  jar.set("vk_verifier", verifier, opts);
  jar.set("vk_state", state, opts);
  jar.set("vk_nonce", nonce, opts);

  const url = new URL(ISSUER + "/oauth/authorize");
  url.searchParams.set("client_id", process.env.VK_CLIENT_ID!);
  url.searchParams.set("redirect_uri", process.env.VK_REDIRECT_URI!);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", "openid email profile");
  url.searchParams.set("state", state);
  url.searchParams.set("nonce", nonce);
  url.searchParams.set("code_challenge", challenge);
  url.searchParams.set("code_challenge_method", "S256");
  url.searchParams.set("terms_version", "2026-08-01");
  url.searchParams.set("privacy_version", "2026-08-01");

  redirect(url.toString());
}
typescriptapp/auth/callback/route.ts — exchange the code, open a session
import { cookies } from "next/headers";
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://visitkorat.com";
// Module scope: the key set is cached across requests and refetched only when
// an unknown kid appears — which is what a key rotation looks like from here.
const JWKS = createRemoteJWKSet(new URL(ISSUER + "/.well-known/jwks.json"));

export async function GET(req: Request) {
  const url = new URL(req.url);
  const jar = await cookies();

  // The app may have declined, or the request may have been rejected.
  const error = url.searchParams.get("error");
  if (error) return new Response(url.searchParams.get("error_description") ?? error, { status: 400 });

  // ⚠️ Compare state BEFORE anything else. This is the CSRF check, and it is
  // worthless if it happens after the code has already been spent.
  const state = url.searchParams.get("state");
  if (!state || state !== jar.get("vk_state")?.value) {
    return new Response("state mismatch", { status: 400 });
  }

  const res = await fetch(ISSUER + "/oauth/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: "Basic " + btoa(
        `${encodeURIComponent(process.env.VK_CLIENT_ID!)}:${encodeURIComponent(process.env.VK_CLIENT_SECRET!)}`,
      ),
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: url.searchParams.get("code")!,
      redirect_uri: process.env.VK_REDIRECT_URI!,
      code_verifier: jar.get("vk_verifier")!.value,
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    return new Response(err.error_description ?? err.error, { status: 400 });
  }
  const tokens = await res.json();

  const { payload } = await jwtVerify(tokens.id_token, JWKS, {
    issuer: ISSUER,
    audience: process.env.VK_CLIENT_ID!,
  });
  if (payload.nonce !== jar.get("vk_nonce")?.value) {
    return new Response("nonce mismatch", { status: 400 });
  }

  // Key your user record on payload.sub — never on the email, people change those.
  await upsertUser({ vkSub: payload.sub as string, email: payload.email as string });

  for (const c of ["vk_verifier", "vk_state", "vk_nonce"]) jar.delete(c);
  await startSession(payload.sub as string);

  // ⚠️ A path, not new URL("/", req.url). Reading the query off req.url above is
  // fine, but building a redirect from it is not: behind a proxy — Cloud Run,
  // Vercel, any load balancer — req.url carries the internal host, so that
  // redirect sends people somewhere like https://0.0.0.0:8080/. It fails
  // silently, because the status code is correct and only a human sees where
  // they landed. A relative Location is resolved against the address bar
  // (RFC 7231 §7.1.2).
  return new Response(null, { status: 303, headers: { Location: "/" } });
}

Single-page app

Public client — no secret, because code running in a browser cannot hold one. Request a public application and list your origin under allowed origins.

⚠️ /oauth/token answers CORS only for origins you registered, one at a time — there is no wildcard. If the browser blocks the request, check the origin matches exactly, scheme and port included.

typescriptStarting the sign-in from the browser
const ISSUER = "https://visitkorat.com";
const CLIENT_ID = "your-spa-91c4a3f2";
const REDIRECT = window.location.origin + "/callback";

function base64url(b: Uint8Array) {
  return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export async function login() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const state    = base64url(crypto.getRandomValues(new Uint8Array(16)));
  const nonce    = base64url(crypto.getRandomValues(new Uint8Array(16)));

  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));

  // sessionStorage, not localStorage: this is per-tab, single-use scratch data
  // and it should not outlive the tab that created it.
  sessionStorage.setItem("vk_verifier", verifier);
  sessionStorage.setItem("vk_state", state);
  sessionStorage.setItem("vk_nonce", nonce);

  const url = new URL(ISSUER + "/oauth/authorize");
  Object.entries({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT,
    response_type: "code",
    scope: "openid email",
    state, nonce,
    code_challenge: base64url(new Uint8Array(digest)),
    code_challenge_method: "S256",
    terms_version: "2026-08-01",
    privacy_version: "2026-08-01",
  }).forEach(([k, v]) => url.searchParams.set(k, v));

  window.location.assign(url.toString());
}
typescript/callback — exchange the code, with no client_secret
export async function handleCallback() {
  const params = new URLSearchParams(window.location.search);

  if (params.get("state") !== sessionStorage.getItem("vk_state")) {
    throw new Error("state mismatch");
  }

  const res = await fetch("https://visitkorat.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: params.get("code")!,
      redirect_uri: window.location.origin + "/callback",
      code_verifier: sessionStorage.getItem("vk_verifier")!,
      client_id: "your-spa-91c4a3f2",
    }),
  });

  const tokens = await res.json();
  if (!res.ok) throw new Error(tokens.error_description ?? tokens.error);

  ["vk_verifier", "vk_state", "vk_nonce"].forEach((k) => sessionStorage.removeItem(k));
  return tokens; // { access_token, id_token, expires_in, ... }
}

Keep the access_token in memory if you can. A token in localStorage is readable by any XSS that reaches the same page.

If you already have a backend, the safer arrangement is a confidential client with the SPA talking to your own server (backend-for-frontend) — then no token touches the browser at all.

Mobile app

Public client, as for an SPA. Open the authorize URL in the system browser — ASWebAuthenticationSession on iOS, Custom Tabs on Android — never an embedded WebView. A WebView can read everything the person types, so they have no way to know whether they are really talking to us.

Register a universal link / app link (https://) as the redirect URI where you can. Nothing stops another app claiming a custom scheme like myapp://, and we accept only https:// and http://localhost anyway.

swiftiOS — ASWebAuthenticationSession
let session = ASWebAuthenticationSession(
  url: authorizeURL,                      // built the same way as the SPA example
  callbackURLScheme: nil                  // universal link
) { callbackURL, error in
  guard let callbackURL, error == nil else { return }
  // 1. compare state  2. POST /oauth/token with code_verifier  3. verify id_token
}
session.prefersEphemeralWebBrowserSession = false   // reuse the browser session the person already has
session.start()

Using a client library

The examples above are hand-rolled so you can see what happens. In production, use a library that has been through conformance testing: clock skew, an aud that arrives as an array, refetching JWKS on an unknown key — those are where hand-written implementations go wrong.

typescriptopenid-client (Node)
import * as client from "openid-client";

// every endpoint comes from discovery — nothing hardcoded but the issuer
const config = await client.discovery(
  new URL("https://visitkorat.com"),
  process.env.VK_CLIENT_ID!,
  process.env.VK_CLIENT_SECRET!,
);

const verifier = client.randomPKCECodeVerifier();
const challenge = await client.calculatePKCECodeChallenge(verifier);
const nonce = client.randomNonce();
const state = client.randomState();

const authUrl = client.buildAuthorizationUrl(config, {
  redirect_uri: process.env.VK_REDIRECT_URI!,
  scope: "openid email profile",
  code_challenge: challenge,
  code_challenge_method: "S256",
  state, nonce,
  // Visit Korat specific: the versions of YOUR documents, stored with the consent (PDPA)
  terms_version: "2026-08-01",
  privacy_version: "2026-08-01",
});

// ...after the person lands back on your callback
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
  pkceCodeVerifier: verifier,
  expectedState: state,
  expectedNonce: nonce,
});
const claims = tokens.claims();   // signature and claims already verified

Others that work as-is: next-auth / @auth/core (configure a generic OIDC provider pointed at our issuer), pyoidc and authlib for Python, league/oauth2-client for PHP, and AppAuth for iOS and Android.

Always send terms_version and privacy_version. No standard library knows about them, so you have to pass them as extra authorization parameters — and the request is refused without them (see troubleshooting).