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 oauthProviders (C1), buildStartUrl, oauthStart (C2), oauthCallback (C5)
- controllers/connection-tests.controller.ts reauth (C4), testImap/testSmtp — XOAUTH2-aware
- services/oauth/registry.ts getOAuthRegistry/registryFor — env → app chung per-provider
- services/oauth/provider.ts configFromConnection, buildAuthUrl, exchangeCode, refreshAccessToken
- services/oauth/token-store.ts getAccessToken — refresh-or-reuse dùng chung bởi IMAP + SMTP
- services/oauth/state.ts signState/verifyState/randomNonce (C3)
- services/inbound/imap-source.ts resolveImapAuth → getAccessToken, XOAUTH2 khi poll
- services/outbound/smtp-sender.ts resolveOutbound + sendReply → XOAUTH2 khi auth_type=oauth
- services/inbound/crypto.ts AES-256-GCM, ENCRYPTED_CONNECTION_FIELDS
- services/normalizer.ts MASKED_CONNECTION_FIELDS (mask khi read)
- routes.ts /oauth/providers, /oauth/start, /oauth/callback, /connections/:id/reauth
Thư mụcapps/packages/helpdesk/src
- controllers/oauth.controller.ts BFF proxy: providers/start/callback/reauth + chọn đích redirect theo resume (C5)
- composables/use-helpdesk-api.ts listOAuthProviders, startOAuth, reauthConnection
- utils/oauth-presets.ts preset cho app riêng (scopes/host/tenant/SMTP fields)
- utils/wizard-oauth-resume.ts sessionStorage blob (C6): saveResume/loadResume/clearResume, TTL 15’
- composables/use-inbox-wizard.ts resume-on-mount (nối lại wizard sau redirect), oauthError
- components/wizard/StepConnectOauth.vue nút một chạm + toggle “Dùng app OAuth riêng”
- components/settings/Connections.vue action Kết nối lại (needs_reauth)
- components/settings/ConnectionEditSlideover.vue Reconnect button trong nhánh OAuth
Hai chế độ: app chung (một chạm) vs app riêng (BYO)
Phần tiêu đề “Hai chế độ: app chung (một chạm) vs app riêng (BYO)”App chung (server-configured): operator set 4-6 biến môi trường một lần cho cả server — không phải mỗi mailbox một app. services/oauth/registry.ts biến các biến này thành một descriptor cố định mỗi provider (label, client id/secret, authorize/token URL, scopes, IMAP/SMTP host:port). Khi admin bấm nút một chạm, oauthStart tạo thẳng connection pending từ registry — không ghi oauth_client_secret xuống row, secret chỉ tồn tại trong process.env và được resolve lại mỗi lần cần (start URL, refresh token) qua registryFor(env, oauth_provider).
App riêng (bring-your-own-app): không có “app chung” nào — client id/secret + authorize/token URL + scopes nằm trên chính connection (hd_connections, mã hoá), 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. Dùng cho provider chưa được operator cấu hình (registry configured:false) hoặc cho custom_oauth.
Cả hai chế độ dùng chung một giá trị trong env: HELPDESK_OAUTH_REDIRECT_URI — callback cố định phải whitelist sẵn trong app phía provider (áp dụng cho cả app chung lẫn app riêng, vì redirect URI là thuộc tính của app, không phải của từng mailbox).
| Biến môi trường | Ý nghĩa | Mặc định |
|---|---|---|
HELPDESK_MICROSOFT_CLIENT_ID |
Application (client) ID của app Entra | — (rỗng = provider configured:false) |
HELPDESK_MICROSOFT_CLIENT_SECRET |
Client secret của app Entra | — |
HELPDESK_MICROSOFT_TENANT |
Segment tenant trong authority URL | common (đổi organizations nếu app chỉ đăng ký cho tài khoản work/school) |
HELPDESK_GOOGLE_CLIENT_ID |
OAuth client ID Google Cloud | — |
HELPDESK_GOOGLE_CLIENT_SECRET |
OAuth client secret Google Cloud | — |
HELPDESK_OAUTH_REDIRECT_URI |
Redirect URI cố định, phải khớp chính xác URI whitelist ở provider | http://localhost:3100/api/helpdesk/oauth/callback (dev); prod ví dụ https://odp.codihaus.com/api/helpdesk/oauth/callback |
Registry trả configured:false cho một provider nếu thiếu client id hoặc secret — GET /oauth/providers (C1) chỉ trả { provider, label, configured }, không bao giờ trả secret; FE hiện nút một chạm khi configured:true, ngược lại rơi về form app riêng.
Thiết lập app Microsoft Entra (operator, một lần)
Phần tiêu đề “Thiết lập app Microsoft Entra (operator, một lần)”-
Entra admin center → App registrations → New registration. Đặt tên (vd
Helpdesk Mail). -
Supported account types: chọn “Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts” nếu muốn khớp
HELPDESK_MICROSOFT_TENANT=common(mặc định); chọn “Accounts in this organizational directory only” (single-tenant) nếu setHELPDESK_MICROSOFT_TENANT=<tenant-id-hoặc-domain>. Chọn sai loại tương ứng tenant env → lỗiAADSTS50194khi consent. -
Redirect URI: platform Web, giá trị = đúng
HELPDESK_OAUTH_REDIRECT_URIsẽ set ở bước 6 (vdhttps://odp.codihaus.com/api/helpdesk/oauth/callback, devhttp://localhost:3100/api/helpdesk/oauth/callback). Lệch một ký tự (http vs https, thiếu/) →AADSTS50011. -
API permissions → Add a permission — hai nhóm:
- Microsoft Graph (delegated):
openid,email,profile,offline_access— thường đã có sẵn/không cần admin consent. - APIs my organization uses → Office 365 Exchange Online (delegated):
IMAP.AccessAsUser.All(scope stringhttps://outlook.office.com/IMAP.AccessAsUser.All) vàSMTP.Send(https://outlook.office.com/SMTP.Send). - Nếu chính sách tenant yêu cầu, bấm Grant admin consent for <tenant> — thiếu bước này, user thường vẫn consent được cho chính họ nhưng tổ chức có thể chặn.
- Microsoft Graph (delegated):
-
Certificates & secrets → New client secret → hạn tối đa 24 tháng → copy giá trị ngay (chỉ hiện một lần). Ghi lại ngày hết hạn — secret hết hạn làm toàn bộ mailbox OAuth (mọi connection dùng app chung này) ngừng refresh được token cùng lúc; rotate là: tạo secret mới trên Azure → cập nhật
HELPDESK_MICROSOFT_CLIENT_SECRET→ restart backend. -
Set trong
backend/.env:HELPDESK_MICROSOFT_CLIENT_ID(Application (client) ID, ở trang Overview),HELPDESK_MICROSOFT_CLIENT_SECRET,HELPDESK_MICROSOFT_TENANT(mặc địnhcommon),HELPDESK_OAUTH_REDIRECT_URI. Restart backend —GET /oauth/providerssẽ báomicrosoft.configured: true. -
Trên mỗi mailbox sẽ kết nối: SMTP AUTH phải bật (Exchange Online tắt mặc định cho tenant mới):
Set-CASMailbox -Identity <mbx> -SmtpClientAuthenticationDisabled $false. Thiếu bước này, IMAP poll vẫn chạy nhưng gửi reply lỗi535 5.7.3 Authentication unsuccessful.
Google (Gmail) tương tự: tạo OAuth client ở Google Cloud Console (xem thao tác chi tiết ở Kết nối email — cùng bước, chỉ khác chỗ set HELPDESK_GOOGLE_CLIENT_ID / HELPDESK_GOOGLE_CLIENT_SECRET vào env thay vì nhập trong wizard), scope openid email https://mail.google.com/.
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) — cả hai chế độ
Phần tiêu đề “Luồng đăng nhập (authorization-code) — cả hai chế độ” Admin (wizard) Admin BFF (:3200) Backend HD (:3100) Provider (Microsoft/Google) ───────────── ───────────────── ────────────────── ───────────────────────── 1. bấm "Connect with Microsoft" (chỉ nhập tên) │ GET /api/helpdesk/oauth/start?provider=microsoft&name=…&resume=1 │ ───────────────► proxy ────────► GET /helpdesk/oauth/start │ registryFor('microsoft') → tạo │ hd_connections (status=pending, │ auth_type=oauth) — KHÔNG có secret │ → signState{c,n,t,u,p:'microsoft',r:1} │ ◄─────────────── {url} ◄──────── buildAuthUrl(cfg, state, 'microsoft') 2. window.location = url ──────────────────────────────────────────────► consent screen 3. user đồng ý quyền (IMAP+SMTP) 4. redirect_uri ?code&state ◄─────────────────────────────────────────────────┘ │ GET /api/helpdesk/oauth/callback?code&state │ ───────────────► proxy ────────► GET /helpdesk/oauth/callback │ verifyState (HMAC + TTL) → {c,p,r} │ exchangeCode ─────► POST token endpoint │ ◄── access+refresh+id_token ───┘ │ updateOne(conn): token/refresh/email, │ smtp_host/port từ registry, status=connected │ ◄── { data:{ ok:true, connectionId, email, resume:true } } │ BFF chọn đích redirect theo `resume` (C5): │ resume → 302 /helpdesk/inboxes/new?oauth=connected&connectionId=N&email=… │ !resume → 302 /helpdesk/settings?tab=connections&oauth=connected&email=… 5. resume=true: wizard tự nhảy tới bước Folder/Finish, connection đã chọn sẵn resume=false (BYO cũ / reauth): toast ở Settings → Connections-
Có sẵn connection
pending. Một chạm:oauthStarttự tạo row từ registry, KHÔNG ghioauth_client_secret. App riêng:StepConnectOauth.vuegọicreateConnection(...)trước vớiauth_type=oauth,oauth_client_id/secret,oauth_authorize_url/token_url/scopesdo admin nhập/preset — filter hookhd_connections.items.createmã hoáoauth_client_secrettrước khi ghi. -
Lấy consent URL.
GET /oauth/start(một chạm:?provider=microsoft|google&name=&resume=1; app riêng/reauth:?connectionId=) →buildStartUrlresolve config (configFromConnection, secret từ row hoặc fallback registry),signState, trả{ data: { url } }. -
Redirect ra provider.
window.location.href = url. Microsoft:response_mode=query&prompt=select_account(không épaccess_type/include_granted_scopes— không phải tham số của Microsoft). Google/BYO:access_type=offline&prompt=consent&include_granted_scopes=true(ép trảrefresh_token). -
Callback đổi code. Provider redirect về
redirect_uri→ BFF proxy → backendoauthCallback:verifyState(state hợp lệ trước khi xéterror, nên một consent bị từ chối vẫn biết đượcresume) →exchangeCode→updateOneconnection: token + email (ưu tiênid_token, fallback địa chỉ mailbox đã nhập),status=connected,needs_reauth=false; nếu state cóp(một chạm) thì đồng thời fillsmtp_host/port/use_tls/usernametừ registry — không bao giờ ghi đè mộtsmtp_hostapp-riêng đã có sẵn. -
Về UI theo
resume. BFF đọcresumetrong JSON trả về (không tự giải mã state phía FE) để chọn đích 302 (C5).resume=true→ về đúng wizard đang mở (/helpdesk/inboxes/new),use-inbox-wizard.tsphục hồi blob C6 từsessionStorage, setsourceMode='existing', nhảy tới bước Folder/Finish,clearResume().resume=false(mặc định của app riêng cũ, và mọi Reconnect C4) → vềSettings → Connections,Connections.vuebắt query rồi xoá.
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: randomNonce(), // nonce t: Date.now(), // để check TTL 10 phút u: req.accountability?.user ?? null, ...(reg ? { p: conn.oauth_provider } : {}), // registry key — chỉ khi một chạm; dùng để resolve secret ở callback ...(opts.resume ? { r: 1 } : {}), // callback trả về trong wizard thay vì Settings});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. p/r chỉ ảnh hưởng UX (chọn secret nguồn nào, chọn đích redirect) — không phải cơ chế chống CSRF (đó vẫn là HMAC + TTL).
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. Cột “Set khi” ghi rõ chỗ nào khác nhau giữa một chạm và app riêng.
| Field | Nội dung | Set khi | Mã hoá at rest | Mask khi read |
|---|---|---|---|---|
provider |
gmail / outlook / custom_oauth |
form (BYO) hoặc registry connectionProvider (một chạm) |
– | – |
auth_type |
luôn oauth |
form / oauthStart |
– | – |
oauth_provider |
google / microsoft (registry key) hoặc custom (BYO) |
form / oauthStart |
– | – |
oauth_email |
địa chỉ mailbox | form (BYO) → cập nhật từ id_token ở callback (cả hai chế độ) |
– | – |
oauth_client_id |
client ID của app | form (BYO) hoặc registry (một chạm) | – | – |
oauth_client_secret |
client secret của app | chỉ app riêng — form. Một chạm: luôn NULL, secret resolve từ env mỗi lần cần | ✅ | ✅ |
oauth_authorize_url |
authorize endpoint | form (preset, BYO) hoặc registry (một chạm) | – | – |
oauth_token_url |
token endpoint | form (preset, BYO) hoặc registry (một chạm) | – | – |
oauth_scopes |
mảng scope xin ở consent | form (preset, BYO) hoặc registry (một chạm) | – | – |
oauth_token |
access token | callback + mỗi lần refresh (cả hai chế độ) | ✅ | ✅ |
oauth_refresh_token |
refresh token | callback (chỉ khi consent trả về, cả hai chế độ) | ✅ | ✅ |
oauth_expires_at |
hạn access token (ISO) | callback + refresh | – | – |
smtp_host / smtp_port / smtp_use_tls / smtp_username |
tham số SMTP dùng để gửi | registry (một chạm, tại callback) hoặc form (BYO) — không bao giờ ghi đè một giá trị BYO đã có | – | – |
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 | form (preset, BYO) hoặc registry (một chạm) | – | – |
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 + gửi: token-store dùng chung + XOAUTH2
Phần tiêu đề “Dùng token khi poll + gửi: token-store dùng chung + XOAUTH2”Cron IMAP và SMTP gửi (xem Inbound pipeline và Outbound) không dùng mật khẩu cho connection OAuth — cả hai gọi chung getAccessToken(ctx, conn) (services/oauth/token-store.ts):
- Giải mã
oauth_token. Nếu còn hạn (còn > 60s) → trả token hiện tại, không gọi provider, không ghi DB. - Nếu sắp hết hạn / trống → giải mã
oauth_refresh_token, gọirefreshAccessToken(cfg, refreshToken)—cfgresolve creds từ chính connection (app riêng) hoặc fallback về registry theooauth_provider(một chạm, vì row không mang secret) — rồi ghi lạioauth_token(mã hoá) +oauth_expires_atmới. - Nếu không có refresh token → throw “reconnect required” ngay, không gọi provider.
- Nếu refresh thất bại (vd
invalid_grantdo secret xoay vòng hoặc user thu hồi quyền) → setneeds_reauth=true+error_message(cắt còn ≤ 300 ký tự), rồi rethrow lỗi gốc — cron ngừng poll/gửi lỗi cho connection đó cho tới khi admin Kết nối lại.
export async function getAccessToken(ctx: AppContext, conn: any): Promise<string> { let accessToken = decrypt(conn.oauth_token, secret); if (accessToken && !expiringSoon(conn.oauth_expires_at)) return accessToken;
const refreshToken = decrypt(conn.oauth_refresh_token, secret); if (!refreshToken) throw new Error('OAuth reconnect required — no refresh token stored');
const reg = registryFor(ctx.env, conn.oauth_provider); // one-click fallback secret const cfg = configFromConnection(conn, redirectUriFromEnv(ctx.env), secret, reg?.clientSecret); try { const refreshed = await refreshAccessToken(cfg, refreshToken); await ctx.database('hd_connections').where('id', conn.id).update({ oauth_token: encrypt(refreshed.accessToken, secret), oauth_expires_at: refreshed.expiresAt, }); return refreshed.accessToken; } catch (err: any) { await ctx.database('hd_connections').where('id', conn.id).update({ needs_reauth: true, error_message: String(err?.message ?? 'refresh failed').slice(0, 300), }); throw err; }}resolveImapAuth (IMAP) và sendReply (SMTP, khi SmtpResolved.oauth === true) đều gọi hàm này rồi truyền { user, accessToken } vào auth: { type:'XOAUTH2', … } (ImapFlow) / auth: { type:'OAuth2', … } (nodemailer) — xem chi tiết nhánh SMTP ở Outbound.
needs_reauth: ý nghĩa và cách thoát
Phần tiêu đề “needs_reauth: ý nghĩa và cách thoát”needs_reauth=true nghĩa là refresh token không còn dùng được — nguyên nhân thường gặp: user thu hồi quyền app, client secret trên Azure/Google đã bị xoay (rotate) nhưng env chưa cập nhật, hoặc refresh token hết hạn do mailbox không hoạt động quá lâu. Cron bỏ qua hoàn toàn connection này (không poll, không gửi được — reply sẽ log lỗi và không rơi về MailService fallback vì smtp_host vẫn trỏ Microsoft/Google). Cách thoát duy nhất: Kết nối lại — POST /connections/:id/reauth (C4) dựng lại consent URL y hệt lúc tạo mới (không cần tạo inbox lại), user đồng ý quyền lần nữa → callback ghi token mới, needs_reauth=false. Route này gọi được từ cả dòng connection trong Settings và slideover sửa connection.
Endpoints
Phần tiêu đề “Endpoints”| Tầng | Route | Guard | Vai trò |
|---|---|---|---|
| BFF | GET /api/helpdesk/oauth/providers |
manage-inboxes |
C1 — danh sách provider + configured, dùng để hiện nút một chạm |
| BFF | GET /api/helpdesk/oauth/start |
manage-inboxes |
C2 — trả consent URL, ?provider=&name=&resume= (một chạm) hoặc ?connectionId= (BYO/reauth) |
| BFF | POST /api/helpdesk/connections/:id/reauth |
manage-inboxes |
C4 — consent URL để kết nối lại một connection đã tồn tại |
| BFF | GET /api/helpdesk/oauth/callback |
manage-inboxes |
redirect_uri; proxy backend rồi chọn đích 302 theo resume (C5) |
| Backend | GET /helpdesk/oauth/providers |
manage-inboxes |
build từ registry.ts, không bao giờ trả clientSecret |
| Backend | GET /helpdesk/oauth/start |
manage-inboxes |
một chạm: tạo connection pending từ registry; BYO/reauth: load conn có sẵn → buildAuthUrl |
| Backend | POST /helpdesk/connections/:id/reauth |
manage-inboxes |
dùng chung buildStartUrl với start, không set resume |
| Backend | GET /helpdesk/oauth/callback |
manage-inboxes |
verifyState → exchangeCode → fill connection → trả { ok, connectionId, email, resume } hoặc { errors, resume } |
Redirect URI là một giá trị cố định cho cả deployment (HELPDESK_OAUTH_REDIRECT_URI, mặc định dev http://localhost:3100/api/helpdesk/oauth/callback) — phải whitelist trong app phía provider, cho cả app chung lẫn app riêng. 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