Bỏ qua để đến nội dung

OAuth: đăng nhập & phân bố data

  • 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)”
  1. Entra admin center → App registrations → New registration. Đặt tên (vd Helpdesk Mail).

  2. 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 set HELPDESK_MICROSOFT_TENANT=<tenant-id-hoặc-domain>. Chọn sai loại tương ứng tenant env → lỗi AADSTS50194 khi consent.

  3. Redirect URI: platform Web, giá trị = đúng HELPDESK_OAUTH_REDIRECT_URI sẽ set ở bước 6 (vd https://odp.codihaus.com/api/helpdesk/oauth/callback, dev http://localhost:3100/api/helpdesk/oauth/callback). Lệch một ký tự (http vs https, thiếu /) → AADSTS50011.

  4. 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 string https://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.
  5. 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.

  6. Set trong backend/.env: HELPDESK_MICROSOFT_CLIENT_ID (Application (client) ID, ở trang Overview), HELPDESK_MICROSOFT_CLIENT_SECRET, HELPDESK_MICROSOFT_TENANT (mặc định common), HELPDESK_OAUTH_REDIRECT_URI. Restart backend — GET /oauth/providers sẽ báo microsoft.configured: true.

  7. 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ỗi 535 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/.

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ế độ”
Luồng OAuth end-to-end (một chạm; app riêng giống hệt trừ bước 1)
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
  1. Có sẵn connection pending. Một chạm: oauthStart tự tạo row từ registry, KHÔNG ghi oauth_client_secret. App riêng: StepConnectOauth.vue gọi createConnection(...) trước với auth_type=oauth, oauth_client_id/secret, oauth_authorize_url/token_url/scopes do admin nhập/preset — filter hook hd_connections.items.create mã hoá oauth_client_secret trước khi ghi.

  2. Lấy consent URL. GET /oauth/start (một chạm: ?provider=microsoft|google&name=&resume=1; app riêng/reauth: ?connectionId=) → buildStartUrl resolve config (configFromConnection, secret từ row hoặc fallback registry), signState, trả { data: { url } }.

  3. Redirect ra provider. window.location.href = url. Microsoft: response_mode=query&prompt=select_account (không ép access_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).

  4. Callback đổi code. Provider redirect về redirect_uri → BFF proxy → backend oauthCallback: verifyState (state hợp lệ trước khi xét error, nên một consent bị từ chối vẫn biết được resume) → exchangeCode → updateOne connection: token + email (ưu tiên id_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 fill smtp_host/port/use_tls/username từ registry — không bao giờ ghi đè một smtp_host app-riêng đã có sẵn.

  5. Về UI theo resume. BFF đọc resume trong 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.ts phục hồi blob C6 từ sessionStorage, set sourceMode='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.vue bắt query rồi xoá.

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):

oauth.controller.ts
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).

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 – –
crypto.ts — cột được mã hoá
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):

  1. 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.
  2. Nếu sắp hết hạn / trống → giải mã oauth_refresh_token, gọi refreshAccessToken(cfg, refreshToken) — cfg resolve creds từ chính connection (app riêng) hoặc fallback về registry theo oauth_provider (một chạm, vì row không mang secret) — rồi ghi lại oauth_token (mã hoá) + oauth_expires_at mới.
  3. Nếu không có refresh token → throw “reconnect required” ngay, không gọi provider.
  4. Nếu refresh thất bại (vd invalid_grant do secret xoay vòng hoặc user thu hồi quyền) → set needs_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.
token-store.ts — getAccessToken (rút gọn)
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=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.

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.