Skip to main content

systemprompt_loader/bundle/source/oci/
auth.rs

1//! Bearer-challenge handling for OCI registries.
2//!
3//! Only the standard `WWW-Authenticate: Bearer realm=…,service=…,scope=…`
4//! flow is implemented. Registry-specific login endpoints are out of scope:
5//! an unrecognised challenge is a fetch failure, not a silent downgrade to an
6//! anonymous request.
7//!
8//! The credential itself never appears in an error or a log field. A secret
9//! containing `:` is treated as `user:token` and sent as HTTP Basic, matching
10//! how registries issue robot accounts; anything else is sent as a bearer
11//! token.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use base64::Engine;
17use serde::Deserialize;
18
19use crate::bundle::error::{BundleError, BundleResult};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct BearerChallenge {
23    pub realm: String,
24    pub service: Option<String>,
25    pub scope: Option<String>,
26}
27
28#[derive(Debug, Deserialize)]
29struct TokenResponse {
30    #[serde(default)]
31    token: Option<String>,
32
33    #[serde(default)]
34    access_token: Option<String>,
35}
36
37#[must_use]
38pub fn parse_challenge(header: &str) -> Option<BearerChallenge> {
39    let rest = header
40        .strip_prefix("Bearer ")
41        .or_else(|| header.strip_prefix("bearer "))?;
42    let mut realm = None;
43    let mut service = None;
44    let mut scope = None;
45
46    for part in rest.split(',') {
47        let (key, value) = part.trim().split_once('=')?;
48        let value = value.trim().trim_matches('"').to_owned();
49        match key.trim() {
50            "realm" => realm = Some(value),
51            "service" => service = Some(value),
52            "scope" => scope = Some(value),
53            _ => {},
54        }
55    }
56    realm.map(|realm| BearerChallenge {
57        realm,
58        service,
59        scope,
60    })
61}
62
63pub fn apply_credential(
64    builder: reqwest::RequestBuilder,
65    secret: Option<&str>,
66) -> reqwest::RequestBuilder {
67    match secret {
68        None => builder,
69        Some(raw) => match raw.split_once(':') {
70            Some((user, password)) => {
71                let encoded =
72                    base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
73                builder.header(reqwest::header::AUTHORIZATION, format!("Basic {encoded}"))
74            },
75            None => builder.bearer_auth(raw),
76        },
77    }
78}
79
80pub async fn fetch_token(
81    client: &reqwest::Client,
82    challenge: &BearerChallenge,
83    secret: Option<&str>,
84    source_name: &str,
85) -> BundleResult<String> {
86    let mut request = client.get(&challenge.realm);
87    if let Some(service) = challenge.service.as_ref() {
88        request = request.query(&[("service", service)]);
89    }
90    if let Some(scope) = challenge.scope.as_ref() {
91        request = request.query(&[("scope", scope)]);
92    }
93    request = apply_credential(request, secret);
94
95    let response = request
96        .send()
97        .await
98        .map_err(|e| BundleError::fetch(source_name, e))?;
99    if !response.status().is_success() {
100        return Err(BundleError::Auth {
101            source_name: source_name.to_owned(),
102        });
103    }
104
105    let body: TokenResponse = response
106        .json()
107        .await
108        .map_err(|e| BundleError::fetch(source_name, format!("token response: {e}")))?;
109    body.token
110        .or(body.access_token)
111        .ok_or_else(|| BundleError::Auth {
112            source_name: source_name.to_owned(),
113        })
114}