สารบัญ

เริ่มต้น

ต่อแอป Next.js

ลงทะเบียนแอป ใส่ SDK ป้องกันหน้า และตรวจสิทธิ์ ภายในไม่ถึงชั่วโมง

SDK @suphark/auth-client ให้แอป Next.js มีหน้าล็อกอิน session cookie ที่เข้ารหัส ต่ออายุโทเค็นอัตโนมัติ และตรวจสิทธิ์ ได้ในไม่กี่ไฟล์ ทุกอย่างด้านล่างรันบน Node.js runtime

  1. ลงทะเบียนแอปใน Pi Auth

    ในคอนโซลเปิด แอป → ลงทะเบียนแอป (แอปขององค์กร) หรือถ้าเป็น platform admin ลงทะเบียนแอปของแพลตฟอร์มให้ทุกองค์กรติดตั้งได้ ตั้ง redirect URI เป็น https://your-app.example.com/auth/callback แล้วคัดลอก client_id และ client_secret — secret แสดงครั้งเดียว

    ในกล่องเดียวกันเลือกวิธีล็อกอินที่แอปนี้เปิด (อีเมล+รหัสผ่าน, passkey, ลิงก์ทางอีเมล, Google, SSO ขององค์กร) — ตัวอย่างหน้าเข้าสู่ระบบทางขวาเปลี่ยนตามทันที และหน้า /sign-in จริงจะแสดงเฉพาะที่เปิดพร้อมชื่อแอป แก้ทีหลังได้จากเมนู "แก้ไข"

  2. ติดตั้ง SDK

    terminal
    pnpm add @suphark/auth-client   # หรือ npm i / bun add
  3. ตั้งค่า environment

    ตัวแปรค่า
    PI_AUTH_ISSUERhttps://auth.suphark.com — เฉพาะ origin ไม่มี /api/auth
    PI_AUTH_CLIENT_IDจากขั้นที่ 1
    PI_AUTH_CLIENT_SECRETจากขั้นที่ 1 (ไม่ต้องใส่ถ้าเป็น public client)
    PI_AUTH_SESSION_SECRETข้อความสุ่มยาวอย่างน้อย 32 ตัวอักษร — ใช้เข้ารหัส session cookie
    APP_URLorigin สาธารณะของแอป ใช้ประกอบ redirect URI
  4. สร้าง client

    src/lib/pi-auth.ts
    import { createPiAuth } from "@suphark/auth-client/next";
    
    export const piAuth = createPiAuth({
      // issuer / clientId / clientSecret / sessionSecret / redirectUri อ่านจาก env ข้างบนเอง (SDK ≥ 0.7.1)
      session: { cookieName: "myapp.session", maxAgeSeconds: 60 * 60 * 8 },
      // แอปที่ผูกกับองค์กรเดียว: ตั้ง PI_AUTH_TENANT=acme หรือใส่ defaultTenant: "acme" — ใส่ tenant ให้ทุกครั้งที่ล็อกอิน และปฏิเสธ token ขององค์กรอื่น
      // "local" = ปุ่มออกจากระบบออกเฉพาะแอปนี้ (ยังล็อกอินที่ Pi Auth อยู่ → เข้าใหม่ได้ทันที); ค่าเริ่มต้น "all" = ออกทุกแอป
      logoutScope: "local",
      // ไม่บังคับ: sync แถวผู้ใช้ของแอป (เรียกตอนเห็นผู้ใช้ ไม่เกินทุก 15 นาทีต่อผู้ใช้)
      // onUserSync: (user) => db.user.upsert({ where: { piAuthId: user.id }, update: {...}, create: {...} }),
    });
    
    // layout: วาง <SessionExpiredRedirect /> (จาก @suphark/auth-client/react) ครั้งเดียว — server action ที่ session หมดจะพาไปล็อกอินแล้วกลับหน้าเดิม
    // ในหน้า / server action: ต้องล็อกอิน (+ มีสิทธิ์ / ยืนยันตัวตนสดสำหรับงานสำคัญ) — ไม่ต้องเขียน wrapper เอง
    // const user = await piAuth.requireUser({ permission: "app:use" });
    // await piAuth.requireUser({ permission: "app:admin", fresh: 300 });

    ไม่ส่งค่าให้ createPiAuth ก็ได้ — อ่านจาก env ข้างบน (PI_AUTH_TENANT → defaultTenant); ขาดตัวไหนจะบอกชื่อ env ที่ขาดครบในครั้งเดียว ถ้ามี UPSTASH_REDIS_REST_URL/TOKEN ระบบถอน session (back-channel logout) ครบทุก instance ให้อัตโนมัติ

    ผู้ใช้ที่อยู่หลายองค์กร: ส่งไป /auth/login?tenant=<slug> (ชนะ defaultTenant); ออกจากระบบ: /auth/logout?scope=local&next=/login หรือ ?scope=all — หรือใช้ piAuth.logoutUrl({ scope, next })

  5. ติดตั้ง route ของ auth

    route แบบ catch-all ตัวเดียวให้บริการ /auth/login, /auth/callback, /auth/logout, /auth/session และ /auth/backchannel-logout

    app/auth/[...pi]/route.ts
    import { piAuth } from "@/lib/pi-auth";
    export const { GET, POST } = piAuth.handlers;
  6. ป้องกันหน้าด้วย middleware

    middleware พาผู้ที่ยังไม่ล็อกอินไปหน้าล็อกอิน และต่ออายุโทเค็นที่ใกล้หมดให้เบื้องหลัง ระบุ path ที่เปิดสาธารณะไว้

    proxy.ts (Next 16) / middleware.ts
    import { piAuth } from "@/lib/pi-auth";
    export default piAuth.middleware({ publicPaths: ["/", "/api/webhooks"] });
  7. อ่านผู้ใช้และตรวจสิทธิ์

    server component / route handler
    import { piAuth } from "@/lib/pi-auth";
    
    const user = await piAuth.getCurrentUser();      // null ถ้ายังไม่ล็อกอิน
    piAuth.requirePermission(user, "memo:manage");   // throw 403 problem
    user?.tenant?.slug;                               // "acme"
    user?.org?.path;                                  // "/acme/eng/it"
    user?.perms;                                      // ["memo:create", ...]
    client component
    "use client";
    import { usePiAuth } from "@suphark/auth-client/react";
    
    export function ApproveButton() {
      const { user, hasPermission } = usePiAuth();
      if (!hasPermission("memo:approve")) return null;
      return <button>อนุมัติ</button>;
    }
  8. ประกาศ permission และผูก role

    ส่ง permission manifest ให้องค์กรเห็นว่า memo:manage หมายถึงอะไร แล้วให้ผู้ดูแลติดตั้งแอปและ ผูก role ตราบใดที่ยังไม่ผูก perms จะว่างและ requirePermission จะปฏิเสธ

