//// Credential-process provider. Runs an external command and reads JSON
//// credentials from its standard output, per the AWS CLI's
//// `credential_process` spec.
////
//// Expected output:
//// {
//// "Version": 1,
//// "AccessKeyId": "...",
//// "SecretAccessKey": "...",
//// "SessionToken": "...", // optional
//// "Expiration": "ISO 8601 Z" // optional; absent = non-expiring
//// }
import aws/internal/datetime
import gleam/bit_array
import gleam/dynamic/decode
import gleam/int
import gleam/json
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
pub type Runner =
fn(String, List(String)) -> Result(#(Int, BitArray), Nil)
pub type ProcessCredentials {
ProcessCredentials(
access_key_id: String,
secret_access_key: String,
session_token: Option(String),
expires_at: Option(Int),
)
}
pub type Error {
/// We could not even start the configured command.
LaunchFailed(reason: String)
/// The command ran but produced an unexpected exit code or output.
BadOutput(reason: String)
}
/// Run the given command line and decode its stdout as credential-process
/// JSON. `command` is the verbatim string from `credential_process`; we
/// split it on whitespace into program + arguments before launch.
pub fn fetch(
runner: Runner,
command: String,
) -> Result(ProcessCredentials, Error) {
use #(program, args) <- result.try(
split_command(command)
|> result.replace_error(LaunchFailed(
reason: "empty credential_process command",
)),
)
use #(exit, stdout) <- result.try(
runner(program, args)
|> result.replace_error(LaunchFailed(
reason: "could not run '" <> program <> "'",
)),
)
case exit {
0 -> decode_credentials(stdout)
code ->
Error(BadOutput(
reason: "credential_process exited with status " <> int.to_string(code),
))
}
}
fn split_command(command: String) -> Result(#(String, List(String)), Nil) {
let parts =
command
|> string.split(" ")
|> list_filter(fn(s) { s != "" })
case parts {
[] -> Error(Nil)
[program, ..args] -> Ok(#(program, args))
}
}
fn list_filter(xs: List(a), keep: fn(a) -> Bool) -> List(a) {
do_filter(xs, keep, [])
}
fn do_filter(xs: List(a), keep: fn(a) -> Bool, acc: List(a)) -> List(a) {
case xs {
[] -> list_reverse(acc)
[h, ..t] ->
case keep(h) {
True -> do_filter(t, keep, [h, ..acc])
False -> do_filter(t, keep, acc)
}
}
}
fn list_reverse(xs: List(a)) -> List(a) {
do_reverse(xs, [])
}
fn do_reverse(xs: List(a), acc: List(a)) -> List(a) {
case xs {
[] -> acc
[h, ..t] -> do_reverse(t, [h, ..acc])
}
}
// ---- output decoding ----
type RawOutput {
RawOutput(
version: Int,
access_key_id: String,
secret_access_key: String,
session_token: Option(String),
expiration: Option(String),
)
}
fn raw_decoder() -> decode.Decoder(RawOutput) {
use version <- decode.field("Version", decode.int)
use access_key_id <- decode.field("AccessKeyId", decode.string)
use secret_access_key <- decode.field("SecretAccessKey", decode.string)
use session_token <- decode.then(decode.optionally_at(
["SessionToken"],
None,
decode.map(decode.string, Some),
))
use expiration <- decode.then(decode.optionally_at(
["Expiration"],
None,
decode.map(decode.string, Some),
))
decode.success(RawOutput(
version: version,
access_key_id: access_key_id,
secret_access_key: secret_access_key,
session_token: session_token,
expiration: expiration,
))
}
fn decode_credentials(stdout: BitArray) -> Result(ProcessCredentials, Error) {
use text <- result.try(
bit_array.to_string(stdout)
|> result.replace_error(BadOutput(reason: "non-utf8 stdout")),
)
use raw <- result.try(
json.parse(text, raw_decoder())
|> result.map_error(fn(_) {
BadOutput(reason: "stdout is not the expected credential_process JSON")
}),
)
// AWS only ships Version 1 today; reject anything else loudly so a future
// Version 2 doesn't get silently mis-parsed against the wrong schema.
case raw.version {
1 -> {
let expires_at = case raw.expiration {
Some(ts) ->
case datetime.parse_iso8601(ts) {
Ok(t) -> Some(t)
Error(_) -> None
}
None -> None
}
Ok(ProcessCredentials(
access_key_id: raw.access_key_id,
secret_access_key: raw.secret_access_key,
session_token: raw.session_token,
expires_at: expires_at,
))
}
other ->
Error(BadOutput(
reason: "unsupported credential_process Version "
<> int.to_string(other),
))
}
}