@suphark/auth-client
v0.8.2
Published
Pi Auth SDK: OIDC code+PKCE flow, encrypted session cookie, JWT verify via JWKS, Next.js handlers/middleware, React hook, service client, webhook verify
Maintainers
Readme
@suphark/auth-client
SDK สำหรับแอป Next.js ที่ต่อ Pi Auth (OIDC provider + directory + permission แบบ multi-tenant)
bun add @suphark/auth-client # หรือ npm i / pnpm addเริ่มเร็ว (0.7.1) — แอปเขียนแค่ 4 จุด
env: PI_AUTH_ISSUER, PI_AUTH_CLIENT_ID, PI_AUTH_CLIENT_SECRET, PI_AUTH_SESSION_SECRET, APP_URL
(ไม่บังคับ: PI_AUTH_TENANT ผูกองค์กรเดียว, UPSTASH_REDIS_REST_URL/TOKEN → ถอน session ครบทุก instance อัตโนมัติ)
// lib/pi-auth.ts — อ่านค่าจาก env เอง (ขาดตัวไหนแจ้งชื่อที่ขาดครบในครั้งเดียว)
import { createPiAuth } from "@suphark/auth-client/next";
export const piAuth = createPiAuth({
// ไม่บังคับ: sync แถวผู้ใช้ของแอป — เรียกตอนเห็นผู้ใช้ (throttle 15 นาที, error ไม่ทำให้คำขอล้ม)
onUserSync: (u) => db.user.upsert({ where: { piAuthId: u.id }, update: {...}, create: {...} }),
});
// app/auth/[...pi]/route.ts
export const { GET, POST } = piAuth.handlers;
// proxy.ts — server action ไม่มี session ได้ 401 JSON { error: "session_expired" } (วาง <SessionExpiredRedirect /> ใน layout ให้พาไปล็อกอิน)
export default piAuth.middleware({ publicPaths: ["/"] });
// หน้า / server action / route handler
const user = await piAuth.requireUser({ permission: P.use }); // ไม่ล็อกอิน → redirect, ไม่มีสิทธิ์ → PermissionDeniedError
await piAuth.requireUser({ permission: P.admin, fresh: 300 }); // งานสำคัญ: ต้องยืนยันตัวตนภายใน 5 นาที (ไม่สด → redirect ไป reauth)
await piAuth.requireUser({ api: true }); // JSON API: โยน PiAuthError 401/403 แทน redirectผูกองค์กรเดียว: PI_AUTH_TENANT (หรือ defaultTenant) — ไม่ตั้ง = ไม่ล็อกองค์กร (ไม่มี tenant_hint/TENANT_MISMATCH) ถ้าต้องล็อกให้แน่นอนให้ใส่ default ใน env schema ของแอปแล้วส่ง defaultTenant เอง ·
dev bypass / build โดยไม่มี secret: ขาด env ตอน next build ได้ (ใช้ค่าชั่วคราว) แต่ตอน start ยังโยน MISCONFIGURED — แอปที่รันโดยไม่มี PI_AUTH_* (dev bypass) ให้ส่งค่า dev ใน createPiAuth({...}) เฉพาะตอนเปิด bypass
หน้า sign-in ของแอปไม่จำเป็นต้องมี (/auth/login พาไปหน้าของ Pi Auth) — ถ้ามี ใช้ describeAuthError(code, { locale, tenant })
(@suphark/auth-client/react) แทนตารางข้อความของตัวเอง: คืน { title, body, next, terminal } — terminal = ล็อกอินซ้ำไม่ช่วย
(ซ่อนปุ่มหลักแล้วบอกว่าใครแก้ได้) · safeNextPath export จาก /next ไม่ต้องเขียนตัวกรอง next เอง
ตัวอย่างเต็ม (ตั้งค่าเองทุกตัว):
// src/lib/pi-auth.ts
import { createPiAuth, createUpstashRevokedSessionStore } from "@suphark/auth-client/next";
export const piAuth = createPiAuth({
issuer: process.env.PI_AUTH_ISSUER!, // https://auth.example.com (ไม่มี /api/auth)
clientId: process.env.PI_AUTH_CLIENT_ID!,
clientSecret: process.env.PI_AUTH_CLIENT_SECRET,
redirectUri: `${process.env.APP_URL}/auth/callback`,
scopes: ["openid", "profile", "email", "offline_access", "tenant", "permissions"],
sessionSecret: process.env.PI_AUTH_SESSION_SECRET!, // ≥ 32 ตัวอักษร
session: { cookieName: "myapp.session", maxAgeSeconds: 60 * 60 * 8 },
// แอปที่ผูกกับองค์กรเดียว: ใส่ tenant_hint ให้ทุกครั้ง (รวม redirect จาก middleware) และปฏิเสธ token ขององค์กรอื่น
defaultTenant: "suphark",
// ปุ่ม "ออกจากระบบ" ของแอปนี้ออกเฉพาะแอป (ผู้ใช้ยังล็อกอินที่ Pi Auth → กลับเข้าได้ทันที); ค่าเริ่มต้น "all" = ออกทุกแอป
logoutScope: "local",
// back-channel logout บนหลาย instance (Vercel): เก็บ sid ที่ถูกถอนใน Upstash แทน memory (ค่าเริ่มต้น = instance เดียวเท่านั้น)
revokedSessionStore: createUpstashRevokedSessionStore({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
}),
});
// app/auth/[...pi]/route.ts
export const { GET, POST } = piAuth.handlers; // /auth/login, /auth/callback, /auth/logout, /auth/session, /auth/backchannel-logout
// proxy.ts (middleware)
export default piAuth.middleware({ publicPaths: ["/", "/api/webhooks"] });
// ผู้ใช้อยู่หลายองค์กร: ส่งไป /auth/login?tenant=<slug> เพื่อขอ token ขององค์กรนั้น (ชนะ defaultTenant)
// (Pi Auth สลับ tenant ให้ถ้าเป็นสมาชิก; ถ้าได้ token ของ tenant อื่นกลับมา SDK ปฏิเสธด้วย TENANT_MISMATCH)
// logout: /auth/logout (ตาม logoutScope) · /auth/logout?scope=local&next=/login · /auth/logout?scope=all
// piAuth.logoutUrl({ scope: "local", next: "/login" }) สร้าง URL ให้; scope=all กลับที่ postLogoutRedirectUri
// ลิงก์ logout ที่มาจากเว็บอื่น (CSRF) ได้หน้ายืนยันแทนการ logout ทันที (0.6.3)
// ยืนยันตัวตนซ้ำก่อนงานสำคัญ (0.6.4): user.authTime = เวลาล็อกอินที่ Pi Auth ครั้งล่าสุด (unix seconds)
// if (!user.authTime || Date.now() / 1000 - user.authTime > 300) redirect(piAuth.reauthUrl("/secret"))
// callback ล้มเหลว (ลิงก์หมดอายุ, กด back, tenant ไม่ตรง ฯลฯ) → redirect ไป errorRedirect (ค่าเริ่มต้น "/")
// พร้อม ?auth_error=<code> (และ &tenant=<slug> เมื่อเป็น TENANT_MISMATCH) — ไม่ใช่ JSON ดิบ
// createPiAuth({ ..., errorRedirect: "/welcome" }) หรือคุมเองด้วย onError(error, request) => Response
// app/page.tsx — แสดงข้อความตามรหัส (server: import จาก /next; client component: import จาก /react)
import { readAuthError } from "@suphark/auth-client/next";
const authError = readAuthError(await searchParams); // { code: "TENANT_MISMATCH", tenant: "beta" } | null
// server
const user = await piAuth.getCurrentUser(); // null ถ้าไม่ได้ล็อกอิน
piAuth.requirePermission(user, "memo:manage"); // throw 403
user?.org?.path; // "/acme/eng/it"
user?.tenants; // องค์กรทั้งหมดที่เป็นสมาชิก [{ id, slug, name, role }] (scope tenant; ใช้กับ <OrganizationSwitcher/> ของ @suphark/auth-ui)
// service-to-service (client_credentials ของ service account)
const svc = piAuth.serviceClient(); // ต้องตั้ง service: { clientId, clientSecret, scopes }
await svc.directory.resolve({ positionCode: "DIR", withinOrgPathOf: user!.tenant!.membershipId! });// รับ webhook: app/api/pi-webhook/route.ts — ตรวจด้วย JWKS ของ PI_AUTH_ISSUER ไม่ต้องมี secret
import { parsePiWebhook } from "@suphark/auth-client/webhooks";
export async function POST(request: Request) {
const event = await parsePiWebhook(request); // หรือ parsePiWebhook(request, { secret }) แบบ HMAC
// idempotent ด้วย event.id
return new Response(null, { status: 204 });
}// client
import { readAuthError, usePiAuth } from "@suphark/auth-client/react";
const { user, hasPermission } = usePiAuth();
const authError = readAuthError(useSearchParams()); // หน้า login ที่เป็น client component- access token เก็บใน cookie ที่เข้ารหัส (JWE) ไม่ถึง browser; refresh อัตโนมัติใน middleware
- verify JWT ด้วย JWKS ของ issuer (cache + rotation), clock skew 60 วินาที
- ต้องการ Node.js runtime (ใช้
node:crypto); middleware ของ Next 16 (proxy.ts) รันบน Node อยู่แล้ว
e2e แบบล็อกอินแล้ว (0.8.0) — @suphark/auth-client/testing
ให้ CI ได้ session ของบัญชีทดสอบเอง ไม่ต้องคัดลอก cookie ของคนจริงไปเก็บเป็น secret:
// e2e/global-setup.ts (Playwright)
import { createTestSession } from "@suphark/auth-client/testing";
export default async function globalSetup() {
const s = await createTestSession({
issuer: process.env.PI_AUTH_ISSUER!,
clientId: process.env.PI_AUTH_CLIENT_ID!, // client **dev** ที่เปิด "ล็อกอินทดสอบ (CI)" ไว้
clientSecret: process.env.PI_AUTH_CLIENT_SECRET!,
sessionSecret: process.env.PI_AUTH_SESSION_SECRET!, // ค่าเดียวกับที่แอปที่รันอยู่ใช้
email: process.env.PI_AUTH_TEST_EMAIL!,
cookieName: "pi-done.session", // = session.cookieName ที่แอปตั้งใน createPiAuth
appUrl: "http://localhost:3000",
});
fs.writeFileSync("e2e/.auth/state.json", JSON.stringify(s.storageState)); // + use: { storageState } ใน playwright.config
}- Pi Auth ต้องตั้งก่อน: แพลตฟอร์มเปิดสวิตช์ · client dev (redirect เป็น localhost ทั้งหมด) เปิดล็อกอินทดสอบ + ระบุอีเมลบัญชีทดสอบ ·
บัญชีทดสอบเป็นสมาชิกองค์กร SANDBOX ที่ติดตั้งแอป — ขั้นตอนเต็มที่
docs/ops/ci-test-login.mdของ Pi Auth - session อายุ 15 นาที ไม่มี refresh token; ผิดเงื่อนไขใดก็ปฏิเสธพร้อมเหตุผล (
TestSessionError.messageเช่นuser_not_listed) - ใช้ใน runtime ของแอปจริงไม่ได้ (ปฏิเสธเมื่อ
NODE_ENV=production)
ความปลอดภัยของ SDK (สิ่งที่ทำให้แล้ว)
sessionSecretต้องยาว ≥ 32 ตัวอักษร — สั้นกว่านั้นcreatePiAuthจะ throwWEAK_SESSION_SECRET(กัน env หายแล้วได้ cookie ที่ถอดได้)- cookie session/transaction เข้ารหัส A256GCM (key จาก HKDF ของ sessionSecret) ·
HttpOnly·SameSite=Lax·Secureเมื่อ redirect URI เป็น https - PKCE S256 +
state+nonceทุกครั้ง;nextรับเฉพาะ path ภายใน (กัน open redirect) perms_refดึงเฉพาะ URL ใต้ origin ของ issuer — access token ไม่ถูกส่งไป host อื่น- manifest (code-first):
definePermissions({...})ในlib/permissions.ts→P.keysสำหรับhasPermission()+ ส่งmanifest: P.manifestให้createPiAuthเพื่อเปิด routeGET /auth/manifestอัตโนมัติให้ Pi Auth ดึงตอน register/กด Sync; confidential client ยัง Push ผ่าน CI ได้; มีPiAuthManifest/validatePiAuthManifestสำหรับตรวจ JSON - ขอ
?tenant=<slug>แล้วได้ token ของ tenant อื่น →TENANT_MISMATCH(ตั้งแต่ 0.1.2) — แอปที่ผูกกับองค์กรเดียวไม่มีทางได้ session ผิดองค์กร - callback ล้มเหลวส่งกลับแอปเป็นรหัสเท่านั้น (
PI_AUTH_ERROR_CODES) ไม่ส่งข้อความลง URL;errorRedirectรับเฉพาะ path ภายใน (ตั้งแต่ 0.1.3) — request ที่ขอAccept: application/jsonยังได้ JSON แบบเดิม - แพ็กเกจไม่มี secret/hostname ภายใน และไม่แนบ source map (ตั้งแต่ 0.1.1)
