local sigv4 = require("assay.aws.sigv4")
local M = {}
local DEFAULT_DURATION = 3600
local EXPIRY_SKEW_SECS = 120
local MAX_PROFILE_DEPTH = 5
local _cache = {}
local function region_of(opts)
return (opts and opts.region)
or env.get("AWS_REGION")
or env.get("AWS_DEFAULT_REGION")
or "us-east-1"
end
local function sts_url(opts)
if opts and opts.sts_endpoint then
return opts.sts_endpoint:gsub("/+$", "")
end
return "https://sts." .. region_of(opts) .. ".amazonaws.com"
end
local function url_encode(str)
return (tostring(str):gsub("([^A-Za-z0-9%-_.~])", function(c)
return string.format("%%%02X", string.byte(c))
end))
end
local function form_encode(params)
local names = {}
for k in pairs(params) do names[#names + 1] = k end
table.sort(names)
local parts = {}
for _, k in ipairs(names) do
parts[#parts + 1] = url_encode(k) .. "=" .. url_encode(params[k])
end
return table.concat(parts, "&")
end
local function parse_credentials(body, result_key)
local doc = json.parse(body)
local resp = doc[result_key .. "Response"]
local result = resp and resp[result_key .. "Result"]
local c = result and result.Credentials
if not c then
error("aws.sts: unexpected STS response shape (missing " .. result_key .. "Response)")
end
local expires_at = nil
if type(c.Expiration) == "number" then
expires_at = c.Expiration
end
return {
access_key = c.AccessKeyId,
secret_key = c.SecretAccessKey,
session_token = c.SessionToken,
expires_at = expires_at,
}
end
local function cache_get(key)
local e = _cache[key]
if not e then return nil end
if e.expires_at and os.time() > (e.expires_at - EXPIRY_SKEW_SECS) then
_cache[key] = nil
return nil
end
return e
end
function M.assume_role_with_web_identity(role_arn, token, opts)
opts = opts or {}
local body = form_encode({
Action = "AssumeRoleWithWebIdentity",
Version = "2011-06-15",
RoleArn = role_arn,
RoleSessionName = opts.session_name or "assay",
WebIdentityToken = token,
DurationSeconds = tostring(opts.duration_secs or DEFAULT_DURATION),
})
local resp = http.post(sts_url(opts), body, {
headers = {
["Content-Type"] = "application/x-www-form-urlencoded",
["Accept"] = "application/json",
},
})
if resp.status ~= 200 then
error("aws.sts.assume_role_with_web_identity: HTTP " .. resp.status .. ": " .. resp.body)
end
return parse_credentials(resp.body, "AssumeRoleWithWebIdentity")
end
function M.assume_role(role_arn, opts)
opts = opts or {}
local base = opts.credentials or M.credentials({
profile = opts.profile,
region = opts.region,
sts_endpoint = opts.sts_endpoint,
})
local body = form_encode({
Action = "AssumeRole",
Version = "2011-06-15",
RoleArn = role_arn,
RoleSessionName = opts.session_name or "assay",
DurationSeconds = tostring(opts.duration_secs or DEFAULT_DURATION),
})
local url = sts_url(opts)
local host = url:gsub("^https?://", ""):gsub("/.*$", "")
local headers = sigv4.sign({
access_key = base.access_key,
secret_key = base.secret_key,
session_token = base.session_token,
service = "sts",
region = region_of(opts),
method = "POST",
host = host,
path = "/",
payload = body,
headers = { ["content-type"] = "application/x-www-form-urlencoded" },
})
headers["accept"] = "application/json"
local resp = http.post(url, body, { headers = headers })
if resp.status ~= 200 then
error("aws.sts.assume_role: HTTP " .. resp.status .. " assuming " .. role_arn .. ": " .. resp.body)
end
return parse_credentials(resp.body, "AssumeRole")
end
local function parse_ini(text)
local sections = {}
local current = nil
for raw in (text .. "\n"):gmatch("(.-)\n") do
local line = raw:match("^%s*(.-)%s*$")
if line ~= "" and not line:match("^[;#]") then
local section = line:match("^%[(.-)%]$")
if section then
current = {}
sections[section] = current
elseif current then
local k, v = line:match("^([%w_%-%.]+)%s*=%s*(.-)$")
if k then current[k:lower()] = v end
end
end
end
return sections
end
M._parse_ini = parse_ini
local function read_ini(path)
local ok, text = pcall(fs.read, path)
if not ok then return {} end
return parse_ini(text)
end
local function aws_dir()
return (env.get("HOME") or "") .. "/.aws"
end
local function profile_entry(name, opts)
local credentials_file = (opts and opts.credentials_file) or aws_dir() .. "/credentials"
local config_file = (opts and opts.config_file) or aws_dir() .. "/config"
local creds = read_ini(credentials_file)[name]
local cfg = read_ini(config_file)[name == "default" and "default" or ("profile " .. name)]
if not creds and not cfg then
return nil
end
local merged = {}
for k, v in pairs(cfg or {}) do merged[k] = v end
for k, v in pairs(creds or {}) do merged[k] = v end
return merged
end
local function resolve_profile(name, opts, depth)
if depth > MAX_PROFILE_DEPTH then
error("aws.sts: profile chain too deep resolving '" .. name .. "' (circular source_profile?)")
end
local p = profile_entry(name, opts)
if not p then
error("aws.sts: profile '" .. name .. "' not found in ~/.aws/config or ~/.aws/credentials")
end
if p.aws_access_key_id and p.aws_secret_access_key and not p.role_arn then
return {
access_key = p.aws_access_key_id,
secret_key = p.aws_secret_access_key,
session_token = p.aws_session_token,
}
end
if p.role_arn then
local call_opts = {
region = (opts and opts.region) or p.region,
sts_endpoint = opts and opts.sts_endpoint,
session_name = "assay-" .. name,
}
if p.web_identity_token_file then
local token = fs.read(p.web_identity_token_file)
return M.assume_role_with_web_identity(p.role_arn, token, call_opts)
end
local base
if p.aws_access_key_id and p.aws_secret_access_key then
base = {
access_key = p.aws_access_key_id,
secret_key = p.aws_secret_access_key,
session_token = p.aws_session_token,
}
elseif p.source_profile then
base = resolve_profile(p.source_profile, opts, depth + 1)
else
base = M.credentials({
region = call_opts.region,
sts_endpoint = call_opts.sts_endpoint,
no_profile = true,
})
end
call_opts.credentials = base
return M.assume_role(p.role_arn, call_opts)
end
error("aws.sts: profile '" .. name .. "' has neither static keys nor a role_arn")
end
function M.credentials(opts)
opts = opts or {}
local base
if opts.access_key and opts.secret_key then
base = {
access_key = opts.access_key,
secret_key = opts.secret_key,
session_token = opts.session_token,
}
else
local profile = not opts.no_profile and (opts.profile or env.get("AWS_PROFILE")) or nil
if profile then
local key = "profile:" .. profile
base = cache_get(key)
if not base then
base = resolve_profile(profile, opts, 1)
_cache[key] = base
end
elseif env.get("AWS_ACCESS_KEY_ID") and env.get("AWS_SECRET_ACCESS_KEY") then
base = {
access_key = env.get("AWS_ACCESS_KEY_ID"),
secret_key = env.get("AWS_SECRET_ACCESS_KEY"),
session_token = env.get("AWS_SESSION_TOKEN"),
}
elseif env.get("AWS_ROLE_ARN") and env.get("AWS_WEB_IDENTITY_TOKEN_FILE") then
local role = env.get("AWS_ROLE_ARN")
local key = "irsa:" .. role
base = cache_get(key)
if not base then
local token = fs.read(env.get("AWS_WEB_IDENTITY_TOKEN_FILE"))
base = M.assume_role_with_web_identity(role, token, opts)
_cache[key] = base
end
else
error(
"aws.sts: no credentials found — pass access_key/secret_key or profile, "
.. "or set AWS_PROFILE / AWS_ACCESS_KEY_ID / AWS_ROLE_ARN+AWS_WEB_IDENTITY_TOKEN_FILE"
)
end
end
if opts.role_arn then
local key = "role:" .. opts.role_arn .. "|" .. (base.access_key or "")
local assumed = cache_get(key)
if not assumed then
assumed = M.assume_role(opts.role_arn, {
credentials = base,
region = opts.region,
sts_endpoint = opts.sts_endpoint,
})
_cache[key] = assumed
end
return assumed
end
return base
end
return M