use anyhow::{anyhow, bail, Context, Result};
use base64::engine::{general_purpose::STANDARD, Engine};
use oci_spec::distribution::ErrorResponse;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, io, path::*};
use url::Url;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StoredAuth {
auths: HashMap<String, Auth>,
}
impl StoredAuth {
pub fn load() -> Result<Self> {
Self::from_path(&auth_path()?)
}
pub fn load_all() -> Result<Self> {
let mut auth = None;
for path in [docker_auth_path(), podman_auth_path(), auth_path()]
.into_iter()
.filter_map(|x| x.ok())
{
if let Ok(new) = Self::from_path(&path) {
log::info!("Loaded auth info from: {}", path.display());
auth.get_or_insert_with(Self::default).append(new);
}
}
auth.context("No valid auth info found")
}
pub fn add(&mut self, domain: &str, username: &str, password: &str) {
self.auths
.insert(domain.to_string(), Auth::new(username, password));
}
#[deprecated(note = "Use `add` instead")]
pub fn insert(&mut self, domain: &str, octet: String) {
self.auths.insert(domain.to_string(), Auth { auth: octet });
}
pub fn save(&self) -> Result<()> {
let path = auth_path().context("No valid runtime directory")?;
let parent = path.parent().unwrap();
if !parent.exists() {
log::info!("Creating directory: {}", parent.display());
fs::create_dir_all(parent)?;
}
log::info!("Saving auth info to: {}", path.display());
let f = fs::File::create(&path)?;
serde_json::to_writer_pretty(f, self)?;
Ok(())
}
pub fn get_token(&self, url: &url::Url) -> Result<Option<String>> {
let test_url = url.join("/v2/").unwrap();
let challenge = match ureq::get(test_url.as_str()).call() {
Ok(_) => return Ok(None),
Err(e) => AuthChallenge::try_from(e)?,
};
self.challenge(&challenge).map(Some)
}
pub fn challenge(&self, challenge: &AuthChallenge) -> Result<String> {
let token_url = Url::parse(&challenge.url)?;
let domain = token_url
.domain()
.with_context(|| format!("www-authenticate header returns invalid URL: {token_url}"))?;
let mut req = ureq::get(token_url.as_str()).set("Accept", "application/json");
if let Some(auth) = self.auths.get(domain) {
req = req.set("Authorization", &format!("Basic {}", auth.auth))
}
req = req
.query("scope", &challenge.scope)
.query("service", &challenge.service);
let res = req.call()?;
let token = res.into_json::<Token>()?;
Ok(token.token)
}
pub fn append(&mut self, other: Self) {
for (key, value) in other.auths.into_iter() {
if value.is_valid() {
self.auths.insert(key, value);
}
}
}
pub fn from_path(path: &Path) -> Result<Self> {
if !path.is_file() {
bail!("Auth file not found: {}", path.display());
}
let f = fs::File::open(path)?;
let loaded = serde_json::from_reader(io::BufReader::new(f))?;
let mut out = Self::default();
out.append(loaded);
Ok(out)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Auth {
auth: String,
}
impl Auth {
fn new(username: &str, password: &str) -> Self {
let auth = format!("{}:{}", username, password);
let auth = STANDARD.encode(auth.as_bytes());
Self { auth }
}
fn is_valid(&self) -> bool {
let Ok(decoded) = STANDARD.decode(&self.auth) else {
return false;
};
decoded.split(|b| *b == b':').count() == 2
}
}
fn home_dir() -> Result<PathBuf> {
let dirs = directories::BaseDirs::new().context("Cannot get $HOME directory")?;
Ok(dirs.home_dir().to_path_buf())
}
fn auth_path() -> Result<PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "ocipkg")
.context("Cannot get project directory of ocipkg")?;
if let Some(runtime_dir) = dirs.runtime_dir() {
Ok(runtime_dir.join("auth.json"))
} else {
Ok(home_dir()?.join(".ocipkg/config.json"))
}
}
fn docker_auth_path() -> Result<PathBuf> {
Ok(home_dir()?.join(".docker/config.json"))
}
fn podman_auth_path() -> Result<PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "containers")
.context("Cannot get the project directory of podman")?;
Ok(dirs
.runtime_dir()
.context("Cannot get runtime directory of podman")?
.join("auth.json"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthChallenge {
pub url: String,
pub service: String,
pub scope: String,
}
impl TryFrom<ureq::Error> for AuthChallenge {
type Error = anyhow::Error;
fn try_from(res: ureq::Error) -> Result<Self> {
match res {
ureq::Error::Status(status, res) => {
if status == 401 && res.has("www-authenticate") {
Self::from_header(res.header("www-authenticate").unwrap())
} else {
let err = res.into_json::<ErrorResponse>()?;
Err(err.into())
}
}
ureq::Error::Transport(e) => Err(e.into()),
}
}
}
impl AuthChallenge {
pub fn from_header(header: &str) -> Result<Self> {
let err = || anyhow!("Unsupported WWW-Authenticate header: {}", header);
let (ty, realm) = header.split_once(' ').ok_or_else(err)?;
if ty != "Bearer" {
return Err(err());
}
let mut url = None;
let mut service = None;
let mut scope = None;
for param in realm.split(',') {
let (key, value) = param.split_once('=').ok_or_else(err)?;
let value = value.trim_matches('"').to_string();
match key {
"realm" => url = Some(value),
"service" => service = Some(value),
"scope" => scope = Some(value),
_ => continue,
}
}
Ok(Self {
url: url.ok_or_else(err)?,
service: service.ok_or_else(err)?,
scope: scope.ok_or_else(err)?,
})
}
}
#[derive(Deserialize)]
struct Token {
token: String,
}