Skip to main content

alibi_plugins/oauth_provider/
authentication.rs

1//! Trusted server client authentication through the shared protocol credential owner.
2use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{
4    EndpointCall, EndpointError, EndpointResponse, with_endpoint_call_context,
5};
6use alibi_core::store::OAuthClient;
7use alibi_core::utils::json::{JsValue, parse_value};
8use alibi_core::{AuthContext, AuthError, AuthSchema};
9use serde_json::{Value, json};
10
11/// Caller policy for the `authenticateClient` server capability.
12#[derive(Debug, Clone)]
13pub struct OAuthClientAuthenticationOptions {
14    pub require_credentials: bool,
15    pub scopes: Option<Vec<String>>,
16}
17impl Default for OAuthClientAuthenticationOptions {
18    fn default() -> Self {
19        Self {
20            require_credentials: true,
21            scopes: None,
22        }
23    }
24}
25/// A client authenticated for a trusted server operation.
26#[derive(Debug, Clone)]
27pub struct OAuthAuthenticatedClient {
28    pub client_id: String,
29    pub client: OAuthClient,
30    pub method: Option<String>,
31    pub confirmation: Option<Value>,
32}
33impl OAuthProviderPlugin {
34    /// Authenticate a client against this endpoint, its scopes and an optional bound grant.
35    /// Equivalent to `getOAuthProviderApi(...).authenticateClient`; assertion audiences
36    /// use the logical endpoint path. Public clients require an explicit caller opt-in.
37    pub async fn authenticate_client<S: AuthSchema>(
38        &self,
39        ctx: &AuthContext<S>,
40        call: &EndpointCall,
41        grant_type: Option<&str>,
42        options: &OAuthClientAuthenticationOptions,
43    ) -> Result<OAuthAuthenticatedClient, EndpointError> {
44        with_endpoint_call_context(
45            call.clone(),
46            self.authenticate_client_inner(ctx, call, grant_type, options),
47        )
48        .await
49    }
50    async fn authenticate_client_inner<S: AuthSchema>(
51        &self,
52        ctx: &AuthContext<S>,
53        call: &EndpointCall,
54        grant_type: Option<&str>,
55        options: &OAuthClientAuthenticationOptions,
56    ) -> Result<OAuthAuthenticatedClient, EndpointError> {
57        // Only authentication fields participate; trusted Date values remain non-strings.
58        let mut body = serde_json::Map::new();
59        if let Some(input) = call.body() {
60            for field in [
61                "client_id",
62                "client_secret",
63                "client_assertion",
64                "client_assertion_type",
65            ] {
66                if let Some(value) = input.get(field) {
67                    _ = body.insert(
68                        field.into(),
69                        match value {
70                            JsValue::String(value) => Value::String(value.clone()),
71                            _ => Value::Null,
72                        },
73                    );
74                }
75            }
76        }
77        let audience = format!(
78            "{}{}{}",
79            ctx.config.base_url.trim_end_matches('/'),
80            ctx.config.base_path,
81            call.path().unwrap_or("/oauth2/token")
82        );
83        let credentials = match super::credentials::extract_for_audience(
84            &Value::Object(body),
85            call,
86            ctx,
87            self,
88            &audience,
89        )
90        .await?
91        {
92            Ok(credentials) => credentials,
93            Err(response) => return Err(response_error(&response)?),
94        };
95        if credentials.client_id.is_empty() {
96            return Err(failure("invalid_request", "Missing required client_id"));
97        }
98        if options.require_credentials && credentials.secret.is_empty() && !credentials.pre_verified
99        {
100            return Err(failure(
101                "invalid_client",
102                "Missing required client credentials",
103            ));
104        }
105        let client = match credentials.authenticate(ctx, self).await? {
106            Ok(client) => client,
107            Err(response) => return Err(response_error(&response)?),
108        };
109        if let Some(requested) = &options.scopes {
110            let allowed = client.metadata.get("scope").and_then(|scope| {
111                scope
112                    .as_str()
113                    .map(|scope| scope.split(' ').collect::<Vec<_>>())
114                    .or_else(|| {
115                        scope
116                            .as_array()
117                            .map(|scopes| scopes.iter().filter_map(Value::as_str).collect())
118                    })
119            });
120            if let Some(allowed) = allowed {
121                for scope in requested {
122                    if !allowed.contains(&scope.as_str()) {
123                        return Err(failure(
124                            "invalid_scope",
125                            &format!("client does not allow scope {scope}"),
126                        ));
127                    }
128                }
129            }
130        }
131        if let Some(grant) = grant_type.filter(|grant| !grant.is_empty())
132            && !client_allows_grant(&client, grant)
133        {
134            return Err(failure(
135                "unauthorized_client",
136                &format!("client is not authorized to use grant type {grant}"),
137            ));
138        }
139        Ok(OAuthAuthenticatedClient {
140            client_id: credentials.client_id,
141            client,
142            method: Some(credentials.method.into()),
143            confirmation: None,
144        })
145    }
146}
147pub(super) fn client_allows_grant(client: &OAuthClient, grant: &str) -> bool {
148    let allowed = client
149        .metadata
150        .get("grant_types")
151        .and_then(Value::as_array)
152        .filter(|grants| !grants.is_empty());
153    allowed.map_or(
154        matches!(grant, "authorization_code" | "refresh_token"),
155        |grants| {
156            grants.contains(&json!(grant))
157                || (grant == "refresh_token" && grants.contains(&json!("authorization_code")))
158        },
159    )
160}
161fn failure(code: &str, description: &str) -> EndpointError {
162    EndpointError {
163        error: AuthError::Api {
164            status: 400,
165            code: None,
166            message: String::new(),
167        },
168        headers: None,
169        body: Some(Box::new(JsValue::from(
170            json!({"error":code,"error_description":description}),
171        ))),
172        body_absent: false,
173    }
174}
175pub(super) fn response_error(response: &EndpointResponse) -> Result<EndpointError, EndpointError> {
176    let response = response.to_response()?;
177    let body_absent = response.body.is_empty();
178    let body = if body_absent {
179        None
180    } else {
181        Some(Box::new(
182            parse_value(String::from_utf8_lossy(&response.body).as_ref())
183                .map_err(AuthError::from)?,
184        ))
185    };
186    Ok(EndpointError {
187        error: AuthError::Api {
188            status: response.status,
189            code: None,
190            message: String::new(),
191        },
192        headers: Some(response.headers),
193        body,
194        body_absent,
195    })
196}