use anyhow::{bail, Context, Result};
const REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
const REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
pub fn available() -> bool {
non_empty(REQUEST_URL).is_some() && non_empty(REQUEST_TOKEN).is_some()
}
pub fn inside_github_actions() -> bool {
non_empty("GITHUB_ACTIONS").is_some()
}
fn non_empty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn audience(base: &str) -> String {
non_empty("PORTAKI_REGISTRY_OIDC_AUDIENCE")
.unwrap_or_else(|| format!("{}/registry", base.trim_end_matches('/')))
}
pub async fn request_token(audience: &str) -> Result<String> {
let url = non_empty(REQUEST_URL).context("ACTIONS_ID_TOKEN_REQUEST_URL absent")?;
let bearer = non_empty(REQUEST_TOKEN).context("ACTIONS_ID_TOKEN_REQUEST_TOKEN absent")?;
let response = crate::http::client()
.get(url)
.query(&[("audience", audience)])
.header("Authorization", format!("bearer {bearer}"))
.send()
.await
.context("demander un jeton OIDC à GitHub Actions")?;
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
if !(200..300).contains(&status) {
bail!("GitHub a refusé d'émettre un jeton OIDC ({status}) : {body}");
}
let parsed: serde_json::Value =
serde_json::from_str(&body).context("réponse OIDC illisible")?;
match parsed.get("value").and_then(serde_json::Value::as_str) {
Some(token) if !token.is_empty() => Ok(token.to_string()),
_ => bail!("la réponse OIDC de GitHub ne porte pas de jeton"),
}
}
pub async fn exchange(
base: &str,
module_id: &str,
channel: &str,
oidc_token: &str,
) -> Result<String> {
let response = crate::http::client()
.post(format!(
"{}/registry/v1/publications/token",
base.trim_end_matches('/')
))
.bearer_auth(oidc_token)
.json(&serde_json::json!({ "moduleId": module_id, "channel": channel }))
.send()
.await
.context("échanger le jeton OIDC contre un credential de publication")?;
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
if !(200..300).contains(&status) {
return Err(Refused::from_response(status, &body).into());
}
let parsed: serde_json::Value =
serde_json::from_str(&body).context("réponse d'échange illisible")?;
match parsed.get("token").and_then(serde_json::Value::as_str) {
Some(token) if !token.is_empty() => Ok(token.to_string()),
_ => bail!("le registre n'a pas rendu de credential"),
}
}
#[derive(Debug)]
pub struct Refused {
pub status: u16,
pub code: String,
pub link_url: Option<String>,
text: String,
}
impl Refused {
pub(crate) fn from_response(status: u16, body: &str) -> Self {
let parsed = serde_json::from_str::<serde_json::Value>(body).ok();
let field = |name: &str| {
parsed
.as_ref()
.and_then(|parsed| parsed.get(name)?.as_str().map(str::to_string))
};
Self {
status,
code: field("code").unwrap_or_default(),
link_url: field("linkUrl"),
text: refusal(status, body),
}
}
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
impl std::error::Error for Refused {}
fn refusal(status: u16, body: &str) -> String {
let parsed: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::Value::Null);
let code = parsed
.get("code")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let message = parsed
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or(body);
let hint = match code {
"module_not_linked" => {
"lie ce module à ce dépôt depuis le dashboard avant de publier depuis la CI"
}
"repository_not_linked" => "ce module est lié à un autre dépôt",
"workflow_not_allowed" => {
"la liaison attend un autre fichier de workflow — corrige-la ou publie depuis celui déclaré"
}
"environment_required" => {
"le canal stable exige l'environment déclaré dans la liaison : ajoute `environment:` au job"
}
"event_not_allowed" => "cet événement déclencheur n'est pas autorisé par la liaison",
"runner_not_allowed" => "la liaison exige un runner hébergé par GitHub",
"token_replayed" => "ce jeton OIDC a déjà été échangé — demandes-en un nouveau",
"github_oidc_required" => {
"le registre n'a pas reconnu le jeton : vérifie l'audience (PORTAKI_REGISTRY_OIDC_AUDIENCE)"
}
_ => "",
};
if hint.is_empty() {
format!("le registre a refusé l'échange ({status} {code}) : {message}")
} else {
format!("le registre a refusé l'échange ({status} {code}) : {message} — {hint}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_not_linked_refusal_carries_the_page_the_registry_gives() {
let refused = Refused::from_response(
403,
r#"{"code":"module_not_linked","message":"x","linkUrl":"https://developer.portaki.app/nuki/repository"}"#,
);
assert_eq!(
refused.link_url.as_deref(),
Some("https://developer.portaki.app/nuki/repository")
);
}
#[test]
fn the_audience_defaults_to_the_registry_of_the_target_platform() {
assert_eq!(
audience("https://api.portaki.app/"),
"https://api.portaki.app/registry"
);
}
#[test]
fn each_refusal_says_what_to_fix() {
let refused = refusal(403, r#"{"code":"environment_required","message":"aucun"}"#);
assert!(refused.contains("environment_required"));
assert!(refused.contains("environment:"));
}
#[test]
fn a_refusal_keeps_its_code_for_the_caller() {
let refused = Refused::from_response(403, r#"{"code":"module_not_linked","message":"x"}"#);
assert_eq!(refused.code, "module_not_linked");
assert_eq!(refused.link_url, None);
assert!(refused.to_string().contains("module_not_linked"));
}
#[test]
fn an_unknown_refusal_still_carries_the_body() {
let refused = refusal(500, "<html>oops</html>");
assert!(refused.contains("oops"));
}
}