Skip to main content

agent_core/core/auth/
azure_openai.rs

1//! Provider-local Azure OpenAI OAuth, catalog, and broker request contracts.
2//! No function in this module performs network I/O or returns credentials.
3use super::AzureOpenAiConfig;
4use serde::{Deserialize, Serialize};
5use std::{collections::HashSet, fmt};
6
7pub const ARM_API_VERSION: &str = "2024-10-01";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct AzureError {
11    code: &'static str,
12    message: String,
13}
14impl AzureError {
15    fn new(code: &'static str, message: impl Into<String>) -> Self {
16        Self {
17            code,
18            message: message.into(),
19        }
20    }
21    pub fn code(&self) -> &'static str {
22        self.code
23    }
24}
25impl fmt::Display for AzureError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "{}: {}", self.code, self.message)
28    }
29}
30impl std::error::Error for AzureError {}
31type Result<T> = std::result::Result<T, AzureError>;
32
33#[derive(Clone, PartialEq, Eq)]
34pub struct AzureRegistration {
35    client_id: String,
36}
37impl fmt::Debug for AzureRegistration {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.debug_struct("AzureRegistration")
40            .field("client_id", &"[configured]")
41            .finish()
42    }
43}
44impl AzureRegistration {
45    pub fn production(client_id: Option<String>) -> Result<Self> {
46        match client_id.filter(|v| !v.trim().is_empty()) { Some(id) => Self::validated(id), None => Err(AzureError::new("registration_required", "configure a Synaps-owned Microsoft Entra public-client application ID with device-code flow enabled; no client secret is used")) }
47    }
48    #[doc(hidden)]
49    pub fn test(client_id: &str) -> Result<Self> {
50        Self::validated(client_id.to_owned())
51    }
52    fn validated(client_id: String) -> Result<Self> {
53        if !guid(&client_id) {
54            return Err(AzureError::new(
55                "invalid_client_id",
56                "public client ID must be a UUID",
57            ));
58        }
59        Ok(Self { client_id })
60    }
61    pub fn client_id(&self) -> &str {
62        &self.client_id
63    }
64}
65fn guid(v: &str) -> bool {
66    let p: Vec<_> = v.split('-').collect();
67    p.len() == 5
68        && [8, 4, 4, 4, 12]
69            .iter()
70            .zip(p)
71            .all(|(n, s)| s.len() == *n && s.bytes().all(|b| b.is_ascii_hexdigit()))
72}
73fn segment(v: &str) -> bool {
74    !v.is_empty()
75        && v.len() <= 128
76        && v.bytes()
77            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81pub enum AzureAudience {
82    Arm,
83    Inference,
84}
85impl AzureAudience {
86    pub const fn scope(self) -> &'static str {
87        match self {
88            Self::Arm => "https://management.azure.com/.default",
89            Self::Inference => "https://cognitiveservices.azure.com/.default",
90        }
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct FormRequest {
96    pub method: &'static str,
97    pub url: String,
98    pub form: Vec<(String, String)>,
99}
100pub fn device_code_request(
101    config: &AzureOpenAiConfig,
102    registration: &AzureRegistration,
103) -> Result<FormRequest> {
104    if config.tenant == "common" || !segment(&config.tenant) {
105        return Err(AzureError::new(
106            "invalid_tenant",
107            "use a tenant UUID or organizations",
108        ));
109    }
110    Ok(FormRequest {
111        method: "POST",
112        url: format!(
113            "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
114            config.tenant
115        ),
116        form: vec![
117            ("client_id".into(), registration.client_id.clone()),
118            ("scope".into(), AzureAudience::Arm.scope().into()),
119        ],
120    })
121}
122pub fn refresh_request(
123    config: &AzureOpenAiConfig,
124    registration: &AzureRegistration,
125    audience: AzureAudience,
126    refresh_token: &str,
127) -> Result<FormRequest> {
128    let mut r = device_code_request(config, registration)?;
129    r.url = r.url.replace("/devicecode", "/token");
130    r.form = vec![
131        ("client_id".into(), registration.client_id.clone()),
132        ("grant_type".into(), "refresh_token".into()),
133        ("refresh_token".into(), refresh_token.into()),
134        ("scope".into(), audience.scope().into()),
135    ];
136    Ok(r)
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum PollReply {
141    AuthorizationPending,
142    SlowDown,
143    Denied,
144}
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum PollAction {
147    Sleep(u64),
148}
149pub struct DevicePoll {
150    interval: u64,
151    expires_at: u64,
152}
153impl DevicePoll {
154    pub fn new(interval: u64, issued_at: u64, expires_in: u64) -> Self {
155        Self {
156            interval: interval.max(1),
157            expires_at: issued_at.saturating_add(expires_in),
158        }
159    }
160    pub fn apply(&mut self, now: u64, cancelled: bool, reply: PollReply) -> Result<PollAction> {
161        if cancelled {
162            return Err(AzureError::new(
163                "cancelled",
164                "device authorization cancelled",
165            ));
166        }
167        if now >= self.expires_at {
168            return Err(AzureError::new(
169                "device_code_expired",
170                "device code expired",
171            ));
172        }
173        match reply {
174            PollReply::AuthorizationPending => Ok(PollAction::Sleep(self.interval)),
175            PollReply::SlowDown => {
176                self.interval = self.interval.saturating_add(5);
177                Ok(PollAction::Sleep(self.interval))
178            }
179            PollReply::Denied => Err(AzureError::new("access_denied", "authorization denied")),
180        }
181    }
182}
183
184#[derive(Clone)]
185pub struct TokenGrant {
186    access: String,
187    refresh: Option<String>,
188    expires_at: u64,
189}
190impl TokenGrant {
191    pub fn new(access: impl Into<String>, refresh: Option<String>, expires_at: u64) -> Self {
192        Self {
193            access: access.into(),
194            refresh,
195            expires_at,
196        }
197    }
198}
199#[derive(Clone, Serialize)]
200pub struct AzureTokenSet {
201    #[serde(skip)]
202    refresh: String,
203    #[serde(skip)]
204    arm: Option<TokenGrant>,
205    #[serde(skip)]
206    inference: Option<TokenGrant>,
207}
208impl fmt::Debug for AzureTokenSet {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        f.write_str("AzureTokenSet { credentials: [REDACTED] }")
211    }
212}
213impl AzureTokenSet {
214    pub fn new(refresh: impl Into<String>) -> Self {
215        Self {
216            refresh: refresh.into(),
217            arm: None,
218            inference: None,
219        }
220    }
221    pub fn commit(&mut self, a: AzureAudience, mut g: TokenGrant) {
222        if let Some(r) = g.refresh.take() {
223            self.refresh = r;
224        }
225        match a {
226            AzureAudience::Arm => self.arm = Some(g),
227            AzureAudience::Inference => self.inference = Some(g),
228        }
229    }
230    pub fn access(&self, a: AzureAudience, now: u64) -> Result<&str> {
231        let g = match a {
232            AzureAudience::Arm => &self.arm,
233            AzureAudience::Inference => &self.inference,
234        }
235        .as_ref()
236        .ok_or_else(|| AzureError::new("login_required", "audience token absent"))?;
237        if now >= g.expires_at {
238            return Err(AzureError::new("token_expired", "audience token expired"));
239        }
240        Ok(&g.access)
241    }
242    pub fn refresh_token(&self) -> &str {
243        &self.refresh
244    }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct AzureEndpoint(String);
249impl AzureEndpoint {
250    pub fn parse(value: &str) -> Result<Self> {
251        let u = url::Url::parse(value)
252            .map_err(|_| AzureError::new("invalid_endpoint", "invalid Azure endpoint"))?;
253        let host = u.host_str().unwrap_or_default();
254        if u.scheme() != "https"
255            || u.port().is_some()
256            || u.path() != "/"
257            || u.query().is_some()
258            || !(host.ends_with(".openai.azure.com")
259                || host.ends_with(".cognitiveservices.azure.com"))
260        {
261            return Err(AzureError::new(
262                "invalid_endpoint",
263                "endpoint must be an exact Azure OpenAI/Cognitive Services HTTPS origin",
264            ));
265        }
266        Ok(Self(format!("https://{host}")))
267    }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
271pub struct CatalogEntry {
272    pub id: String,
273    pub display_name: String,
274    pub source: &'static str,
275    pub model: Option<String>,
276    pub version: Option<String>,
277}
278#[derive(Deserialize)]
279struct Page {
280    value: Vec<Deployment>,
281    #[serde(rename = "nextLink")]
282    next_link: Option<String>,
283}
284#[derive(Deserialize)]
285struct Deployment {
286    name: String,
287    properties: Properties,
288}
289#[derive(Deserialize)]
290struct Properties {
291    model: Option<Model>,
292    #[serde(rename = "provisioningState")]
293    state: Option<String>,
294}
295#[derive(Deserialize)]
296struct Model {
297    name: String,
298    version: Option<String>,
299}
300pub struct DeploymentDiscovery {
301    config: AzureOpenAiConfig,
302    max_pages: usize,
303    max_entries: usize,
304    pages: usize,
305    seen: HashSet<String>,
306    entries: Vec<CatalogEntry>,
307}
308impl DeploymentDiscovery {
309    pub fn new(config: AzureOpenAiConfig, max_pages: usize, max_entries: usize) -> Self {
310        Self {
311            config,
312            max_pages,
313            max_entries,
314            pages: 0,
315            seen: HashSet::new(),
316            entries: vec![],
317        }
318    }
319    pub fn initial_url(&self) -> String {
320        format!("https://management.azure.com/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments?api-version={ARM_API_VERSION}",self.config.subscription_id,self.config.resource_group,self.config.resource_name)
321    }
322    pub fn accept_page(&mut self, body: &str) -> Result<Option<String>> {
323        if body.len() > 1024 * 1024 || self.pages >= self.max_pages {
324            return Err(AzureError::new(
325                "catalog_limit",
326                "ARM catalog limit exceeded",
327            ));
328        }
329        self.pages += 1;
330        let p: Page = serde_json::from_str(body).map_err(|_| {
331            AzureError::new("malformed_catalog", "malformed ARM deployments response")
332        })?;
333        for d in p.value {
334            if d.properties.state.as_deref() != Some("Succeeded") || !segment(&d.name) {
335                continue;
336            }
337            if self.seen.insert(d.name.clone()) {
338                if self.entries.len() >= self.max_entries {
339                    return Err(AzureError::new(
340                        "catalog_limit",
341                        "deployment limit exceeded",
342                    ));
343                }
344                let (m, v) = d
345                    .properties
346                    .model
347                    .map(|m| (Some(m.name), m.version))
348                    .unwrap_or_default();
349                self.entries.push(CatalogEntry {
350                    id: format!("azure-openai/{}", d.name),
351                    display_name: d.name,
352                    source: "dynamic",
353                    model: m,
354                    version: v,
355                });
356            }
357        }
358        match p.next_link {
359            Some(n) => {
360                let u = url::Url::parse(&n)
361                    .map_err(|_| AzureError::new("invalid_pagination", "invalid ARM nextLink"))?;
362                if u.scheme() != "https"
363                    || u.host_str() != Some("management.azure.com")
364                    || !u.path().starts_with("/subscriptions/")
365                {
366                    return Err(AzureError::new(
367                        "invalid_pagination",
368                        "ARM nextLink escaped fixed authority/path",
369                    ));
370                }
371                Ok(Some(n))
372            }
373            None => Ok(None),
374        }
375    }
376    pub fn finish(mut self) -> Result<Vec<CatalogEntry>> {
377        if self.entries.is_empty() {
378            return Err(AzureError::new(
379                "empty_catalog",
380                "no callable Azure OpenAI deployments found",
381            ));
382        }
383        self.entries.sort_by(|a, b| a.id.cmp(&b.id));
384        Ok(self.entries)
385    }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct RuntimeRequest {
390    pub method: &'static str,
391    pub url: String,
392    pub body: Vec<u8>,
393}
394pub fn responses_request(
395    endpoint: &AzureEndpoint,
396    deployment: &str,
397    body: &[u8],
398) -> Result<RuntimeRequest> {
399    if !segment(deployment) || body.len() > 4 * 1024 * 1024 {
400        return Err(AzureError::new(
401            "invalid_request",
402            "invalid deployment or oversized body",
403        ));
404    }
405    let mut value: serde_json::Value = serde_json::from_slice(body)
406        .map_err(|_| AzureError::new("invalid_request", "Responses body must be JSON"))?;
407    let obj = value
408        .as_object_mut()
409        .ok_or_else(|| AzureError::new("invalid_request", "Responses body must be an object"))?;
410    obj.insert("model".into(), serde_json::Value::String(deployment.into()));
411    let body = serde_json::to_vec(&value).unwrap();
412    Ok(RuntimeRequest {
413        method: "POST",
414        url: format!("{}/openai/v1/responses", endpoint.0),
415        body,
416    })
417}