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
685 lines
34 KiB
TypeScript
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();
|
|
}
|