Chuyển tới nội dung chính

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_JWKS củ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.

Đổ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.

  • Webhook user_send_text có 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.hide với payload #link_the.

4. Ký token​

TrườngGiá trịCard-core kiểm
header algEdDSA (hoặc Ed25519)thuật toán khác → từ chối
header kidkid của khoá đang kýphải có trong ZALO_LINK_JWKS
isszalo-gatewayđúng chuỗi này
audcard-linkđúng chuỗi này
subZalo OA user id (sender.id của webhook)chuỗi, 1–64 ký tự
jtiUUID mới cho mỗi linkchuỗi, 1–128 ký tự
iatthời điểm ký (giây)không được ở tương lai (lệch đồng hồ tối đa 30 giây)
expiat + 15 phútexp − 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_id như 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ãHTTPKhi nàoTrang nên làm
zalo_token_invalid422chữ 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_expired422quá exp"Link đã hết hạn, bấm Link thẻ trong OA để lấy link mới"
zalo_token_used409token đã được tài khoản khác dùngnhư trên
zalo_link_unconfigured500card-core chưa có ZALO_LINK_JWKSlỗ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​

  1. Gateway tạo khoá, gửi public JWK cho card-core.
  2. Card-core chạy migration và đặt ZALO_LINK_JWKS.
  3. Gateway bật gửi nút "Link thẻ"; đổi menu OA.
  4. 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.