@comitor/account-sdk
v0.8.0
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, code_verifier và nonce 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.
Từ 0.6.0 nó còn làm ba việc mà bản trước chỉ làm "trên giấy":
- kiểm
isstrên URL callback (RFC 9207) — chặn phản hồi của một máy chủ uỷ quyền khác; - xác minh
id_token: chữ ký ES256 qua JWKS,iss,aud/azp, hạn dùng, vànoncekhớp đúng lần đăng nhập này (bản ≤ 0.5.0 sinhnoncerồi không bao giờ so lại); - lấy
tokens.sidtừ payload ĐÃ xác minh — giá trị bạn dùng để đóng đúng phiên khi nhận back-channel logout.
Tự gọi exchangeCode (không qua completeSignIn) thì truyền nonce của createAuthorizationRequest:
exchangeCode(config, { code, codeVerifier, nonce }).
Và đây là chỗ DUY NHẤT gieo tuỳ chọn hiển thị (từ 0.2.0)
AccountUser mang mặc định hiển thị theo tài khoản: locale, timezone, và từ 0.2.0 thêm
theme · contrast · density · fontSize. Route callback là chỗ đúng để chép chúng xuống thiết
bị, vì ba điều cùng đúng ở đây và không đúng ở đâu khác: nó là Route Handler (ghi cookie chỉ hợp
lệ ở Route Handler, Server Action và proxy.ts — gọi từ page.tsx là NÉM), nó chạy đúng một lần
mỗi lần đăng nhập, và nó đã có ngữ cảnh trong tay nên không tốn thêm vòng mạng nào.
const { user } = await getAccountContext(account, tokens.accessToken);
// user.locale, user.timezone, user.theme, user.contrast, user.density, user.fontSize⚠ Đây là MẶC ĐỊNH, không phải giá trị đang có hiệu lực. Giá trị có hiệu lực nằm ở
localStorage của thiết bị, vì bốn trục hiển thị phải áp trước lượt sơn đầu tiên — thứ chỉ
script chống nháy đọc localStorage làm được. Đọc chúng trong lúc render là mua lại đúng cái nháy
mà @comitor/ui dựng script kia để tránh.
⚠ Ba trạng thái, không phải hai: undefined = máy chủ Account này không gửi trường đó (SDK
mới hơn bản Account đang chạy — không nói gì về người dùng); null = người dùng chưa từng chọn;
chuỗi = lựa chọn thật. Hai ca đầu dẫn tới cùng một hành động — đừng gieo gì cả — nên chỗ dùng
được phép gộp bằng ?? null.
⚠ Gieo một giá trị mặc định thay cho null là ghi đè lựa chọn tại chỗ của người dùng bằng một
thứ chưa ai chọn bao giờ. locale đã mắc đúng lỗi này: cột ấy có DEFAULT 'vi' nên "chưa từng
chọn" trông y hệt "đã chọn tiếng Việt", và một trình duyệt xin en vẫn bị gieo vi đè lên. Bốn cột
mới cố ý không có default để giữ được sự phân biệt đó — đừng làm hỏng nó ở phía app.
⚠ App KHÔNG ghi ngược lên Account. assertSessionActor từ chối mọi actor không phải phiên người
dùng bằng 403, kể cả access token hợp lệ — xem "Vai trò thô — và ranh giới không được vượt". Nên
mọi "cái này quản ở Account" vĩnh viễn là ĐỌC + LIÊN KẾT: app đọc mặc định, và dẫn người dùng
sang /preferences của Account khi họ muốn đổi.
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.
Bốn lớp guard, bốn thông điệp khác nhau
requireWorkspaceAccess tách rõ bốn trường hợp vì chúng dẫn tới bốn hành động khác nhau của
người dùng — và ba trong bốn hành động ấy là đi tìm một NGƯỜI KHÁC. Gộp lại thành "bạn không có
quyền" là bắt họ tự đoán phải đi hỏi ai:
| 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".
⚠ Tiêu đề mục này từng ghi "Ba lớp guard, ba thông điệp" trong khi bảng ngay dưới có BỐN hàng —
và nó sai từ commit sinh ra nó (ab1ea95 đưa tiêu đề, bảng bốn hàng và bốn nhánh throw vào
cùng một lần), chứ không phải trôi dần. Nó đã được chép sang ít nhất một module: comitor-starter
mang câu "ba lớp guard với ba thông điệp khác nhau" cho tới 2026-09-05. Đó là cái giá riêng của
một con số nằm trong TIÊU ĐỀ tài liệu SDK: người viết module đọc tiêu đề rồi chép, không đếm bảng.
Đừng đếm bằng mắt, và số có thẩm quyền nằm ở mã chứ không ở đây:
grep -cE '^\s*403,$' packages/account-sdk/src/workspace.ts # → phải bằng số hàng của bảng trên⚠ Lọc theo 403 chứ không theo AccountError(: file này có sáu chỗ ném, và hai chỗ không phải
guard — một lần ném LẠI nguyên status/code của lỗi từ trên xuống, và MISSING_APP_KEY là 500
(lỗi CẤU HÌNH của app, không phải câu trả lời cho người dùng — đừng dựng màn hình cho nó). Đếm cả
sáu ra 6, và một phép đếm sai kiểu đó chính là thứ đã sinh ra cái tiêu đề vừa sửa.
Bệ phóng ứng dụng — hai nguồn, ba trạng thái (từ 0.4.0)
Dải sản phẩm trong khung app cần hai phép đọc, và chúng trả lời hai câu khác nhau:
| Gọi | Trả lời | Hình dạng |
|---|---|---|
| getAccountContext() | workspace này đã bật app nào, và NGƯỜI ĐANG HỎI có chỗ ngồi ở đâu | WorkspaceMembership.apps: WorkspaceApp[] |
| listAppCatalogue() | Comitor CÓ những app nào | AppCatalogueEntry[] |
Ghép theo key để ra ba trạng thái. Đừng gộp — chúng dẫn tới ba hành động khác nhau:
| Trạng thái | Điều kiện | Người dùng làm gì |
|---|---|---|
| mở được | có trong apps và hasSeat | vào app |
| chưa có chỗ ngồi | có trong apps, hasSeat: false | xin quản trị viên cấp chỗ |
| chưa bật | chỉ có trong danh mục | quản trị viên bật app cho workspace |
const [context, catalogue] = await Promise.all([
getAccountContext(account, accessToken),
listAppCatalogue(account, accessToken)
]);
const workspace = findWorkspace(context, slug);
const enabled = new Map(workspace?.apps.map((app) => [app.key, app]) ?? []);
const tiles = catalogue.map((entry) => {
const app = enabled.get(entry.key);
return {
...entry,
href: appUrlFor(entry, slug), // `{baseUrl}?w={slug}`, slug đã mã hoá
state: !app ? "locked" : app.hasSeat ? "unlocked" : "no-seat"
};
});⚠ Đừng ghi cứng danh sách app vào module. Nó trôi ngay khi hệ có sản phẩm mới, và mỗi module
trôi một kiểu. Tệ hơn là đoán URL theo khuôn https://{key}.comitor.ai: bản dev và bản staging ném
người dùng sang production của app anh em, mang theo phiên của họ, và không có gì báo. baseUrl
là câu trả lời của Account theo đúng môi trường đang chạy — dùng appUrlFor().
⚠ Liên kết chéo sản phẩm mang ?w={slug}, KHÔNG phải /w/{slug} (đổi ở 0.5.0). App nhận nó như
một chỉ thị đổi workspace một lần: kiểm slug có trong danh sách của CHÍNH người đang đăng nhập
(findWorkspace) → ghi vào phiên của app → chuyển hướng BỎ query, để nó không nằm lại trong lịch sử
và trong nút chia sẻ. Bỏ phép kiểm đầu là để bất kỳ ai đoán được một slug cũng mở được không gian
làm việc của khách hàng khác; giữ query lại thì mỗi lần tải lại trang ghi đè lựa chọn mà người dùng
vừa đổi bằng bộ chọn của app.
Đổi khuôn vì khuôn cũ hỏng theo hướng NGUY: app nào chưa dựng route /w/[slug] thì mọi ô trong
bệ phóng là một đường link chết (404) — đã xảy ra thật. Query hỏng theo hướng AN TOÀN: app chưa dựng
bộ nhận thì bỏ qua tham số thừa và mở workspace mặc định.
Cái giá đã chấp nhận: workspace nằm trong PHIÊN của app, nên hai tab = hai workspace là không làm
được. App nào thật sự cần thì tự dựng /w/[slug]/… BÊN TRONG nó và coi ?w= là cửa vào — hợp
đồng này quyết định thứ được GỬI ĐI, không quyết định bản đồ route của app.
⚠ iconColor là tập ĐÓNG, và nó đi vào style. Nối thẳng chuỗi từ API vào CSS là một đường
tiêm. Giá trị không nhận ra thì bỏ qua và dùng màu mặc định của theme; đừng dựng var(--color-${...})
từ nó mà không lọc. Cùng luật với WorkspaceApp.iconColor.
⚠ icon là TÊN lucide PascalCase, không phải component và không phải SVG. Module tra tên đó
trong bảng icon của chính nó; tên lạ thì rơi về icon mặc định.
name và description đã được Account chọn ngôn ngữ theo user.locale của người hỏi — không
theo Accept-Language, vì lời gọi này xuất phát từ máy chủ của app khách nên header đó là của máy
chủ ấy. Cùng luật với WorkspaceApp.name, nên hai nửa của bệ phóng không bao giờ hiện hai thứ tiếng.
Danh mục được cache 300 giây (đổi bằng { cacheSeconds }), dài hơn contextCacheSeconds của
ngữ cảnh: danh mục gần như tĩnh, còn tư cách thành viên và chỗ ngồi thì bị thu hồi và phải bắt kịp
nhanh. Cache khoá theo access token — vì danh mục đã chọn ngôn ngữ theo người hỏi.
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ó.
Xoay khoá ký không còn làm mất sự kiện (từ 0.6.0). Sau khi quản trị viên xoay khoá, trong 24 giờ
Account ký mỗi lần gửi bằng CẢ khoá cũ lẫn khoá mới (t=…,v1=<cũ>,v1=<mới>), và
verifyWebhookSignature / readWebhook nhận nếu bất kỳ chữ ký nào khớp — deploy
COMITOR_WEBHOOK_SECRET mới lúc nào trong 24 giờ đó cũng được. Bản < 0.6.0 chỉ đọc v1 cuối (khoá
mới), nên với nó mọi lần gửi hỏng tới khi deploy lại.
Back-channel logout — tự khử trùng lặp bằng jti
verifyLogoutToken trả claims.jti nhưng không nhớ jti nào đã thấy (gói không có kho lưu
trữ). Một logout_token hợp lệ bắt được còn dùng lại được trong cửa sổ tươi 120 giây — vô hại nếu
việc của bạn là "đóng phiên sid" (làm lại cũng vậy), nhưng nếu route của bạn làm thêm việc khác thì
lưu jti (Redis/database, TTL vài phút) và từ chối lần thứ hai.
Resource server — xác minh access token
API của module nhận Authorization: Bearer (từ app di động, từ module khác) thì đừng tự viết
jwtVerify:
import { verifyAccessToken } from "@comitor/account-sdk";
const claims = await verifyAccessToken(account, token, { audience: "https://api.comitor.ai/tasks" });
// claims.sub, claims.scope — tự kiểm scope theo việc của API này.Hàm ghim alg ES256, kiểm iss (kèm hậu tố /api/auth), aud phải là resource của bạn — mọi
module tin cùng issuer và cùng JWKS, nên thiếu phép kiểm này là token cấp cho module khác cũng qua —
và hạn dùng. audience không có mặc định, cố ý.
Mọi lời gọi mạng của SDK có hạn chót 10 giây (từ 0.6.0) — một Account treo không kéo module treo theo.
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")) { /* … */ }Bốn vai trò thô trả lời đúng một câu: ai quản trị workspace ở Account (mời người, cấp chỗ ngồi, bật app). Chúng 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.
Phân quyền của app và vai trò tuỳ chỉnh — @comitor/account-sdk/rbac (từ 0.7.0)
Hàm THUẦN: không I/O, không trạng thái, không gọi Account, không phụ thuộc gì. Dùng được ở cả máy chủ
lẫn trình duyệt, kể cả trong lib/core/ của app. Thiết kế đầy đủ ở docs/vai-tro-dong.md của repo
comitor-account.
App giữ ba thứ trong database CỦA NÓ: bảng quyền theo chức năng, ma trận vai trò thô × quyền theo từng workspace, và vai trò tuỳ chỉnh (một tên + một tập quyền, gán cho từng người). Gói này giữ LUẬT ghép chúng:
import { type PermissionRule, resolveGrants, hasGrant } from "@comitor/account-sdk/rbac";
const RULES: readonly PermissionRule<"task.view" | "task.delete">[] = [
{ id: "task.view", write: false, defaultRoles: ["owner", "admin", "member", "guest"] },
{ id: "task.delete", write: true, defaultRoles: ["owner", "admin"] }
];
const grants = resolveGrants({
rules: RULES,
role: workspace.role, // vai trò thô từ Account
baseGrants: matrixRowOf(workspace.role), // cột của vai trò thô trong ma trận của app
customRoles: rolesAssignedTo(user.id) // [{ permissions: [...] }] từ bảng của app
});
if (!hasGrant(grants, "task.delete")) throw forbidden();Ba điều resolveGrants bảo đảm, đọc trước khi tự viết lại:
- Vai trò tuỳ chỉnh chỉ CỘNG quyền, không trừ. Muốn hẹp hơn
memberthì hạ cộtmembertrong ma trận, rồi gán vai trò tuỳ chỉnh cho người cần nhiều hơn. - Hai hằng đúng áp ở TẦNG ĐỌC, trên mọi nguồn: chủ sở hữu luôn có mọi quyền; khách không bao giờ
có quyền GHI, kể cả qua vai trò tuỳ chỉnh. Một dòng database hỏng không bao giờ thành
true. - Mã quyền lạ bị bỏ (quyền đã gỡ khỏi
RULESmà dòng cũ còn nằm lại), nên không có quyền nào thiếu đường thu hồi.
Kiểm khi tạo hoặc đổi tên vai trò tuỳ chỉnh. Hàm trả MÃ, app tự tra câu chữ:
checkCustomRoleName(name, { existing: otherNames, reserved: [t("roles.admin"), …] });
// → null | "empty" | "too_long" | "reserved" | "duplicate"
checkCustomRoleLimit(count); // → null | "limit_reached" (mặc định 50 mỗi workspace)
normalizeCustomRoleName(name); // NFC + gộp khoảng trắng — lưu tên ĐÃ chuẩn hoá⚠ Vai trò tuỳ chỉnh không đi vào Account và không đi vào token. App khác không thấy nó: "Kế toán" ở HR và "Kế toán" ở CRM là hai vai trò riêng.
