Skip to main content

alibi_plugins/api_key/
endpoint.rs

1use super::{
2    ApiKeyCallbackContext, ApiKeyErrorCode, ApiKeyErrorMessage, ApiKeyPlugin, ApiKeyReferences,
3    ApiKeyValidationError, ApiKeyVerificationError, CreateKeyRequest, CreateKeyResponse,
4    DeleteExpiredApiKeysResponse, UpdateKeyRequest, VerifyApiKey,
5};
6use crate::endpoint::definition;
7use alibi_core::endpoint::{
8    BeforeEndpointAction, EndpointCall, EndpointContextPatch, EndpointDefinition, EndpointHook,
9    EndpointInput, EndpointResponse, ServerEndpoint,
10};
11use alibi_core::session::SessionRequest;
12use alibi_core::utils::json::JsValue;
13use alibi_core::wire::ApiKeyView;
14use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthUser, HttpMethod};
15use serde::{Deserialize, Serialize};
16
17/// Input to the registered server-only verification operation.
18#[serde_with::skip_serializing_none]
19#[derive(Debug, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct ApiKeyVerificationInput {
22    pub key: String,
23    pub config_id: Option<String>,
24    pub permissions: Option<super::ApiKeyPermissions>,
25}
26
27#[derive(Debug, Deserialize)]
28pub struct ApiKeyVerificationOutput {
29    pub valid: bool,
30    pub error: Option<JsValue>,
31    pub key: Option<ApiKeyView>,
32}
33
34pub(super) fn definitions() -> Vec<EndpointDefinition> {
35    vec![
36        definition("verifyApiKey", "verifyApiKey", None, HttpMethod::Post),
37        definition(
38            "deleteAllExpiredApiKeys",
39            "deleteAllExpiredApiKeys",
40            None,
41            HttpMethod::Post,
42        ),
43        definition(
44            "createApiKey",
45            "createApiKey",
46            Some("/api-key/create"),
47            HttpMethod::Post,
48        ),
49        definition(
50            "updateApiKey",
51            "updateApiKey",
52            Some("/api-key/update"),
53            HttpMethod::Post,
54        ),
55    ]
56}
57
58pub(super) fn validate(call: &EndpointCall) -> AuthResult<EndpointInput> {
59    let body = match call.operation_id() {
60        "verifyApiKey" | "createApiKey" | "updateApiKey" => {
61            Some(validate_key_body(call.body(), call.operation_id())?)
62        }
63        _ => call.body().cloned(),
64    };
65    Ok(EndpointInput {
66        body,
67        query: call.query().cloned(),
68    })
69}
70
71#[derive(Clone, Copy)]
72enum KeyFieldKind {
73    String,
74    Prefix,
75    CoercedId,
76    Boolean,
77    Number(Option<f64>),
78    Permissions,
79    Any,
80}
81
82fn key_fields(operation: &str) -> &'static [(&'static str, KeyFieldKind, bool, bool)] {
83    use KeyFieldKind::{Any, Boolean, CoercedId, Number, Permissions, Prefix, String};
84    match operation {
85        "verifyApiKey" => &[
86            ("configId", String, false, false),
87            ("key", String, true, false),
88            ("permissions", Permissions, false, false),
89        ],
90        "createApiKey" => &[
91            ("configId", String, false, false),
92            ("name", String, false, false),
93            ("expiresIn", Number(Some(1.0)), false, true),
94            ("prefix", Prefix, false, false),
95            ("remaining", Number(Some(0.0)), false, true),
96            ("metadata", Any, false, true),
97            ("refillAmount", Number(Some(1.0)), false, false),
98            ("refillInterval", Number(None), false, false),
99            ("rateLimitTimeWindow", Number(None), false, false),
100            ("rateLimitMax", Number(None), false, false),
101            ("rateLimitEnabled", Boolean, false, false),
102            ("permissions", Permissions, false, false),
103            ("userId", CoercedId, false, true),
104            ("organizationId", CoercedId, false, true),
105        ],
106        _ => &[
107            ("configId", String, false, false),
108            ("keyId", String, true, false),
109            ("userId", CoercedId, false, true),
110            ("name", String, false, false),
111            ("enabled", Boolean, false, false),
112            ("remaining", Number(Some(1.0)), false, false),
113            ("refillAmount", Number(None), false, false),
114            ("refillInterval", Number(None), false, false),
115            ("metadata", Any, false, true),
116            ("expiresIn", Number(Some(1.0)), false, true),
117            ("rateLimitEnabled", Boolean, false, false),
118            ("rateLimitTimeWindow", Number(None), false, false),
119            ("rateLimitMax", Number(None), false, false),
120            ("permissions", Permissions, false, true),
121        ],
122    }
123}
124
125// Registered schemas validate after accumulated before-hook patches. Preserve
126// Source's schema order and all nested issues before invoking the typed core.
127fn validate_key_body(body: Option<&JsValue>, operation: &str) -> AuthResult<JsValue> {
128    use KeyFieldKind::{Any, Boolean, CoercedId, Number, Permissions, Prefix, String};
129    let fields = key_fields(operation);
130    let object = body.and_then(JsValue::as_object).ok_or_else(|| {
131        crate::endpoint::validation(format!(
132            "[body] Invalid input: expected object, received {}",
133            crate::authentication_helpers::json_type(body)
134        ))
135    })?;
136    let mut issues = Vec::new();
137    let mut output = indexmap::IndexMap::new();
138    for &(name, kind, required, nullable) in fields {
139        let value = object.get(name);
140        if value.is_none() && !required {
141            if operation == "createApiKey" && matches!(name, "expiresIn" | "remaining") {
142                _ = output.insert(name.to_owned(), JsValue::Null);
143            }
144            continue;
145        }
146        let path = format!("body.{name}");
147        if nullable && value.is_some_and(JsValue::is_null) && !matches!(kind, CoercedId) {
148            _ = output.insert(name.to_owned(), JsValue::Null);
149            continue;
150        }
151        if matches!(kind, CoercedId) {
152            if let Some(value) = value {
153                match value.coerce_string() {
154                    Ok(value) => {
155                        _ = output.insert(name.to_owned(), JsValue::String(value));
156                    }
157                    Err(_) => issues.push(format!(
158                        "[{path}] Invalid input: expected string, received {}",
159                        crate::authentication_helpers::json_type(Some(value))
160                    )),
161                }
162            }
163            continue;
164        }
165        let expected = match kind {
166            String | Prefix | CoercedId => "string",
167            Boolean => "boolean",
168            Number(_) => "number",
169            Permissions => "record",
170            Any => "any",
171        };
172        let valid = value.is_some_and(|value| match kind {
173            String | Prefix => value.is_string(),
174            Boolean => value.is_boolean(),
175            Number(_) => value.as_f64().is_some_and(f64::is_finite),
176            Permissions => value.is_object(),
177            Any => true,
178            CoercedId => false,
179        });
180        if !valid {
181            issues.push(format!(
182                "[{path}] Invalid input: expected {expected}, received {}",
183                key_field_type(value)
184            ));
185            continue;
186        }
187        let value = value.ok_or_else(|| crate::endpoint::validation("Missing validated input"))?;
188        match kind {
189            Number(Some(minimum)) if value.as_f64().is_some_and(|number|number<minimum)=>issues.push(format!("[{path}] Too small: expected number to be >={minimum}")),
190            Prefix if value.as_str().is_some_and(|prefix|prefix.is_empty()||!prefix.bytes().all(|byte|byte.is_ascii_alphanumeric()||matches!(byte,b'_'|b'-')))=>issues.push(format!("[{path}] Invalid prefix format, must be alphanumeric and contain only underscores and hyphens.")),
191            Permissions => validate_permissions(value,&path,&mut issues),
192            _ => {}
193        }
194        _ = output.insert(name.to_owned(), value.clone());
195    }
196    if issues.is_empty() {
197        Ok(JsValue::Object(output))
198    } else {
199        Err(crate::endpoint::validation(issues.join("; ")))
200    }
201}
202
203fn validate_permissions(value: &JsValue, path: &str, issues: &mut Vec<String>) {
204    if let Some(resources) = value.as_object() {
205        for (resource, actions) in resources {
206            let path = format!("{path}.{resource}");
207            if let Some(actions) = actions.as_array() {
208                for (index, action) in actions.iter().enumerate() {
209                    if !action.is_string() {
210                        issues.push(format!(
211                            "[{path}.{index}] Invalid input: expected string, received {}",
212                            key_field_type(Some(action))
213                        ));
214                    }
215                }
216            } else {
217                issues.push(format!(
218                    "[{path}] Invalid input: expected array, received {}",
219                    key_field_type(Some(actions))
220                ));
221            }
222        }
223    }
224}
225
226fn key_field_type(value: Option<&JsValue>) -> &'static str {
227    if value.and_then(JsValue::as_f64).is_some_and(f64::is_nan) {
228        "NaN"
229    } else {
230        crate::authentication_helpers::json_type(value)
231    }
232}
233
234impl ApiKeyPlugin {
235    /// Verify and consume usage through the installed logical endpoint pipeline.
236    /// # Errors
237    /// Returns an error if input serialization fails.
238    pub fn verify_endpoint(
239        input: &ApiKeyVerificationInput,
240    ) -> AuthResult<ServerEndpoint<ApiKeyVerificationOutput>> {
241        ServerEndpoint::new("api-key", "verifyApiKey").with_body(input)
242    }
243
244    #[must_use]
245    pub const fn delete_all_expired_endpoint() -> ServerEndpoint<DeleteExpiredApiKeysResponse> {
246        ServerEndpoint::new("api-key", "deleteAllExpiredApiKeys")
247    }
248
249    /// Create a key through installed hooks and actual server/client context policy.
250    /// # Errors
251    /// Returns an error if input serialization fails.
252    pub fn create_endpoint(
253        body: &CreateKeyRequest,
254    ) -> AuthResult<ServerEndpoint<CreateKeyResponse>> {
255        ServerEndpoint::new("api-key", "createApiKey").with_body(body)
256    }
257
258    /// Update through installed hooks and owner checks after patched-body validation.
259    /// # Errors
260    /// Returns an error if input serialization fails.
261    pub fn update_endpoint(body: &UpdateKeyRequest) -> AuthResult<ServerEndpoint<ApiKeyView>> {
262        ServerEndpoint::new("api-key", "updateApiKey").with_body(body)
263    }
264
265    pub(super) async fn call_endpoint<S: AuthSchema>(
266        &self,
267        call: &EndpointCall,
268        ctx: &AuthContext<S>,
269    ) -> AuthResult<EndpointResponse> {
270        match call.operation_id() {
271            "verifyApiKey" => {
272                let body: ApiKeyVerificationInput = call.body_as()?;
273                let permissions = body
274                    .permissions
275                    .as_ref()
276                    .map(serde_json::to_value)
277                    .transpose()?;
278                let input = VerifyApiKey {
279                    key: &body.key,
280                    config_id: body.config_id.as_deref(),
281                    permissions: permissions.as_ref(),
282                };
283                let value = match self
284                    .verify_api_key_with_registration(&input, call.request(), ctx)
285                    .await
286                {
287                    Ok(key) => serde_json::json!({"valid":true,"error":null,"key":key}),
288                    Err(ApiKeyVerificationError::Validation(error)) => {
289                        serde_json::json!({"valid":false,"error":error,"key":null})
290                    }
291                    Err(ApiKeyVerificationError::ExplicitValidator(error)) => return Err(error),
292                    Err(ApiKeyVerificationError::Internal(error)) => {
293                        tracing::error!(%error,"Failed to validate API key");
294                        if alibi_core::endpoint::is_endpoint_api_error(&error) {
295                            let (_, code, message) = error.error_payload();
296                            serde_json::json!({"valid":false,"error":{"code":code,"message":message},"key":null})
297                        } else {
298                            serde_json::json!({"valid":false,"error":{"code":"INVALID_API_KEY","message":{"code":"INVALID_API_KEY","message":"Invalid API Key"}},"key":null})
299                        }
300                    }
301                };
302                EndpointResponse::json(&value)
303            }
304            "deleteAllExpiredApiKeys" => {
305                EndpointResponse::json(&self.delete_all_expired_api_keys(ctx).await)
306            }
307            "createApiKey" => {
308                let body: CreateKeyRequest = call.body_as()?;
309                let config = self.resolve_configuration(body.config_id.as_deref())?;
310                let mut resolution = call.clone();
311                alibi_core::endpoint::EndpointContextPatch {
312                    query: Some(JsValue::Object(
313                        [("disableCookieCache".into(), JsValue::Bool(true))]
314                            .into_iter()
315                            .collect(),
316                    )),
317                    ..alibi_core::endpoint::EndpointContextPatch::default()
318                }
319                .apply(&mut resolution);
320                let session = ctx.require_cached_session(&resolution).await.ok();
321                if let Some((user, session)) = &session {
322                    call.record_authenticated_session(
323                        match user {
324                            alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
325                            alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
326                        },
327                        session.clone(),
328                    );
329                }
330                let is_client = call.request().is_some() || call.headers().is_some();
331                if is_client
332                    && (body.refill_amount.is_some()
333                        || body.refill_interval.is_some()
334                        || body.rate_limit_max.is_some()
335                        || body.rate_limit_time_window.is_some()
336                        || body.rate_limit_enabled.is_some()
337                        || body.permissions.is_some()
338                        || body.remaining.is_some())
339                {
340                    return Err(super::api_key_error(ApiKeyErrorCode::ServerOnlyProperty));
341                }
342                if call.request().is_some() && body.user_id.is_some() {
343                    return Err(super::api_key_error(ApiKeyErrorCode::UnauthorizedSession));
344                }
345                let user_id = if config.references == ApiKeyReferences::Organization {
346                    session
347                        .as_ref()
348                        .map(|(user, _)| user.id().into_owned())
349                        .or_else(|| body.user_id.clone())
350                } else if is_client {
351                    session.as_ref().map(|(user, _)| user.id().into_owned())
352                } else {
353                    if let Some((user, _)) = &session
354                        && body
355                            .user_id
356                            .as_ref()
357                            .is_some_and(|id| !id.is_empty() && user.id() != *id)
358                    {
359                        return Err(super::api_key_error(ApiKeyErrorCode::UnauthorizedSession));
360                    }
361                    session
362                        .as_ref()
363                        .map(|(user, _)| user.id().into_owned())
364                        .or_else(|| body.user_id.clone())
365                }
366                .filter(|id| !id.is_empty())
367                .ok_or_else(|| super::api_key_error(ApiKeyErrorCode::UnauthorizedSession))?;
368                EndpointResponse::json(
369                    &super::handlers::create_key_for_user(
370                        &body,
371                        &user_id,
372                        self,
373                        ctx,
374                        call.request(),
375                    )
376                    .await?,
377                )
378            }
379            "updateApiKey" => {
380                let body: UpdateKeyRequest = call.body_as()?;
381                let mut resolution = call.clone();
382                alibi_core::endpoint::EndpointContextPatch {
383                    query: Some(JsValue::Object(
384                        [("disableCookieCache".into(), JsValue::Bool(true))]
385                            .into_iter()
386                            .collect(),
387                    )),
388                    ..alibi_core::endpoint::EndpointContextPatch::default()
389                }
390                .apply(&mut resolution);
391                let session = ctx.require_cached_session(&resolution).await.ok();
392                if let Some((user, session)) = &session {
393                    call.record_authenticated_session(
394                        match user {
395                            alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
396                            alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
397                        },
398                        session.clone(),
399                    );
400                }
401                let is_client = call.request().is_some() || call.headers().is_some();
402                let user_id = session
403                    .as_ref()
404                    .map(|(user, _)| user.id().into_owned())
405                    .or_else(|| (!is_client).then(|| body.user_id.clone()).flatten())
406                    .filter(|id| !id.is_empty())
407                    .ok_or_else(|| super::api_key_error(ApiKeyErrorCode::UnauthorizedSession))?;
408                if body
409                    .user_id
410                    .as_ref()
411                    .is_some_and(|id| !id.is_empty() && *id != user_id)
412                {
413                    return Err(super::api_key_error(ApiKeyErrorCode::UnauthorizedSession));
414                }
415                if is_client {
416                    EndpointResponse::json(
417                        &super::handlers::update_key_core(&body, &user_id, self, ctx).await?,
418                    )
419                } else {
420                    EndpointResponse::json(
421                        &super::handlers::update_key_for_user(&body, &user_id, self, ctx).await?,
422                    )
423                }
424            }
425            _ => Err(AuthError::not_found("Unregistered API key operation")),
426        }
427    }
428}
429
430#[async_trait::async_trait]
431impl<S: AuthSchema> EndpointHook<S> for ApiKeyPlugin {
432    fn matches_before(&self, call: &EndpointCall, ctx: &AuthContext<S>) -> AuthResult<bool> {
433        Ok(self
434            .find_session_key_for_input(call.session_headers(), call.request(), Some(call), ctx)?
435            .is_some())
436    }
437
438    async fn before(
439        &self,
440        call: &EndpointCall,
441        ctx: &AuthContext<S>,
442    ) -> AuthResult<Option<BeforeEndpointAction>> {
443        let (config, key) = self
444            .find_session_key_for_input(call.session_headers(), call.request(), Some(call), ctx)?
445            .ok_or_else(|| {
446                AuthError::internal("API key getter did not return a key after matching")
447            })?;
448        if f64::from(
449            u32::try_from(key.encode_utf16().count())
450                .map_err(|error| AuthError::internal(error.to_string()))?,
451        ) < config.key_length
452        {
453            return Ok(Some(BeforeEndpointAction::Reject(rejection(
454                &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
455                Some(403),
456            )?)));
457        }
458        if let Some(validator) = &config.custom_api_key_validator
459            && !validator
460                .validate(
461                    &ApiKeyCallbackContext::new(call.request(), ctx, &config.config_id)
462                        .with_endpoint(call),
463                    &key,
464                )
465                .await?
466        {
467            return Ok(Some(BeforeEndpointAction::Reject(rejection(
468                &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
469                Some(403),
470            )?)));
471        }
472        let input = VerifyApiKey {
473            key: &key,
474            config_id: Some(&config.config_id),
475            permissions: None,
476        };
477        let view = match self
478            .verify_api_key_checked(&input, call.request(), ctx, false)
479            .await
480        {
481            Ok(view) => view,
482            Err(ApiKeyVerificationError::Validation(error)) => {
483                return Ok(Some(BeforeEndpointAction::Reject(rejection(&error, None)?)));
484            }
485            Err(
486                ApiKeyVerificationError::Internal(error)
487                | ApiKeyVerificationError::ExplicitValidator(error),
488            ) => return Err(error),
489        };
490        if config.defer_updates {
491            self.register_expired_cleanup(ctx).await?;
492        } else if let Err(error) = self.maybe_delete_expired(ctx).await {
493            tracing::error!(%error,"Failed to delete expired API keys");
494        }
495        if config.references != ApiKeyReferences::User {
496            return Ok(Some(BeforeEndpointAction::Reject(rejection(
497                &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidReferenceIdFromApiKey),
498                None,
499            )?)));
500        }
501        let Some(user) = ctx.database.get_user_by_id(&view.reference_id).await? else {
502            return Ok(Some(BeforeEndpointAction::Reject(rejection(
503                &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidReferenceIdFromApiKey),
504                None,
505            )?)));
506        };
507        let session = Self::virtual_session_from_key(&view, &key, &user, call.request(), ctx)?;
508        let user_view = ctx.user_view(&user);
509        call.establish_session::<S>(user, user_view.clone(), session.clone(), ctx);
510        if call.path() == Some("/get-session") {
511            return Ok(Some(BeforeEndpointAction::Respond(EndpointResponse::json(
512                &serde_json::json!({"user":user_view,"session":session}),
513            )?)));
514        }
515        Ok(Some(BeforeEndpointAction::Patch(Box::new(
516            EndpointContextPatch {
517                path: call.path().map(str::to_owned),
518                ..EndpointContextPatch::default()
519            },
520        ))))
521    }
522}
523
524fn rejection(error: &ApiKeyValidationError, status: Option<u16>) -> AuthResult<EndpointResponse> {
525    let status = status.unwrap_or_else(|| error.status());
526    let message = match &error.message {
527        ApiKeyErrorMessage::Text(message) | ApiKeyErrorMessage::CodeMessage { message, .. } => {
528            message.clone()
529        }
530    };
531    let body = alibi_core::utils::json::parse_value(&alibi_core::utils::json::to_string(error)?)?;
532    Ok(EndpointResponse::error(AuthError::Api {
533        status,
534        code: Some(error.code.as_str().into()),
535        message,
536    })
537    .with_error_body(body))
538}