Gateway Zalo OA: nút "Link thẻ" có token ký
Khi người dùng link thẻ qua trang mở từ Zalo OA, card-core lưu Zalo OA user id của họ vào hồ sơ staff
(corporate_staffs.zalo_oa_user_id). Card-core không hỏi Zalo, nên nó chỉ tin id này khi id đi kèm
chữ ký của Gateway — bên duy nhất biết chắc id đó, vì lấy từ webhook đã kiểm chữ ký của Zalo.
Tài liệu này liệt kê những gì Gateway cần làm. Phần card-core đã xong (POST /card-link/cards,
trường zalo_token).
Luồng
1. Zalo → Gateway webhook (kiểm chữ ký OA), sender.id = zalo_user_id
2. Gateway ký token { sub: zalo_user_id, … } bằng private key Ed25519
3. Gateway → Zalo gửi tin tư vấn có nút "Link thẻ" → https://<trang>/link#t=<token>
4. User bấm trang đọc #t, giữ token qua bước đăng nhập Store (OTP)
5. Trang → card-core POST /card-link/cards { pan | pan_token, pin, first_name?, last_name?, zalo_token }
(pan_token = giá trị QR in trên thẻ, ptok_…; gửi một trong hai)
6. card-core kiểm token trước PIN → PIN đúng → lưu sub vào staff, token gắn với tài khoản này
7. card-core → trợ lý POST /internal/v1/card-events { type: "card.linked", … } → trợ lý báo trên Zalo
("Thẻ •••• 4421 đã liên kết…"), xem xpay-user-mcp.md §6.1
Gateway chỉ làm bước 1–3. Gateway không gọi card-core, không lưu gì về token, không đụng thẻ hay PIN.
Việc Gateway cần làm
1. Cặp khoá Ed25519 (một lần)
const { generateKeyPairSync } = require("node:crypto");
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const kid = "gw-2026-10";
// Gửi cho card-core, đặt vào env ZALO_LINK_JWKS:
console.log(JSON.stringify({ keys: [{ ...publicKey.export({ format: "jwk" }), kid }] }));
// Chỉ Gateway giữ, trong secret manager — không commit, không gửi qua chat:
console.log(JSON.stringify({ ...privateKey.export({ format: "jwk" }), kid }));
- Card-core chỉ nhận public key. Gửi nhầm private key (JWK có trường
d) thì card-core từ chối khởi động. - Đổi khoá: thêm public key mới (kid mới) vào
ZALO_LINK_JWKScủa card-core trước → Gateway chuyển sang ký bằng kid mới → sau 15 phút mới bỏ key cũ khỏi card-core.
2. Menu OA "Link thẻ" (trang quản trị OA, không phải code)
Đổi action từ Mở URL sang Gửi tin với nội dung ẩn #link_the. Link trong menu cố định nên không
mang được token riêng cho từng người; menu chỉ để người dùng xin link.
3. Nhận yêu cầu xin link
- Webhook
user_send_textcó text đúng bằng#link_the→ Gateway xử lý luôn, không cần qua AI. - AI nhận ra ý định "liên kết thẻ" → báo Gateway. Đề xuất thêm một loại lệnh cạnh lệnh gửi tin hiện có:
{ zalo_user_id, kind: "card_link_invite", dedup_key }. AI không tự dựng URL, không giữ khoá. - Nút trong tin khác (ví dụ khi đặt xe mà chưa có thẻ): nút
oa.query.hidevới payload#link_the.
4. Ký token
| Trường | Giá trị | Card-core kiểm |
|---|---|---|
header alg | EdDSA (hoặc Ed25519) | thuật toán khác → từ chối |
header kid | kid của khoá đang ký | phải có trong ZALO_LINK_JWKS |
iss | zalo-gateway | đúng chuỗi này |
aud | card-link | đúng chuỗi này |
sub | Zalo OA user id (sender.id của webhook) | chuỗi, 1–64 ký tự |
jti | UUID mới cho mỗi link | chuỗi, 1–128 ký tự |
iat | thời điểm ký (giây) | không được ở tương lai (lệch đồng hồ tối đa 30 giây) |
exp | iat + 15 phút | exp − iat > 15 phút → từ chối |
import { importJWK, SignJWT } from "jose";
import { randomUUID } from "node:crypto";
const privateKey = await importJWK(PRIVATE_JWK, "EdDSA"); // nạp một lần lúc khởi động
const token = await new SignJWT({})
.setProtectedHeader({ alg: "EdDSA", kid: PRIVATE_JWK.kid })
.setIssuer("zalo-gateway")
.setAudience("card-link")
.setSubject(zaloUserId) // từ webhook đã kiểm chữ ký — KHÔNG lấy từ nội dung tin nhắn
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
sub chỉ được lấy từ sender.id của webhook đã kiểm chữ ký, hoặc từ zalo_user_id mà chính Gateway
đã chuyển cho AI. Ai điều khiển được sub thì gắn được Zalo id của người khác vào tài khoản của mình.
5. Gửi tin có nút
{
"recipient": { "user_id": "<zalo_user_id>" },
"message": {
"text": "Bấm để liên kết thẻ (link hết hạn sau 15 phút)",
"attachment": {
"type": "template",
"payload": {
"buttons": [
{
"title": "Link thẻ",
"type": "oa.open.url",
"payload": { "url": "https://<trang>/link#t=<token>" }
}
]
}
}
}
}
Gửi qua API tin tư vấn của OA v3, bằng OA access token Gateway đang tự làm mới. Cấu trúc payload trên lấy từ tài liệu bên thứ ba — đối chiếu lại với tài liệu OA v3 chính thức.
Token nằm sau # để không vào access log, CDN hay header Referer. Cần thử trên máy thật xem trình
duyệt trong Zalo có giữ phần # không; nếu mất thì đổi sang ?t= và báo card-core + trang.
6. An toàn
- Không log token hay URL đầy đủ. Ai cầm token còn hạn thì gắn được Zalo id kia vào tài khoản của họ.
- Giới hạn khoảng 1 link / 30 giây cho mỗi Zalo user, để bấm menu liên tục không thành gửi hàng loạt tin.
- Bỏ tin trùng
msg_idnhư hiện tại.
Card-core trả gì
Tất cả lỗi token được trả trước khi kiểm PIN, nên không tốn lượt nhập PIN của người dùng.
| Mã | HTTP | Khi nào | Trang nên làm |
|---|---|---|---|
zalo_token_invalid | 422 | chữ ký sai, kid lạ, sai iss/aud, thiếu trường, sống quá 15 phút | "Link không hợp lệ, bấm Link thẻ trong OA để lấy link mới" |
zalo_token_expired | 422 | quá exp | "Link đã hết hạn, bấm Link thẻ trong OA để lấy link mới" |
zalo_token_used | 409 | token đã được tài khoản khác dùng | như trên |
zalo_link_unconfigured | 500 | card-core chưa có ZALO_LINK_JWKS | lỗi cấu hình phía server |
Mỗi token gắn với tài khoản dùng nó đầu tiên. Chính tài khoản đó gửi lại token vẫn được (link thẻ thứ hai, gửi lại khi mất mạng); tài khoản khác thì bị từ chối — nên link bị chuyển tiếp không gắn được Zalo id của người này vào tài khoản người kia.
Thứ tự triển khai
- Gateway tạo khoá, gửi public JWK cho card-core.
- Card-core chạy migration và đặt
ZALO_LINK_JWKS. - Gateway bật gửi nút "Link thẻ"; đổi menu OA.
- Trang Link thẻ bắt đầu gửi
zalo_token.
Làm bước 3–4 trước bước 2 thì mọi lần link có kèm token đều bị 500 zalo_link_unconfigured.