import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto"; import { chmodSync, mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; import type { Response } from "express"; import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; import { InvalidClientMetadataError, InvalidGrantError, InvalidRequestError, } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import type { AuthorizationParams, OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { AppConfig } from "./config.js"; import type { AuthenticatedDingTalkUser } from "./xft.js"; export const DINGTALK_OAUTH_AUTHORIZE_ORIGIN = "https://login.dingtalk.com"; type JsonRow = Record; interface StoredAuthorization { flow: "authorization_code"; client: OAuthClientInformationFull; params: AuthorizationParams; consentApproved: boolean; expiresAt: number; } interface StoredDeviceLogin { flow: "device"; deviceCodeHash: string; clientId: string; scopes: string[]; userCode: string; consentApproved: boolean; expiresAt: number; } interface StoredDeviceCode { clientId: string; scopes: string[]; resource?: string; user?: AuthenticatedDingTalkUser; status: "pending" | "authorized"; expiresAt: number; } interface StoredCode extends StoredAuthorization { user: AuthenticatedDingTalkUser; } interface StoredToken { clientId: string; user: AuthenticatedDingTalkUser; scopes: string[]; resource?: string; expiresAt: number; } interface DingTalkTokenResponse { accessToken?: string; refreshToken?: string; expireIn?: number; expiresIn?: number; corpId?: string; } /** Persistent, single-enterprise OAuth provider for remote MCP clients. */ export class DingTalkMcpOAuthProvider implements OAuthServerProvider { readonly clientsStore: OAuthRegisteredClientsStore; private readonly db: DatabaseSync; private readonly auth: NonNullable; private readonly encryptionKey: Buffer; private appToken?: { value: string; expiresAt: number }; private readonly userRefreshes = new Map>(); private readonly cleanupTimer: NodeJS.Timeout; constructor(private readonly config: AppConfig, private readonly fetchFn: typeof fetch = fetch) { if (!config.remoteAuth) throw new Error("远程 OAuth 未启用"); if (!config.corpId) throw new Error("远程 OAuth 必须配置钉钉 CorpId"); this.auth = config.remoteAuth; const databasePath = resolve(this.auth.databasePath); mkdirSync(dirname(databasePath), { recursive: true, mode: 0o700 }); this.db = new DatabaseSync(databasePath); chmodSync(databasePath, 0o600); this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;"); this.db.exec(` CREATE TABLE IF NOT EXISTS oauth_clients ( client_id TEXT PRIMARY KEY, metadata TEXT NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS login_requests ( login_state TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS authorization_codes ( code_hash TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS access_tokens ( token_hash TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS refresh_tokens ( token_hash TEXT PRIMARY KEY, payload TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS identities ( user_id TEXT PRIMARY KEY, name TEXT, token_cipher TEXT NOT NULL, expires_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS device_codes ( device_code_hash TEXT PRIMARY KEY, user_code TEXT UNIQUE NOT NULL, payload TEXT NOT NULL, expires_at INTEGER NOT NULL, last_poll_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS audit_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, action TEXT NOT NULL, reference_hash TEXT, outcome TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS audit_events_created_at ON audit_events(created_at); `); this.ensureOAuthClientLastUsedColumn(); this.encryptionKey = createHash("sha256").update(this.auth.sessionSecret).digest(); this.clientsStore = { getClient: async (clientId) => this.getClient(clientId), registerClient: async (client) => this.registerClient(client as OAuthClientInformationFull), }; this.cleanup(); this.cleanupTimer = setInterval(() => this.cleanup(), 60 * 60_000); this.cleanupTimer.unref(); } async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { if (!client.redirect_uris.includes(params.redirectUri) && !loopbackRedirectMatches(client.redirect_uris, params.redirectUri)) { throw new InvalidRequestError("Unregistered redirect_uri"); } const loginState = randomToken(32); const payload: StoredAuthorization = { flow: "authorization_code", client, params, consentApproved: false, expiresAt: Date.now() + 10 * 60_000, }; this.db.prepare("INSERT INTO login_requests(login_state,payload,expires_at) VALUES(?,?,?)") .run(loginState, serialize(payload), payload.expiresAt); const consent = new URL("/oauth/consent", this.auth.issuerUrl); consent.searchParams.set("login_state", loginState); res.redirect(302, consent.toString()); } consentDetails(loginState: string) { const pending = this.readPendingLogin(loginState); if (pending.flow === "device") { const client = this.getClient(pending.clientId); if (!client) throw new InvalidGrantError("OAuth 客户端已失效"); return { clientName: client.client_name ?? "未命名 OAuth 客户端", scopes: pending.scopes, userCode: pending.userCode, }; } return { clientName: pending.client.client_name ?? "未命名 OAuth 客户端", scopes: pending.params.scopes ?? [], redirectOrigin: new URL(pending.params.redirectUri).origin, }; } approveConsent(loginState: string): string { const pending = this.readPendingLogin(loginState); const approved = { ...pending, consentApproved: true }; this.db.prepare("UPDATE login_requests SET payload=? WHERE login_state=?") .run(serialize(approved), loginState); return this.dingTalkAuthorizeUrl(loginState); } async completeDingTalkLogin(code: string, loginState: string): Promise { const row = this.db.prepare("SELECT payload,expires_at FROM login_requests WHERE login_state=?").get(loginState) as JsonRow | undefined; this.db.prepare("DELETE FROM login_requests WHERE login_state=?").run(loginState); if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("登录请求无效或已过期"); const pending = deserialize(String(row.payload)); if (!pending.consentApproved) throw new InvalidGrantError("用户尚未确认 OAuth 客户端授权"); const upstream = await this.exchangeDingTalkCode(code); const user = await this.resolveDingTalkIdentity(upstream); this.saveIdentity(user, upstream); if (pending.flow === "device") { const deviceRow = this.db.prepare("SELECT payload,expires_at FROM device_codes WHERE device_code_hash=?") .get(pending.deviceCodeHash) as JsonRow | undefined; if (!deviceRow || Number(deviceRow.expires_at) < Date.now()) throw new InvalidGrantError("设备授权请求已过期"); const device = deserialize(String(deviceRow.payload)); const authorized: StoredDeviceCode = { ...device, status: "authorized", user }; this.db.prepare("UPDATE device_codes SET payload=? WHERE device_code_hash=?") .run(serialize(authorized), pending.deviceCodeHash); return new URL("/oauth/device/success", this.auth.issuerUrl).toString(); } const authorizationCode = randomToken(32); const stored: StoredCode = { ...pending, user, expiresAt: Date.now() + 5 * 60_000 }; this.db.prepare("INSERT INTO authorization_codes(code_hash,payload,expires_at) VALUES(?,?,?)") .run(hashToken(authorizationCode), serialize(stored), stored.expiresAt); const redirect = new URL(pending.params.redirectUri); redirect.searchParams.set("code", authorizationCode); if (pending.params.state) redirect.searchParams.set("state", pending.params.state); return redirect.toString(); } startDeviceAuthorization(clientId: string, scopes: string[], resource?: string) { if (!this.getClient(clientId)) throw new InvalidGrantError("未知 OAuth 客户端"); this.db.prepare("DELETE FROM device_codes WHERE expires_at < ?").run(Date.now()); const pending = this.db.prepare("SELECT COUNT(*) AS count FROM device_codes WHERE payload LIKE ?") .get(`%\"clientId\":\"${clientId.replace(/[\\%_]/g, "")}\"%`) as JsonRow; if (Number(pending.count) >= 10) throw new InvalidRequestError("该客户端的待登录请求过多,请稍后重试"); const total = this.db.prepare("SELECT COUNT(*) AS count FROM device_codes").get() as JsonRow; if (Number(total.count) >= 1000) throw new InvalidRequestError("登录服务繁忙,请稍后重试"); const deviceCode = randomToken(32); const userCode = readableUserCode(); const expiresAt = Date.now() + 10 * 60_000; const stored: StoredDeviceCode = { clientId, scopes, ...(resource ? { resource } : {}), status: "pending", expiresAt }; this.db.prepare("INSERT INTO device_codes(device_code_hash,user_code,payload,expires_at,last_poll_at) VALUES(?,?,?,?,0)") .run(hashToken(deviceCode), userCode, serialize(stored), expiresAt); const verificationUri = new URL("/oauth/device", this.auth.issuerUrl); const complete = new URL(verificationUri); complete.searchParams.set("user_code", userCode); return { device_code: deviceCode, user_code: userCode, verification_uri: verificationUri.toString(), verification_uri_complete: complete.toString(), expires_in: 600, interval: 5, }; } beginDeviceLogin(userCode: string): string { const row = this.db.prepare("SELECT device_code_hash,payload,expires_at FROM device_codes WHERE user_code=?") .get(normalizeUserCode(userCode)) as JsonRow | undefined; if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("设备授权码无效或已过期"); const device = deserialize(String(row.payload)); if (device.status !== "pending") return new URL("/oauth/device/success", this.auth.issuerUrl).toString(); const loginState = randomToken(32); const pending: StoredDeviceLogin = { flow: "device", deviceCodeHash: String(row.device_code_hash), clientId: device.clientId, scopes: device.scopes, userCode: normalizeUserCode(userCode), consentApproved: false, expiresAt: Number(row.expires_at), }; this.db.prepare("INSERT INTO login_requests(login_state,payload,expires_at) VALUES(?,?,?)") .run(loginState, serialize(pending), pending.expiresAt); const consent = new URL("/oauth/consent", this.auth.issuerUrl); consent.searchParams.set("login_state", loginState); return consent.toString(); } exchangeDeviceCode(clientId: string, deviceCode: string): OAuthTokens | "authorization_pending" | "slow_down" { const hash = hashToken(deviceCode); const row = this.db.prepare("SELECT payload,expires_at,last_poll_at FROM device_codes WHERE device_code_hash=?") .get(hash) as JsonRow | undefined; if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("设备授权请求无效或已过期"); const stored = deserialize(String(row.payload)); if (stored.clientId !== clientId) throw new InvalidGrantError("设备授权请求不属于当前客户端"); const now = Date.now(); if (stored.status === "pending") { if (Number(row.last_poll_at) > now - 4_000) return "slow_down"; this.db.prepare("UPDATE device_codes SET last_poll_at=? WHERE device_code_hash=?").run(now, hash); return "authorization_pending"; } if (!stored.user) throw new InvalidGrantError("设备授权缺少用户身份"); this.db.prepare("DELETE FROM device_codes WHERE device_code_hash=?").run(hash); return this.issueTokens(stored.clientId, stored.user, stored.scopes, stored.resource); } async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { const stored = this.readCode(authorizationCode); if (stored.client.client_id !== client.client_id) throw new InvalidGrantError("授权码不属于当前客户端"); return stored.params.codeChallenge; } async exchangeAuthorizationCode( client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string, resource?: URL, ): Promise { const stored = this.readCode(authorizationCode); if (stored.client.client_id !== client.client_id) throw new InvalidGrantError("授权码不属于当前客户端"); if (redirectUri && redirectUri !== stored.params.redirectUri) throw new InvalidGrantError("redirect_uri 不匹配"); if (!resourceMatches(stored.params.resource, resource)) throw new InvalidGrantError("resource 不匹配"); this.db.prepare("DELETE FROM authorization_codes WHERE code_hash=?").run(hashToken(authorizationCode)); return this.issueTokens(client.client_id, stored.user, stored.params.scopes ?? [], stored.params.resource?.toString()); } async exchangeRefreshToken( client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL, ): Promise { const hash = hashToken(refreshToken); const row = this.db.prepare("SELECT payload,expires_at FROM refresh_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined; if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("Refresh Token 无效或已过期"); const stored = deserialize(String(row.payload)); if (stored.clientId !== client.client_id) throw new InvalidGrantError("Refresh Token 不属于当前客户端"); if (resource && stored.resource && resource.toString() !== stored.resource) throw new InvalidGrantError("resource 不匹配"); const requestedScopes = scopes ?? stored.scopes; if (requestedScopes.some((scope) => !stored.scopes.includes(scope))) throw new InvalidGrantError("不能扩大授权范围"); this.db.prepare("DELETE FROM refresh_tokens WHERE token_hash=?").run(hash); return this.issueTokens(client.client_id, stored.user, requestedScopes, stored.resource); } async verifyAccessToken(token: string): Promise { const row = this.db.prepare("SELECT payload,expires_at FROM access_tokens WHERE token_hash=?").get(hashToken(token)) as JsonRow | undefined; if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("Access Token 无效或已过期"); const stored = deserialize(String(row.payload)); return { token, clientId: stored.clientId, scopes: stored.scopes, expiresAt: Math.floor(stored.expiresAt / 1000), ...(stored.resource ? { resource: new URL(stored.resource) } : {}), extra: { userId: stored.user.userId, ...(stored.user.name ? { userName: stored.user.name } : {}) }, }; } async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise { const hash = hashToken(request.token); const refresh = this.db.prepare("SELECT payload FROM refresh_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined; if (refresh) { const stored = deserialize(String(refresh.payload)); if (stored.clientId === client.client_id) { this.deleteGrantTokens(stored.clientId, stored.user.userId); return; } } const access = this.db.prepare("SELECT payload FROM access_tokens WHERE token_hash=?").get(hash) as JsonRow | undefined; if (access && deserialize(String(access.payload)).clientId === client.client_id) { this.db.prepare("DELETE FROM access_tokens WHERE token_hash=?").run(hash); } } userFromAuth(auth: AuthInfo): AuthenticatedDingTalkUser { const userId = auth.extra?.userId; if (typeof userId !== "string" || !userId) throw new InvalidGrantError("会话缺少钉钉用户身份"); const name = auth.extra?.userName; return { userId, ...(typeof name === "string" && name ? { name } : {}) }; } hasDingTalkIdentity(userId: string): boolean { return Boolean(this.db.prepare("SELECT 1 FROM identities WHERE user_id=?").get(userId)); } /** Returns a current upstream user token for server-side per-user operations. */ async getDingTalkUserAccessToken(userId: string): Promise { const row = this.db.prepare("SELECT name,token_cipher,expires_at FROM identities WHERE user_id=?").get(userId) as JsonRow | undefined; if (!row) throw new InvalidGrantError("当前用户尚未完成钉钉授权"); let token = this.decryptTokenCipher(String(row.token_cipher)); if (Number(row.expires_at) <= Date.now() + 60_000) { if (!token.refreshToken) throw new InvalidGrantError("钉钉用户授权已过期,请重新登录"); const active = this.userRefreshes.get(userId); if (active) return active; const refresh = (async () => { const refreshed = await this.refreshDingTalkToken(token.refreshToken!); this.saveIdentity({ userId, ...(typeof row.name === "string" && row.name ? { name: row.name } : {}) }, refreshed); if (!refreshed.accessToken) throw new InvalidGrantError("钉钉用户授权缺少 Access Token"); return refreshed.accessToken; })(); this.userRefreshes.set(userId, refresh); try { return await refresh; } finally { this.userRefreshes.delete(userId); } } if (!token.accessToken) throw new InvalidGrantError("钉钉用户授权缺少 Access Token"); return token.accessToken; } audit(userId: string, action: string, reference: string | undefined, outcome: string): void { this.db.prepare("INSERT INTO audit_events(user_id,action,reference_hash,outcome,created_at) VALUES(?,?,?,?,?)") .run(userId, action, reference ? hashToken(reference) : null, outcome, Date.now()); } close(): void { clearInterval(this.cleanupTimer); this.db.close(); } private getClient(clientId: string): OAuthClientInformationFull | undefined { const row = this.db.prepare("SELECT metadata FROM oauth_clients WHERE client_id=?").get(clientId) as JsonRow | undefined; if (row) this.db.prepare("UPDATE oauth_clients SET last_used_at=? WHERE client_id=?").run(Date.now(), clientId); return row ? deserialize(String(row.metadata)) : undefined; } private registerClient(client: OAuthClientInformationFull): OAuthClientInformationFull { if (!client.client_id) client.client_id = randomUUID(); if (!client.redirect_uris.every(safeRedirectUri)) throw new InvalidClientMetadataError("仅允许 HTTPS 或本机回环 redirect_uri"); const normalized: OAuthClientInformationFull = { ...client, token_endpoint_auth_method: client.token_endpoint_auth_method ?? "none", grant_types: client.grant_types ?? ["authorization_code", "refresh_token"], response_types: client.response_types ?? ["code"], client_id_issued_at: client.client_id_issued_at ?? Math.floor(Date.now() / 1000), }; const existing = this.db.prepare("SELECT 1 FROM oauth_clients WHERE client_id=?").get(normalized.client_id); const count = this.db.prepare("SELECT COUNT(*) AS count FROM oauth_clients").get() as JsonRow; if (!existing && Number(count.count) >= 10_000) throw new InvalidClientMetadataError("OAuth 客户端注册数量已达安全上限"); const now = Date.now(); this.db.prepare("INSERT OR REPLACE INTO oauth_clients(client_id,metadata,created_at,last_used_at) VALUES(?,?,?,?)") .run(normalized.client_id, serialize(normalized), now, now); return normalized; } private readCode(code: string): StoredCode { const row = this.db.prepare("SELECT payload,expires_at FROM authorization_codes WHERE code_hash=?").get(hashToken(code)) as JsonRow | undefined; if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("授权码无效或已过期"); return deserialize(String(row.payload)); } private readPendingLogin(loginState: string): StoredAuthorization | StoredDeviceLogin { const row = this.db.prepare("SELECT payload,expires_at FROM login_requests WHERE login_state=?") .get(loginState) as JsonRow | undefined; if (!row || Number(row.expires_at) < Date.now()) throw new InvalidGrantError("登录请求无效或已过期"); return deserialize(String(row.payload)); } private issueTokens(clientId: string, user: AuthenticatedDingTalkUser, scopes: string[], resource?: string): OAuthTokens { const accessToken = randomToken(32); const refreshToken = randomToken(48); const access: StoredToken = { clientId, user, scopes, ...(resource ? { resource } : {}), expiresAt: Date.now() + this.auth.accessTokenTtlSeconds * 1_000, }; const refresh: StoredToken = { ...access, expiresAt: Date.now() + this.auth.refreshTokenTtlSeconds * 1_000, }; this.db.prepare("INSERT INTO access_tokens(token_hash,payload,expires_at) VALUES(?,?,?)") .run(hashToken(accessToken), serialize(access), access.expiresAt); this.db.prepare("INSERT INTO refresh_tokens(token_hash,payload,expires_at) VALUES(?,?,?)") .run(hashToken(refreshToken), serialize(refresh), refresh.expiresAt); return { access_token: accessToken, refresh_token: refreshToken, token_type: "bearer", expires_in: this.auth.accessTokenTtlSeconds, scope: scopes.join(" "), }; } private async exchangeDingTalkCode(code: string): Promise { const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/userAccessToken`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ clientId: this.config.clientId, clientSecret: this.config.clientSecret, code, grantType: "authorization_code" }), signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); const data = await response.json() as DingTalkTokenResponse & { message?: string }; if (!response.ok || !data.accessToken) throw new InvalidGrantError(data.message ?? "钉钉授权码交换失败"); if (data.corpId && data.corpId !== this.config.corpId) throw new InvalidGrantError("该钉钉账号不属于允许的企业"); return data; } private dingTalkAuthorizeUrl(loginState: string): string { const callback = new URL("/oauth/dingtalk/callback", this.auth.issuerUrl); const target = new URL("/oauth2/auth", DINGTALK_OAUTH_AUTHORIZE_ORIGIN); target.searchParams.set("redirect_uri", callback.toString()); target.searchParams.set("response_type", "code"); target.searchParams.set("client_id", this.config.clientId); target.searchParams.set("scope", "openid corpid"); target.searchParams.set("corpId", this.config.corpId!); target.searchParams.set("state", loginState); target.searchParams.set("prompt", "consent"); return target.toString(); } private async refreshDingTalkToken(refreshToken: string): Promise { const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/userAccessToken`, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ clientId: this.config.clientId, clientSecret: this.config.clientSecret, refreshToken, grantType: "refresh_token", }), signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); const data = await response.json() as DingTalkTokenResponse & { message?: string }; if (!response.ok || !data.accessToken) throw new InvalidGrantError(data.message ?? "钉钉用户授权刷新失败,请重新登录"); if (data.corpId && data.corpId !== this.config.corpId) throw new InvalidGrantError("刷新后的钉钉账号不属于允许的企业"); return { ...data, refreshToken: data.refreshToken ?? refreshToken }; } private async resolveDingTalkIdentity(token: DingTalkTokenResponse): Promise { const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/contact/users/me`, { headers: { "x-acs-dingtalk-access-token": token.accessToken!, accept: "application/json" }, signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); const me = await response.json() as Record; if (!response.ok) throw new InvalidGrantError("无法读取钉钉登录用户身份"); const corpId = stringField(me, "corpId", "corp_id"); if (corpId && corpId !== this.config.corpId) throw new InvalidGrantError("该钉钉账号不属于允许的企业"); const directUserId = stringField(me, "userId", "userid"); const unionId = stringField(me, "unionId", "unionid"); let userId: string | undefined; if (unionId) { userId = await this.userIdByUnionId(unionId); if (!userId) throw new InvalidGrantError("该钉钉账号不是允许企业的成员"); if (directUserId && directUserId !== userId) throw new InvalidGrantError("钉钉登录身份与企业成员映射不一致"); } else if (corpId === this.config.corpId) { userId = directUserId; } else { throw new InvalidGrantError("无法确认该钉钉账号属于允许的企业"); } if (!userId) throw new InvalidGrantError("钉钉登录结果缺少企业 userId"); const name = stringField(me, "nick", "name"); return { userId, ...(name ? { name } : {}) }; } private async userIdByUnionId(unionId: string): Promise { const appToken = await this.getAppAccessToken(); const response = await this.fetchFn(`${this.config.oapiBaseUrl}/topapi/user/getbyunionid?access_token=${encodeURIComponent(appToken)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ unionid: unionId }), signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); const value = await response.json() as Record; const result = value.result && typeof value.result === "object" ? value.result as Record : value; return stringField(result, "userid", "userId"); } private async getAppAccessToken(): Promise { if (this.appToken && this.appToken.expiresAt > Date.now() + 60_000) return this.appToken.value; const response = await this.fetchFn(`${this.config.apiBaseUrl}/v1.0/oauth2/accessToken`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ appKey: this.config.clientId, appSecret: this.config.clientSecret }), signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); const data = await response.json() as { accessToken?: string; expireIn?: number }; if (!response.ok || !data.accessToken) throw new InvalidGrantError("无法取得钉钉应用 Access Token"); this.appToken = { value: data.accessToken, expiresAt: Date.now() + (data.expireIn ?? 7200) * 1_000 }; return data.accessToken; } private saveIdentity(user: AuthenticatedDingTalkUser, token: DingTalkTokenResponse): void { const expiresAt = Date.now() + (token.expireIn ?? token.expiresIn ?? 7200) * 1_000; const cipher = this.encrypt(JSON.stringify(token)); this.db.prepare(`INSERT INTO identities(user_id,name,token_cipher,expires_at,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET name=COALESCE(excluded.name, identities.name), token_cipher=excluded.token_cipher, expires_at=excluded.expires_at, updated_at=excluded.updated_at`) .run(user.userId, user.name ?? null, cipher, expiresAt, Date.now()); } private encrypt(plaintext: string): string { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, iv); const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); return `${iv.toString("base64url")}.${encrypted.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}`; } decryptIdentityToken(userId: string): DingTalkTokenResponse | undefined { const row = this.db.prepare("SELECT token_cipher FROM identities WHERE user_id=?").get(userId) as JsonRow | undefined; if (!row) return undefined; return this.decryptTokenCipher(String(row.token_cipher)); } private decryptTokenCipher(value: string): DingTalkTokenResponse { const [ivValue, payloadValue, tagValue] = value.split("."); if (!ivValue || !payloadValue || !tagValue) throw new InvalidGrantError("钉钉用户令牌存储损坏,请重新登录"); const decipher = createDecipheriv("aes-256-gcm", this.encryptionKey, Buffer.from(ivValue, "base64url")); decipher.setAuthTag(Buffer.from(tagValue, "base64url")); const plaintext = Buffer.concat([decipher.update(Buffer.from(payloadValue, "base64url")), decipher.final()]); return JSON.parse(plaintext.toString("utf8")) as DingTalkTokenResponse; } private cleanup(): void { const now = Date.now(); for (const table of ["login_requests", "authorization_codes", "access_tokens", "refresh_tokens", "device_codes"] as const) { this.db.prepare(`DELETE FROM ${table} WHERE expires_at < ?`).run(now); } this.db.prepare("DELETE FROM audit_events WHERE created_at < ?").run(now - 30 * 24 * 60 * 60_000); const staleClients = this.db.prepare("SELECT client_id FROM oauth_clients WHERE last_used_at < ? LIMIT 1000") .all(now - 180 * 24 * 60 * 60_000) as Array>; for (const row of staleClients) { const clientId = String(row.client_id); if (!this.clientHasLiveArtifacts(clientId, now)) { this.db.prepare("DELETE FROM oauth_clients WHERE client_id=?").run(clientId); } } } private ensureOAuthClientLastUsedColumn(): void { const columns = this.db.prepare("PRAGMA table_info(oauth_clients)").all() as Array>; if (!columns.some((column) => column.name === "last_used_at")) { this.db.exec("ALTER TABLE oauth_clients ADD COLUMN last_used_at INTEGER NOT NULL DEFAULT 0"); this.db.prepare("UPDATE oauth_clients SET last_used_at=created_at WHERE last_used_at=0").run(); } } private clientHasLiveArtifacts(clientId: string, now: number): boolean { const needle = `%${clientId}%`; for (const table of ["access_tokens", "refresh_tokens", "device_codes", "authorization_codes", "login_requests"] as const) { const row = this.db.prepare(`SELECT 1 FROM ${table} WHERE expires_at >= ? AND payload LIKE ? LIMIT 1`).get(now, needle); if (row) return true; } return false; } private deleteGrantTokens(clientId: string, userId: string): void { for (const table of ["access_tokens", "refresh_tokens"] as const) { const rows = this.db.prepare(`SELECT token_hash,payload FROM ${table}`).all() as Array>; for (const row of rows) { const token = deserialize(String(row.payload)); if (token.clientId === clientId && token.user.userId === userId) { this.db.prepare(`DELETE FROM ${table} WHERE token_hash=?`).run(String(row.token_hash)); } } } } } function serialize(value: unknown): string { return JSON.stringify(value, (_key, child) => child instanceof URL ? child.toString() : child); } function deserialize(value: string): T { return JSON.parse(value) as T; } function randomToken(bytes: number): string { return randomBytes(bytes).toString("base64url"); } function hashToken(value: string): string { return createHash("sha256").update(value).digest("hex"); } function safeRedirectUri(value: string): boolean { try { const parsed = new URL(value); return parsed.protocol === "https:" || (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname)); } catch { return false; } } function loopbackRedirectMatches(registered: string[], requested: string): boolean { try { const target = new URL(requested); if (target.protocol !== "http:" || !["localhost", "127.0.0.1", "::1", "[::1]"].includes(target.hostname)) return false; return registered.some((value) => { const expected = new URL(value); return expected.protocol === target.protocol && expected.hostname === target.hostname && expected.pathname === target.pathname && expected.search === target.search; }); } catch { return false; } } function resourceMatches(expected: URL | undefined, actual: URL | undefined): boolean { return expected === undefined ? actual === undefined : actual?.toString() === expected.toString(); } function stringField(record: Record, ...keys: string[]): string | undefined { for (const key of keys) { const value = record[key]; if (typeof value === "string" && value.trim()) return value.trim(); } return undefined; } function readableUserCode(): string { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const bytes = randomBytes(8); const value = [...bytes].map((byte) => alphabet[byte % alphabet.length]).join(""); return `${value.slice(0, 4)}-${value.slice(4)}`; } function normalizeUserCode(value: string): string { const compact = value.toUpperCase().replace(/[^A-Z2-9]/g, ""); return compact.length === 8 ? `${compact.slice(0, 4)}-${compact.slice(4)}` : value.trim().toUpperCase(); }