Files
dingMCP/src/oauth.ts
T
gitea-admin 504145aec6
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
feat: initial public release
2026-08-17 03:04:09 +00:00

685 lines
34 KiB
TypeScript

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<string, unknown>;
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<AppConfig["remoteAuth"]>;
private readonly encryptionKey: Buffer;
private appToken?: { value: string; expiresAt: number };
private readonly userRefreshes = new Map<string, Promise<string>>();
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<void> {
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<string> {
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<StoredAuthorization | StoredDeviceLogin>(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<StoredDeviceCode>(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<StoredDeviceCode>(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<StoredDeviceCode>(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<string> {
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<OAuthTokens> {
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<OAuthTokens> {
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<StoredToken>(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<AuthInfo> {
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<StoredToken>(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<void> {
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<StoredToken>(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<StoredToken>(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<string> {
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<OAuthClientInformationFull>(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<StoredCode>(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<StoredAuthorization | StoredDeviceLogin>(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<DingTalkTokenResponse> {
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<DingTalkTokenResponse> {
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<AuthenticatedDingTalkUser> {
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<string, unknown>;
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<string | undefined> {
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<string, unknown>;
const result = value.result && typeof value.result === "object" ? value.result as Record<string, unknown> : value;
return stringField(result, "userid", "userId");
}
private async getAppAccessToken(): Promise<string> {
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
for (const row of rows) {
const token = deserialize<StoredToken>(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<T>(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<string, unknown>, ...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();
}