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

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.

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 OAuth end-to-end
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)
  1. Tạo connection pending. step-connect-oauth.vue gọi createConnection(...) với auth_type=oauth, provider, oauth_client_id/secret, oauth_authorize_url/token_url/scopes, oauth_email, imap_host/port/username. Filter hook hd_connections.items.create mã hoá oauth_client_secret trước khi ghi.

  2. Lấy consent URL. $fetch('/api/helpdesk/oauth/start', { query: { connectionId } }) → backend oauthStart load connection, configFromConnection (giải mã secret), signState, trả { data: { url } }.

  3. Redirect ra provider. window.location.href = url. URL chứa client_id, redirect_uri, scope, access_type=offline, prompt=consent (ép provider trả refresh_token), và state.

  4. Callback đổi code. Provider redirect về redirect_uri (BFF callback.get.ts) → backend oauthCallback: verifyStateconnectionIdexchangeCode (POST token endpoint) → updateOne connection với token + email, status=connected, needs_reauth=false. Hook mã hoá oauth_token + oauth_refresh_token khi ghi.

  5. Về UI. BFF sendRedirect tới /helpdesk/settings?tab=connections&oauth=connected&email=…; settings-connections.vue bắt query → toast rồi xoá query. Admin tạo inbox trên connection đã connected.

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

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 pendingconnectederror 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
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',
];

Cron IMAP (xem Inbound pipeline) không dùng mật khẩu cho connection OAuth — nó gọi resolveImapAuth:

  1. Giải mã oauth_token. Nếu còn hạn → trả { user, accessToken } cho ImapFlow (auth XOAUTH2).
  2. Nếu hết hạn / trống → giải mã oauth_refresh_token, gọi refreshAccessToken(configFromConnection(conn, …), refreshToken) (POST token endpoint bằng creds của chính connection), rồi ghi lại oauth_token (mã hoá) + oauth_expires_at mới.
  3. Nếu refresh thất bại (vd invalid_grant do bị thu hồi quyền) → set needs_reauth=true + error_message; cron ngừng poll connection đó cho tới khi admin kết nối lại.
imap-source.ts — resolveImapAuth (rút gọn)
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)
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 verifyStateexchangeCode → 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.