feat: initial public release
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
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
This commit is contained in:
+684
@@ -0,0 +1,684 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user