Skip to main content

alibi_plugins/oauth_provider/
discovery.rs

1//! Application-owned client identity resolution for external registries.
2use alibi_core::store::OAuthClient;
3use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, CallbackContext};
4use async_trait::async_trait;
5use std::sync::Arc;
6/// A stable external source of OAuth clients, matching a contributed TS `ClientDiscovery`.
7#[async_trait]
8pub trait OAuthClientDiscovery: Send + Sync + std::fmt::Debug {
9    /// Stable provenance identifier. Changing this requires migrating owned client rows.
10    fn id(&self) -> &str;
11    fn matches(&self, client_id: &str) -> bool;
12    /// Additive authorization-server and `OpenID` discovery fields.
13    /// Later discoveries override earlier fields; provider-owned fields take precedence.
14    fn discovery_metadata(&self) -> serde_json::Map<String, serde_json::Value> {
15        serde_json::Map::new()
16    }
17    /// Runtime-owned fetch boundary for remote resources referenced by this registry.
18    fn metadata_resource_fetch(&self) -> Option<Arc<dyn super::OAuthClientMetadataResourceFetch>> {
19        None
20    }
21
22    /// Resolve or refresh a matching identity using the actual initialized callback context.
23    /// Returning None for an owned row fails closed rather than treating it as managed.
24    async fn resolve(
25        &self,
26        context: &CallbackContext,
27        client_id: &str,
28        existing: Option<OAuthClient>,
29    ) -> AuthResult<Option<OAuthClient>>;
30}
31#[derive(Clone)]
32pub(super) struct ClientDiscoveries(pub(super) Vec<Arc<dyn OAuthClientDiscovery>>);
33
34#[derive(Debug, Default)]
35pub(super) struct TrustedCache {
36    clients: std::sync::Mutex<std::collections::HashMap<String, OAuthClient>>,
37}
38impl TrustedCache {
39    fn get(&self, id: &str) -> AuthResult<Option<OAuthClient>> {
40        Ok(self
41            .clients
42            .lock()
43            .map_err(|_| AuthError::internal("Trusted client cache unavailable"))?
44            .get(id)
45            .cloned())
46    }
47    fn set(&self, id: &str, client: OAuthClient) -> AuthResult<()> {
48        _ = self
49            .clients
50            .lock()
51            .map_err(|_| AuthError::internal("Trusted client cache unavailable"))?
52            .insert(id.into(), client);
53        Ok(())
54    }
55}
56#[derive(Clone)]
57pub(super) struct TrustedClients {
58    pub(super) ids: std::collections::HashSet<String>,
59    pub(super) cache: Arc<TrustedCache>,
60}
61type TrustedView<'a> = (&'a std::collections::HashSet<String>, &'a TrustedCache);
62
63pub(super) async fn resolve<S: AuthSchema>(
64    ctx: &AuthContext<S>,
65    client_id: &str,
66) -> AuthResult<Option<OAuthClient>> {
67    let discoveries = ctx.extensions.get::<ClientDiscoveries>();
68    let configured = discoveries
69        .as_ref()
70        .map_or(&[][..], |sources| sources.0.as_slice());
71    let trusted = ctx.extensions.get::<TrustedClients>();
72    resolve_configured(
73        ctx,
74        client_id,
75        configured,
76        trusted
77            .as_ref()
78            .map(|state| (&state.ids, state.cache.as_ref())),
79    )
80    .await
81}
82
83impl super::OAuthProviderPlugin {
84    /// Resolve the raw client for a trusted server caller, including stored credentials.
85    /// Equivalent to `getOAuthProviderApi(...).getClient`; callers authorize access.
86    pub async fn get_client<S: AuthSchema>(
87        &self,
88        ctx: &AuthContext<S>,
89        client_id: &str,
90    ) -> AuthResult<Option<OAuthClient>> {
91        resolve_configured(
92            ctx,
93            client_id,
94            &self.config.client_discoveries,
95            Some((
96                &self.config.cached_trusted_clients,
97                self.trusted_clients.as_ref(),
98            )),
99        )
100        .await
101    }
102}
103
104async fn resolve_configured<S: AuthSchema>(
105    ctx: &AuthContext<S>,
106    client_id: &str,
107    configured: &[Arc<dyn OAuthClientDiscovery>],
108    trusted: Option<TrustedView<'_>>,
109) -> AuthResult<Option<OAuthClient>> {
110    if let Some((_, cache)) = trusted
111        && let Some(client) = cache.get(client_id)?
112        && client
113            .metadata
114            .get("client_discovery_id")
115            .and_then(serde_json::Value::as_str)
116            .is_none_or(str::is_empty)
117    {
118        return Ok(Some(client));
119    }
120    let existing = ctx.database.get_oauth_client(client_id).await?;
121    if let Some(owner) = existing
122        .as_ref()
123        .and_then(|client| client.metadata.get("client_discovery_id"))
124        .and_then(serde_json::Value::as_str)
125        .filter(|owner| !owner.is_empty())
126    {
127        let Some(discovery) = configured.iter().find(|discovery| discovery.id() == owner) else {
128            return Ok(None);
129        };
130        if !discovery.matches(client_id) {
131            return Ok(None);
132        }
133        return discovery
134            .resolve(&CallbackContext::new(ctx, None), client_id, existing)
135            .await;
136    }
137    if let Some(client) = existing {
138        if let Some((ids, cache)) = trusted
139            && ids.contains(client_id)
140        {
141            cache.set(client_id, client.clone())?;
142        }
143        return Ok(Some(client));
144    }
145    for discovery in configured {
146        if discovery.matches(client_id) {
147            let resolved = discovery
148                .resolve(&CallbackContext::new(ctx, None), client_id, None)
149                .await?;
150            if resolved.is_some() {
151                return Ok(resolved);
152            }
153        }
154    }
155    Ok(None)
156}