SDK ทำอะไรให้บ้าง

  • รัน authorization code flow พร้อม PKCE, state และ nonce ทุกครั้งที่ล็อกอิน
  • เก็บ access/refresh token ใน cookie ที่เข้ารหัส A256GCM (HttpOnly, SameSite=Lax, Secure บน https) — JavaScript ในเบราว์เซอร์แตะไม่ถึง
  • ตรวจ JWT กับ JWKS ของ issuer พร้อม cache และรองรับการหมุน key, เผื่อเวลาเคลื่อน 60 วินาที
  • ต่ออายุโทเค็นใน middleware ก่อนหมดอายุ; รองรับ back-channel logout เมื่อ session ถูกถอนใน Pi Auth
  • รับ ?next= เฉพาะ path ภายใน จึงใช้เป็น open redirect ไม่ได้
  • /auth/login?tenant=<slug> ขอองค์กรที่เจาะจง ถ้าได้โทเค็นของ tenant อื่นจะปฏิเสธด้วย TENANT_MISMATCH

เมื่อล็อกอินไม่สำเร็จ

callback ที่ล้มเหลวจะไม่โชว์ JSON ดิบให้ผู้ใช้: SDK ล้าง transaction แล้วพาไป errorRedirect (ค่าเริ่มต้น /) พร้อม ?auth_error=<code> — ส่งเฉพาะรหัส ไม่ส่งข้อความ และรับเฉพาะ path ภายใน อ่านที่หน้านั้นแล้วแสดงข้อความของแอปเอง:

app/page.tsx
import { readAuthError } from "@suphark/auth-client/next";

export default async function Home({ searchParams }) {
  const authError = readAuthError(await searchParams);
  // { code: "TENANT_MISMATCH", tenant: "beta" } | { code: "TRANSACTION_EXPIRED" } | null
  return authError ? <Banner code={authError.code} tenant={authError.tenant} /> : <App />;
}
รหัสความหมายควรแสดงอะไร
TRANSACTION_EXPIREDเริ่มล็อกอินเกิน 10 นาที หรือ cookie หาย“ล็อกอินใหม่อีกครั้ง”
STATE_MISMATCHcallback ไม่ตรงกับการล็อกอินที่เริ่มไว้ (กด back, ลิงก์เก่า)“ล็อกอินใหม่อีกครั้ง”
TENANT_MISMATCHขอ ?tenant=X แต่ผู้ใช้ไม่ได้เป็นสมาชิก X; tenant ที่ขออยู่ใน tenant“คุณไม่ได้เป็นสมาชิกของ X” + ปุ่มล็อกอินโดยไม่ระบุ tenant
TOKEN_REQUEST_FAILEDPi Auth ปฏิเสธการแลก code (client secret หรือ redirect URI ผิด)ข้อความทั่วไป; ตรวจ env ของแอป
INVALID_TOKENtoken ตรวจไม่ผ่าน (issuer, audience, ลายเซ็น)ข้อความทั่วไป; ตรวจ PI_AUTH_ISSUER

ส่ง onError(error, request) ให้ createPiAuth ถ้าต้องการตอบเองทั้งหมด request ที่ขอ application/json ยังได้ JSON แบบเดิม

เรียก Pi Auth จากฝั่งหลังบ้าน

งานที่ไม่มีผู้ใช้ล็อกอิน (sync ตอนกลางคืน, ตัวหาผู้อนุมัติที่ต้องใช้ผังองค์กร) ให้สร้าง service account แล้วส่ง credential ของมันใน service:

server
const svc = piAuth.serviceClient(); // ต้องตั้ง service: { clientId, clientSecret, scopes } ใน createPiAuth
await svc.directory.resolve({
  positionCode: "DIR",
  withinOrgPathOf: user!.tenant!.membershipId!,
}); // → ผู้อำนวยการที่อยู่เหนือหน่วยงานของสมาชิกคนนี้