@comitor/account-sdk
v0.1.1
Published
SDK tích hợp Comitor.Account (OIDC) cho các ứng dụng trong hệ sinh thái Comitor.
Readme
@comitor/account-sdk
SDK tích hợp Comitor.Account — Identity Provider (OIDC) dùng chung của hệ sinh thái SaaS Comitor — cho một ứng dụng bất kỳ trong hệ.
Mục tiêu: app mới tích hợp xác thực trong dưới một ngày.
Đây không chỉ là tiện ích — nó là LỚP CÁCH LY
Gói này nuốt mọi hành vi ngầm của tầng OIDC bên dưới, để:
- Không app nào phải biết tới chúng.
- Đổi tầng OIDC sau này là thay gói này, không phải viết lại từng app.
Vì vậy: đừng để app nào import thẳng better-auth/*. Thiếu thứ gì thì bổ sung vào đây.
Bảy hành vi ngầm được nuốt ở đây
| # | Hành vi | Hậu quả nếu app tự làm |
|---|---|---|
| 1 | Access token chỉ là JWT khi request có resource= | Token thành chuỗi opaque, verify qua JWKS không được, claim tuỳ biến biến mất |
| 2 | Back-channel logout đòi scope offline_access | Client không bao giờ được gọi khi phiên bị xoá — log rỗng, không lỗi |
| 3 | PKCE S256 bắt buộc cho mọi client, kể cả confidential | plain bị từ chối với invalid_request |
| 4 | /oauth2/authorize đổi kiểu phản hồi theo sec-fetch-mode, không theo Accept | Gọi bằng fetch nhận 200 + JSON, không có Location |
| 5 | Chữ ký webhook ký trên "{timestamp}.{body}", body phải là chuỗi thô | JSON.parse rồi stringify lại là chữ ký không bao giờ khớp |
| 6 | Dùng lại authorization code hỏng luôn refresh token của grant đó | Thử lại một lần là mất cả chuỗi |
| 7 | Scope M2M cần resource riêng thì mới thực sự hẹp | Server lấy giao hai tập; chung resource là ranh giới vô nghĩa |
Cài đặt
pnpm add @comitor/account-sdkPeer tuỳ chọn: next (chỉ khi dùng entry /next).
Tích hợp một app Next.js — bốn bước
1. Cấu hình một chỗ
// lib/account.ts
import type { AccountClientConfig } from "@comitor/account-sdk";
export const account: AccountClientConfig = {
accountUrl: process.env.COMITOR_ACCOUNT_URL!, // https://account.comitor.ai
clientId: process.env.COMITOR_CLIENT_ID!,
clientSecret: process.env.COMITOR_CLIENT_SECRET, // bỏ trống với app di động
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
postLogoutRedirectUri: process.env.APP_URL,
appKey: "tasks" // định danh app trong danh mục Comitor
};2. Bắt đầu đăng nhập
// app/api/auth/dang-nhap/route.ts
import { redirect } from "next/navigation";
import { startSignIn } from "@comitor/account-sdk/next";
import { account } from "@/lib/account";
export async function GET() {
redirect(await startSignIn(account));
}startSignIn cất state và code_verifier vào cookie httpOnly sống 10 phút — khớp đúng hạn
của authorization code ở phía Account.
3. Nhận callback
// app/api/auth/callback/route.ts
import { completeSignIn } from "@comitor/account-sdk/next";
import { account } from "@/lib/account";
export async function GET(request: Request) {
const tokens = await completeSignIn(account, new URL(request.url));
// Phát cookie phiên CỦA RIÊNG APP ở đây. Xem "Nguyên tắc phiên" bên dưới.
}completeSignIn kiểm state — đó là thứ duy nhất chặn CSRF trên luồng đăng nhập, đừng bỏ qua.
4. Guard trong layout của workspace
// app/w/[slug]/layout.tsx
import { requireWorkspaceAccess } from "@comitor/account-sdk";
import { account } from "@/lib/account";
export default async function Layout({ params, children }) {
const { slug } = await params;
const { user, workspace, app } = await requireWorkspaceAccess(account, accessToken, slug);
// …
}Nguyên tắc phiên — đọc trước khi tự chế
App phát cookie phiên của RIÊNG NÓ. Cookie của Comitor.Account nằm ở account.comitor.ai và
không chia sẻ sang subdomain của app. SSO đi qua luồng OIDC, không đi qua cookie chung — an toàn
hơn và đúng chuẩn hơn.
Refresh token là bí mật dài hạn: giữ ở server, không bao giờ để lọt xuống trình duyệt. Khi xoay token, phải lưu bộ mới — Account bật rotation nên refresh token cũ mất hiệu lực ngay:
const fresh = await ensureFreshTokens(account, tokens, async (rotated) => {
await luuVaoPhien(rotated); // KHÔNG lưu là lần sau người dùng bị đăng xuất không rõ lý do
});⚠ ensureFreshTokens chống đua từ 0.1.1, và đó là một lỗi ĐÃ ĐO ĐƯỢC. Một lượt tải trang của
Next sinh ra vài request phía máy chủ, và cache() của React chỉ gộp trong MỘT request. Khi access
token vừa hết hạn, hai request cùng gọi refresh với cùng một token; Account xoay token nên bản cũ
chết ngay, và request thua nhận 401 → phiên rơi.
Lỗi này TỰ CHE: SSO đưa người dùng quay lại trong dưới một giây, nên không ai báo. Trên production triệu chứng duy nhất là "thỉnh thoảng bị đăng xuất không rõ lý do".
Từ 0.1.1, các lời gọi đồng thời cùng một refresh token chia nhau MỘT lần xoay, và onRotate
chạy cho mọi người gọi — không chỉ người thắng. Nửa thứ hai đó là bắt buộc: mỗi request có phản
hồi riêng, nên request nào không chạy onRotate sẽ trả về phản hồi không mang cookie mới.
⚠ Phạm vi: MỘT tiến trình. Nhiều instance sau load balancer vẫn đua — ở đó phải lưu phiên vào database và xoay trong một giao dịch.
Đăng xuất khỏi riêng app thì chỉ cần xoá cookie của app — phiên tại Account còn nguyên và người
dùng vẫn đang đăng nhập ở các sản phẩm khác. createLogoutUrl() là đăng xuất toàn hệ, chỉ dùng
khi người dùng thật sự muốn thoát khỏi mọi sản phẩm Comitor.
Ba lớp guard, ba thông điệp khác nhau
requireWorkspaceAccess tách rõ ba trường hợp vì chúng dẫn tới ba hành động khác nhau của người
dùng. Gộp lại thành "bạn không có quyền" là để họ tự đoán:
| Mã lỗi | Nghĩa | Người dùng phải làm gì |
|---|---|---|
| NOT_A_MEMBER | không thuộc không gian làm việc | xin quản trị viên mời vào |
| APP_NOT_ENABLED | workspace chưa bật ứng dụng này | quản trị viên bật trong phần Ứng dụng |
| NO_SEAT | đã bật nhưng bạn chưa có chỗ ngồi | xin quản trị viên cấp chỗ |
| APP_SUSPENDED | quá hạn thanh toán | cập nhật thanh toán |
Chỗ ngồi tính theo TỪNG ỨNG DỤNG, không theo workspace: một người dùng Tasks mà không dùng CRM là chuyện bình thường. Đừng dựng logic kiểu "là thành viên thì có mọi ứng dụng".
Webhook
Account gửi webhook để bù cho độ trễ cache: app cache tư cách thành viên 30–60 giây, nhưng gỡ thành viên và thu hồi chỗ ngồi thì phải tới ngay.
// app/api/comitor/webhook/route.ts
import { readWebhook, invalidateContextCache } from "@comitor/account-sdk";
export async function POST(request: Request) {
const result = await readWebhook(request, process.env.COMITOR_WEBHOOK_SECRET!);
if (!result.ok) return new Response(result.reason, { status: 401 });
// PHẢI tự khử trùng lặp bằng payload.id — Account thử lại tới 5 lần.
if (await daXuLy(result.payload.id)) return new Response(null, { status: 200 });
switch (result.payload.event) {
case "member.removed":
case "seat.revoked":
invalidateContextCache();
await huyPhienLienQuan(result.payload.data);
break;
}
return new Response(null, { status: 200 });
}⚠ Route nhận webhook không được redirect. Account gửi với redirect: "error", nên URL đăng ký
không được có dấu / cuối nếu framework của bạn chuyển hướng để thêm/bớt nó.
Máy-tới-máy
Cho dịch vụ nền gọi API danh bạ và quyền dùng ứng dụng khi không có người dùng nào đứng sau:
import { AccountM2mClient } from "@comitor/account-sdk";
const directory = new AccountM2mClient({
accountUrl: process.env.COMITOR_ACCOUNT_URL!,
clientId: process.env.COMITOR_M2M_CLIENT_ID!,
clientSecret: process.env.COMITOR_M2M_CLIENT_SECRET!
});
const { members } = await directory.listMembers("acme");
const entitlements = await directory.getEntitlements("acme", "tasks");Token được cache và tự xoay. Nhiều lời gọi đồng thời lúc token vừa hết hạn dùng chung một lần
xin token — không có chuyện mười request cùng đấm vào /oauth2/token rồi chạm rate limit của chính
nó.
Vai trò thô — và ranh giới không được vượt
import { hasAtLeastRole } from "@comitor/account-sdk";
if (hasAtLeastRole(workspace.role, "admin")) { /* … */ }⚠ Đây là tiện ích không có nhà nước, dùng nếu muốn. Nó không phải hệ phân quyền của app. "Ai được xoá task", "ai duyệt đơn nghỉ" là câu hỏi của app và dữ liệu nằm ở database của app.
Dựng RBAC của app trên bốn vai trò thô này là con đường biến Account thành kho quyền của mọi sản phẩm — đúng thứ thiết kế của Comitor cố tình tránh, và một khi đã đi thì rất khó quay lại.
