/** * Alibaba Cloud STS (Security Token Service) integration. * * Issues temporary OSS credentials scoped to a specific user's prefix, * so clients can upload directly to OSS without long-term AccessKeys. */ const crypto = require("node:crypto"); const STS_ACCESS_KEY_ID = process.env.STS_ACCESS_KEY_ID || ""; const STS_ACCESS_KEY_SECRET = process.env.STS_ACCESS_KEY_SECRET || ""; const OSS_ROLE_ARN = process.env.OSS_ROLE_ARN || ""; const OSS_BUCKET = process.env.OSS_BUCKET || ""; const OSS_REGION = process.env.OSS_REGION || ""; const STS_ENDPOINT = "sts.aliyuncs.com"; const STS_API_VERSION = "2015-04-01"; const DEFAULT_DURATION_SECONDS = 900; // 15 minutes const MAX_DURATION_SECONDS = 3600; function isSTSConfigured() { return !!(STS_ACCESS_KEY_ID && STS_ACCESS_KEY_SECRET && OSS_ROLE_ARN && OSS_BUCKET && OSS_REGION); } /** * Build a session policy that restricts the assumed role to only * access objects under users// in the target bucket. */ function buildSessionPolicy(userId) { return JSON.stringify({ Version: "1", Statement: [ { Effect: "Allow", Action: ["oss:PutObject", "oss:PutObjectAcl", "oss:GetObject", "oss:DeleteObject"], Resource: [ `acs:oss:*:*:${OSS_BUCKET}/users/${userId}/*`, `acs:oss:*:*:${OSS_BUCKET}/tmp/${userId}/*`, ], }, { Effect: "Allow", Action: ["oss:ListObjects"], Resource: [`acs:oss:*:*:${OSS_BUCKET}`], Condition: { StringLike: { "oss:Prefix": [`users/${userId}/*`, `tmp/${userId}/*`], }, }, }, ], }); } /** * Percent-encode per RFC 3986 (same as AWS/Alibaba signing). */ function percentEncode(str) { return encodeURIComponent(str) .replace(/!/g, "%21") .replace(/'/g, "%27") .replace(/\(/g, "%28") .replace(/\)/g, "%29") .replace(/\*/g, "%2A"); } /** * Compute HMAC-SHA1 signature for STS API request. */ function computeSignature(method, params, accessKeySecret) { const sortedKeys = Object.keys(params).sort(); const canonicalQuery = sortedKeys .map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`) .join("&"); const stringToSign = `${method.toUpperCase()}&${percentEncode("/")}&${percentEncode(canonicalQuery)}`; return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64"); } /** * Call STS AssumeRole API to get temporary credentials for a user. * * @param {string|number} userId - The user ID to scope credentials to * @param {number} [durationSeconds] - Credential lifetime (900-3600s) * @returns {Promise<{accessKeyId, accessKeySecret, securityToken, expiration, bucket, region, ossPathPrefix}>} */ async function assumeRole(userId, durationSeconds = DEFAULT_DURATION_SECONDS) { if (!isSTSConfigured()) { throw new Error("STS is not configured on the server"); } const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, ""); if (!safeUserId) { throw new Error("Invalid userId for STS token"); } const duration = Math.min(Math.max(durationSeconds, 900), MAX_DURATION_SECONDS); const params = { Action: "AssumeRole", Version: STS_API_VERSION, RoleArn: OSS_ROLE_ARN, RoleSessionName: `omniai-user-${safeUserId}-${Date.now()}`, DurationSeconds: String(duration), Policy: buildSessionPolicy(safeUserId), Format: "JSON", AccessKeyId: STS_ACCESS_KEY_ID, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", SignatureNonce: crypto.randomUUID(), Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), }; params.Signature = computeSignature("GET", params, STS_ACCESS_KEY_SECRET); const queryString = Object.entries(params) .map(([k, v]) => `${percentEncode(k)}=${percentEncode(String(v))}`) .join("&"); const url = `https://${STS_ENDPOINT}/?${queryString}`; const response = await fetch(url, { method: "GET" }); const data = await response.json(); if (data.RequestId && data.Credentials) { const cred = data.Credentials; return { accessKeyId: cred.AccessKeyId, accessKeySecret: cred.AccessKeySecret, securityToken: cred.SecurityToken, expiration: cred.Expiration, bucket: OSS_BUCKET, region: OSS_REGION, ossPathPrefix: `users/${safeUserId}/assets/`, }; } throw new Error(data.Message || `STS AssumeRole failed: ${JSON.stringify(data)}`); } module.exports = { assumeRole, isSTSConfigured };