OAuth: đăng nhập & phân bố data
Vị trí mã nguồn
Phần tiêu đề “Vị trí mã nguồn”Thư mụcbackend/extensions/helpdesk/src/endpoints
- controllers/oauth.controller.ts signState/verifyState, oauthStart, oauthCallback
- services/oauth/provider.ts config generic, buildAuthUrl, exchangeCode, refreshAccessToken
- services/inbound/imap-source.ts resolveImapAuth — XOAUTH2 + refresh khi poll
- services/inbound/crypto.ts AES-256-GCM, ENCRYPTED_CONNECTION_FIELDS
- services/normalizer.ts MASKED_CONNECTION_FIELDS (mask khi read)
- routes.ts /oauth/start + /oauth/callback (guard manage-inboxes)
Thư mụcadmin/packages/helpdesk
- app/components/helpdesk/wizard/step-connect-oauth.vue form + create pending + redirect
- app/components/helpdesk/settings-connections.vue đọc ?oauth=connected|error
- server/api/helpdesk/oauth/start.get.ts BFF proxy
- server/api/helpdesk/oauth/callback.get.ts BFF proxy + sendRedirect
Bức tranh tổng: bring-your-own-app
Phần tiêu đề “Bức tranh tổng: bring-your-own-app”Không có một “app Google chung” trong env. Client id/secret + authorize/token URL + scopes nằm trên chính connection (hd_connections), nên một admin thêm được bao nhiêu mailbox tuỳ ý, mỗi cái backing bởi một OAuth app khác nhau. Env chỉ giữ một thứ dùng chung: HELPDESK_OAUTH_REDIRECT_URI — callback cố định phải whitelist sẵn trong app phía provider.
Khác gì login SSO của ODP core?
Phần tiêu đề “Khác gì login SSO của ODP core?”ODP core SSO (@odp/api auth) |
Helpdesk OAuth (module này) | |
|---|---|---|
| Mục đích | AuthN — đăng nhập một người vào ODP | AuthZ — xin quyền app truy cập một mailbox |
| Kết quả | Provision odp_users → cấp ODP session (JWT sid, access 15m / refresh 7d rotate) → Accountability |
Access token Google/MS lưu vào hd_connections; không tạo session/Accountability |
| Principal | User người thật, gắn role/permission | Không ai “đăng nhập” — token là service credential cho 1 hộp thư |
id_token/userinfo |
Dùng để định danh user rồi map vào odp_users |
Chỉ đọc email để biết địa chỉ mailbox |
| Token dùng để | Gọi ODP API với tư cách user đó | Kết nối IMAP XOAUTH2 nền (cron) + SMTP |
| Config sống ở | odp_extension_settings (global, per-IdP) |
hd_connections (per-connection, thêm bao nhiêu tuỳ ý) |
| Anti-replay | odp_ephemeral_codes TTL 5m, delete-on-read |
signed state HMAC TTL 10m, stateless |
| Mã hoá secret | secret IdP mã hoá bằng SECRET |
oauth_client_secret/token mã hoá AES-256-GCM (key từ SECRET) |
Điểm trùng về kỹ thuật (vì đều là chuẩn OAuth2): authorization-code + state chống CSRF, one-time/short-TTL guard, mã hoá secret bằng SECRET của ODP. Vì sao tách chứ không hợp nhất: SSO là luồng inbound identity (IdP → phiên đăng nhập ODP), helpdesk là outbound resource token (ODP → hộp thư Google) — khác scope (https://mail.google.com/ vs openid/profile), khác nơi lưu, khác lifecycle refresh-nền. Helpdesk chỉ “mượn tinh thần” (mã hoá bằng SECRET + guard short-lived), và chọn per-connection thay vì per-IdP để hợp với bài toán nhiều mailbox.
Luồng đăng nhập (authorization-code)
Phần tiêu đề “Luồng đăng nhập (authorization-code)” Admin (wizard) Admin BFF (:8888) Backend HD (:8055) Provider (Google/MS) ───────────── ───────────────── ────────────────── ──────────────────── 1. điền form Connect&authorize │ createConnection ─────────────► POST /helpdesk/connections │ → hd_connections row (status=pending, │ auth_type=oauth); hook mã hoá secret │ GET /api/helpdesk/oauth/start?connectionId=N │ ───────────────► proxy ────────► GET /helpdesk/oauth/start │ load conn → configFromConnection │ (giải mã secret) → signState{c:N,u,n,t} │ ◄─────────────── {url} ◄──────── buildAuthUrl(cfg, state) 2. window.location = url ──────────────────────────────────────────────► consent screen 3. user đồng ý quyền 4. redirect_uri ?code&state ◄─────────────────────────────────────────────────┘ │ GET /api/helpdesk/oauth/callback?code&state │ ───────────────► proxy ────────► GET /helpdesk/oauth/callback │ verifyState (HMAC + TTL) → connId │ exchangeCode ─────► POST token endpoint │ ◄── access+refresh+id_token ───┘ │ updateOne(conn): token/refresh/email, │ status=connected (hook mã hoá token) │ ◄── 302 /helpdesk/settings?tab=connections&oauth=connected&email=… 5. toast "Đã kết nối"; tạo inbox trên connection này (nhánh existing của wizard)-
Tạo connection
pending.step-connect-oauth.vuegọicreateConnection(...)vớiauth_type=oauth,provider,oauth_client_id/secret,oauth_authorize_url/token_url/scopes,oauth_email,imap_host/port/username. Filter hookhd_connections.items.createmã hoáoauth_client_secrettrước khi ghi. -
Lấy consent URL.
$fetch('/api/helpdesk/oauth/start', { query: { connectionId } })→ backendoauthStartload connection,configFromConnection(giải mã secret),signState, trả{ data: { url } }. -
Redirect ra provider.
window.location.href = url. URL chứaclient_id,redirect_uri,scope,access_type=offline,prompt=consent(ép provider trả refresh_token), vàstate. -
Callback đổi code. Provider redirect về
redirect_uri(BFFcallback.get.ts) → backendoauthCallback:verifyState→connectionId→exchangeCode(POST token endpoint) →updateOneconnection với token + email,status=connected,needs_reauth=false. Hook mã hoáoauth_token+oauth_refresh_tokenkhi ghi. -
Về UI. BFF
sendRedirecttới/helpdesk/settings?tab=connections&oauth=connected&email=…;settings-connections.vuebắt query → toast rồi xoá query. Admin tạo inbox trên connection đãconnected.
State ký (CSRF, stateless)
Phần tiêu đề “State ký (CSRF, stateless)”Vì luồng là full-page redirect (mất state trong RAM), state phải tự mang thông tin và tự chứng thực. state = base64url(payload).HMAC-SHA256(payload, SECRET):
const state = signState(secret, { c: connectionId, // connection nào sẽ được fill token n: randomBytes(8).toString('hex'), // nonce t: Date.now(), // để check TTL 10 phút u: req.accountability?.user ?? null,});verifyState so HMAC bằng timingSafeEqual và từ chối nếu quá STATE_MAX_AGE_MS (10 phút). Không lưu gì ở server → callback chạy được trên bất kỳ instance nào.
Phân bố data trên hd_connections
Phần tiêu đề “Phân bố data trên hd_connections”Tất cả field OAuth nằm trên một row hd_connections. Cột nào nhạy cảm thì mã hoá at rest (AES-256-GCM, key suy từ SECRET) qua filter hook lúc ghi, và mask *** lúc đọc qua normalizer — nên client secret / token không bao giờ lộ lại qua API sau khi nhập.
| Field | Nội dung | Set khi | Mã hoá at rest | Mask khi read |
|---|---|---|---|---|
provider |
gmail / outlook / custom_oauth |
form | – | – |
auth_type |
luôn oauth |
form | – | – |
oauth_provider |
google / microsoft / custom (preset key) |
form | – | – |
oauth_email |
địa chỉ mailbox | form → cập nhật từ id_token ở callback |
– | – |
oauth_client_id |
client ID của app | form | – | – |
oauth_client_secret |
client secret của app | form | ✅ | ✅ |
oauth_authorize_url |
authorize endpoint | form (preset) | – | – |
oauth_token_url |
token endpoint | form (preset) | – | – |
oauth_scopes |
mảng scope xin ở consent | form (preset) | – | – |
oauth_token |
access token | callback + mỗi lần refresh | ✅ | ✅ |
oauth_refresh_token |
refresh token | callback (chỉ khi consent) | ✅ | ✅ |
oauth_expires_at |
hạn access token (ISO) | callback + refresh | – | – |
status |
pending → connected → error |
lifecycle | – | – |
needs_reauth |
true khi refresh fail → cron bỏ qua |
khi refresh lỗi | – | – |
imap_host / imap_port / imap_username / imap_use_ssl |
tham số IMAP (preset) | form | – | – |
error_message |
lý do lỗi gần nhất | on failure | – | – |
export const ENCRYPTED_CONNECTION_FIELDS = [ 'imap_password', 'smtp_password', 'webhook_secret', 'oauth_refresh_token', 'oauth_token', 'oauth_client_secret', 'twilio_auth_token',];Dùng token khi poll: refresh + XOAUTH2
Phần tiêu đề “Dùng token khi poll: refresh + XOAUTH2”Cron IMAP (xem Inbound pipeline) không dùng mật khẩu cho connection OAuth — nó gọi resolveImapAuth:
- Giải mã
oauth_token. Nếu còn hạn → trả{ user, accessToken }cho ImapFlow (authXOAUTH2). - Nếu hết hạn / trống → giải mã
oauth_refresh_token, gọirefreshAccessToken(configFromConnection(conn, …), refreshToken)(POST token endpoint bằng creds của chính connection), rồi ghi lạioauth_token(mã hoá) +oauth_expires_atmới. - Nếu refresh thất bại (vd
invalid_grantdo bị thu hồi quyền) → setneeds_reauth=true+error_message; cron ngừng poll connection đó cho tới khi admin kết nối lại.
if (conn.auth_type !== 'oauth') { return { user: conn.imap_username, pass: decrypt(conn.imap_password, secret) };}let accessToken = decrypt(conn.oauth_token, secret);if (!accessToken || expired(conn.oauth_expires_at)) { const refreshToken = decrypt(conn.oauth_refresh_token, secret); const cfg = configFromConnection(conn, redirectUriFromEnv(ctx.env), secret); const refreshed = await refreshAccessToken(cfg, refreshToken); // per-connection creds accessToken = refreshed.accessToken; await ctx.database('hd_connections').where('id', conn.id).update({ oauth_token: encrypt(accessToken, secret), oauth_expires_at: refreshed.expiresAt, });}return { user, accessToken }; // → ImapFlow auth: { user, accessToken } (XOAUTH2)Endpoints
Phần tiêu đề “Endpoints”| Tầng | Route | Guard | Vai trò |
|---|---|---|---|
| BFF | GET /api/helpdesk/oauth/start |
manage-inboxes |
trả consent URL cho connection ?connectionId |
| BFF | GET /api/helpdesk/oauth/callback |
manage-inboxes |
redirect_uri; proxy backend rồi sendRedirect về settings |
| Backend | GET /helpdesk/oauth/start |
manage-inboxes |
load conn → buildAuthUrl |
| Backend | GET /helpdesk/oauth/callback |
manage-inboxes |
verifyState → exchangeCode → fill connection |
Redirect URI là một giá trị cố định cho cả deployment (HELPDESK_OAUTH_REDIRECT_URI, mặc định dev http://localhost:8888/api/helpdesk/oauth/callback) — phải whitelist trong app phía provider. Xem hướng dẫn tạo app + kết nối ở Kết nối email.
Related links
Phần tiêu đề “Related links”- Kết nối email (IMAP/SMTP + OAuth) — hướng dẫn thao tác
- Inbound: IMAP → ticket — cron poll dùng token này
- Data model — toàn bộ collection
hd_* - Endpoints, routes & guards — mô hình guard
manage-inboxes