Files
omniai-server/src/sts.js
T

142 lines
4.4 KiB
JavaScript
Raw Normal View History

2026-06-02 13:14:10 +08:00
/**
* 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/<userId>/ 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 };