Skip to main content

alibi_api/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::plugins::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::plugins::endpoint::validation(format!(
132            "[body] Invalid input: expected object, received {}",
133            crate::plugins::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                drop(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            drop(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                        drop(output.insert(name.to_owned(), JsValue::String(value)));
156                    }
157                    Err(_) => issues.push(format!(
158                        "[{path}] Invalid input: expected string, received {}",
159                        crate::plugins::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 =
188            value.ok_or_else(|| crate::plugins::endpoint::validation("Missing validated input"))?;
189        match kind {
190            Number(Some(minimum)) if value.as_f64().is_some_and(|number|number<minimum)=>issues.push(format!("[{path}] Too small: expected number to be >={minimum}")),
191            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.")),
192            Permissions => validate_permissions(value,&path,&mut issues),
193            _ => {}
194        }
195        drop(output.insert(name.to_owned(), value.clone()));
196    }
197    if issues.is_empty() {
198        Ok(JsValue::Object(output))
199    } else {
200        Err(crate::plugins::endpoint::validation(issues.join("; ")))
201    }
202}
203
204fn validate_permissions(value: &JsValue, path: &str, issues: &mut Vec<String>) {
205    if let Some(resources) = value.as_object() {
206        for (resource, actions) in resources {
207            let path = format!("{path}.{resource}");
208            if let Some(actions) = actions.as_array() {
209                for (index, action) in actions.iter().enumerate() {
210                    if !action.is_string() {
211                        issues.push(format!(
212                            "[{path}.{index}] Invalid input: expected string, received {}",
213                            key_field_type(Some(action))
214                        ));
215                    }
216                }
217            } else {
218                issues.push(format!(
219                    "[{path}] Invalid input: expected array, received {}",
220                    key_field_type(Some(actions))
221                ));
222            }
223        }
224    }
225}
226
227fn key_field_type(value: Option<&JsValue>) -> &'static str {
228    if value.and_then(JsValue::as_f64).is_some_and(f64::is_nan) {
229        "NaN"
230    } else {
231        crate::plugins::authentication_helpers::json_type(value)
232    }
233}
234
235impl ApiKeyPlugin {
236    /// Verify and consume usage through the installed logical endpoint pipeline.
237    /// # Errors
238    /// Returns an error if input serialization fails.
239    pub fn verify_endpoint(
240        input: &ApiKeyVerificationInput,
241    ) -> AuthResult<ServerEndpoint<ApiKeyVerificationOutput>> {
242        ServerEndpoint::new("api-key", "verifyApiKey").with_body(input)
243    }
244
245    #[must_use]
246    pub const fn delete_all_expired_endpoint() -> ServerEndpoint<DeleteExpiredApiKeysResponse> {
247        ServerEndpoint::new("api-key", "deleteAllExpiredApiKeys")
248    }
249
250    /// Create a key through installed hooks and actual server/client context policy.
251    /// # Errors
252    /// Returns an error if input serialization fails.
253    pub fn create_endpoint(
254        body: &CreateKeyRequest,
255    ) -> AuthResult<ServerEndpoint<CreateKeyResponse>> {
256        ServerEndpoint::new("api-key", "createApiKey").with_body(body)
257    }
258
259    /// Update through installed hooks and owner checks after patched-body validation.
260    /// # Errors
261    /// Returns an error if input serialization fails.
262    pub fn update_endpoint(body: &UpdateKeyRequest) -> AuthResult<ServerEndpoint<ApiKeyView>> {
263        ServerEndpoint::new("api-key", "updateApiKey").with_body(body)
264    }
265
266    pub(super) async fn call_endpoint<S: AuthSchema>(
267        &self,
268        call: &EndpointCall,
269        ctx: &AuthContext<S>,
270    ) -> AuthResult<EndpointResponse> {
271        match call.operation_id() {
272            "verifyApiKey" => {
273                let body: ApiKeyVerificationInput = call.body_as()?;
274                let permissions = body
275                    .permissions
276                    .as_ref()
277                    .map(serde_json::to_value)
278                    .transpose()?;
279                let input = VerifyApiKey {
280                    key: &body.key,
281                    config_id: body.config_id.as_deref(),
282                    permissions: permissions.as_ref(),
283                };
284                let value = match self
285                    .verify_api_key_with_registration(&input, call.request(), ctx)
286                    .await
287                {
288                    Ok(key) => serde_json::json!({"valid":true,"error":null,"key":key}),
289                    Err(ApiKeyVerificationError::Validation(error)) => {
290                        serde_json::json!({"valid":false,"error":error,"key":null})
291                    }
292                    Err(ApiKeyVerificationError::ExplicitValidator(error)) => return Err(error),
293                    Err(ApiKeyVerificationError::Internal(error)) => {
294                        tracing::error!(%error,"Failed to validate API key");
295                        if alibi_core::endpoint::is_endpoint_api_error(&error) {
296                            let (_, code, message) = error.error_payload();
297                            serde_json::json!({"valid":false,"error":{"code":code,"message":message},"key":null})
298                        } else {
299                            serde_json::json!({"valid":false,"error":{"code":"INVALID_API_KEY","message":{"code":"INVALID_API_KEY","message":"Invalid API Key"}},"key":null})
300                        }
301                    }
302                };
303                EndpointResponse::json(&value)
304            }
305            "deleteAllExpiredApiKeys" => {
306                EndpointResponse::json(&self.delete_all_expired_api_keys(ctx).await)
307            }
308            "createApiKey" => {
309                let body: CreateKeyRequest = call.body_as()?;
310                let config = self.resolve_configuration(body.config_id.as_deref())?;
311                let mut resolution = call.clone();
312                alibi_core::endpoint::EndpointContextPatch {
313                    query: Some(JsValue::Object(
314                        [("disableCookieCache".into(), JsValue::Bool(true))]
315                            .into_iter()
316                            .collect(),
317                    )),
318                    ..alibi_core::endpoint::EndpointContextPatch::default()
319                }
320                .apply(&mut resolution);
321                let session = ctx.require_cached_session(&resolution).await.ok();
322                if let Some((user, session)) = &session {
323                    call.record_authenticated_session(
324                        match user {
325                            alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
326                            alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
327                        },
328                        session.clone(),
329                    );
330                }
331                let is_client = call.request().is_some() || call.headers().is_some();
332                if is_client
333                    && (body.refill_amount.is_some()
334                        || body.refill_interval.is_some()
335                        || body.rate_limit_max.is_some()
336                        || body.rate_limit_time_window.is_some()
337                        || body.rate_limit_enabled.is_some()
338                        || body.permissions.is_some()
339                        || body.remaining.is_some())
340                {
341                    return Err(super::api_key_error(ApiKeyErrorCode::ServerOnlyProperty));
342                }
343                if call.request().is_some() && body.user_id.is_some() {
344                    return Err(super::api_key_error(ApiKeyErrorCode::UnauthorizedSession));
345                }
346                let user_id = if config.references == ApiKeyReferences::Organization {
347                    session
348                        .as_ref()
349                        .map(|(user, _)| user.id().into_owned())
350                        .or_else(|| body.user_id.clone())
351                } else if is_client {
352                    session.as_ref().map(|(user, _)| user.id().into_owned())
353                } else {
354                    if let Some((user, _)) = &session
355                        && body
356                            .user_id
357                            .as_ref()
358                            .is_some_and(|id| !id.is_empty() && user.id() != *id)
359                    {
360                        return Err(super::api_key_error(ApiKeyErrorCode::UnauthorizedSession));
361                    }
362                    session
363                        .as_ref()
364                        .map(|(user, _)| user.id().into_owned())
365                        .or_else(|| body.user_id.clone())
366                }
367                .filter(|id| !id.is_empty())
368                .ok_or_else(|| super::api_key_error(ApiKeyErrorCode::UnauthorizedSession))?;
369                EndpointResponse::json(
370                    &super::handlers::create_key_for_user(
371                        &body,
372                        &user_id,
373                        self,
374                        ctx,
375                        call.request(),
376                    )
377                    .await?,
378                )
379            }
380            "updateApiKey" => {
381                let body: UpdateKeyRequest = call.body_as()?;
382                let mut resolution = call.clone();
383                alibi_core::endpoint::EndpointContextPatch {
384                    query: Some(JsValue::Object(
385                        [("disableCookieCache".into(), JsValue::Bool(true))]
386                            .into_iter()
387                            .collect(),
388                    )),
389                    ..alibi_core::endpoint::EndpointContextPatch::default()
390                }
391                .apply(&mut resolution);
392                let session = ctx.require_cached_session(&resolution).await.ok();
393                if let Some((user, session)) = &session {
394                    call.record_authenticated_session(
395                        match user {
396                            alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(user),
397                            alibi_core::AuthenticatedUser::Cached(user) => (**user).clone(),
398                        },
399                        session.clone(),
400                    );
401                }
402                let is_client = call.request().is_some() || call.headers().is_some();
403                let user_id = session
404                    .as_ref()
405                    .map(|(user, _)| user.id().into_owned())
406                    .or_else(|| (!is_client).then(|| body.user_id.clone()).flatten())
407                    .filter(|id| !id.is_empty())
408                    .ok_or_else(|| super::api_key_error(ApiKeyErrorCode::UnauthorizedSession))?;
409                if body
410                    .user_id
411                    .as_ref()
412                    .is_some_and(|id| !id.is_empty() && *id != user_id)
413                {
414                    return Err(super::api_key_error(ApiKeyErrorCode::UnauthorizedSession));
415                }
416                if is_client {
417                    EndpointResponse::json(
418                        &super::handlers::update_key_core(&body, &user_id, self, ctx).await?,
419                    )
420                } else {
421                    EndpointResponse::json(
422                        &super::handlers::update_key_for_user(&body, &user_id, self, ctx).await?,
423                    )
424                }
425            }
426            _ => Err(AuthError::not_found("Unregistered API key operation")),
427        }
428    }
429}
430
431#[async_trait::async_trait]
432impl<S: AuthSchema> EndpointHook<S> for ApiKeyPlugin {
433    fn matches_before(&self, call: &EndpointCall, ctx: &AuthContext<S>) -> AuthResult<bool> {
434        Ok(self
435            .find_session_key_for_input(call.session_headers(), call.request(), Some(call), ctx)?
436            .is_some())
437    }
438
439    async fn before(
440        &self,
441        call: &EndpointCall,
442        ctx: &AuthContext<S>,
443    ) -> AuthResult<Option<BeforeEndpointAction>> {
444        let (config, key) = self
445            .find_session_key_for_input(call.session_headers(), call.request(), Some(call), ctx)?
446            .ok_or_else(|| {
447                AuthError::internal("API key getter did not return a key after matching")
448            })?;
449        if f64::from(
450            u32::try_from(key.encode_utf16().count())
451                .map_err(|error| AuthError::internal(error.to_string()))?,
452        ) < config.key_length
453        {
454            return Ok(Some(BeforeEndpointAction::Reject(rejection(
455                ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
456                Some(403),
457            )?)));
458        }
459        if let Some(validator) = &config.custom_api_key_validator
460            && !validator
461                .validate(
462                    &ApiKeyCallbackContext::new(call.request(), ctx, &config.config_id)
463                        .with_endpoint(call),
464                    &key,
465                )
466                .await?
467        {
468            return Ok(Some(BeforeEndpointAction::Reject(rejection(
469                ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
470                Some(403),
471            )?)));
472        }
473        let input = VerifyApiKey {
474            key: &key,
475            config_id: Some(&config.config_id),
476            permissions: None,
477        };
478        let view = match self
479            .verify_api_key_checked(&input, call.request(), ctx, false)
480            .await
481        {
482            Ok(view) => view,
483            Err(ApiKeyVerificationError::Validation(error)) => {
484                return Ok(Some(BeforeEndpointAction::Reject(rejection(error, None)?)));
485            }
486            Err(
487                ApiKeyVerificationError::Internal(error)
488                | ApiKeyVerificationError::ExplicitValidator(error),
489            ) => return Err(error),
490        };
491        if config.defer_updates {
492            self.register_expired_cleanup(ctx).await?;
493        } else if let Err(error) = self.maybe_delete_expired(ctx).await {
494            tracing::error!(%error,"Failed to delete expired API keys");
495        }
496        if config.references != ApiKeyReferences::User {
497            return Ok(Some(BeforeEndpointAction::Reject(rejection(
498                ApiKeyValidationError::new(ApiKeyErrorCode::InvalidReferenceIdFromApiKey),
499                None,
500            )?)));
501        }
502        let Some(user) = ctx.database.get_user_by_id(&view.reference_id).await? else {
503            return Ok(Some(BeforeEndpointAction::Reject(rejection(
504                ApiKeyValidationError::new(ApiKeyErrorCode::InvalidReferenceIdFromApiKey),
505                None,
506            )?)));
507        };
508        let session = Self::virtual_session_from_key(&view, &key, &user, call.request(), ctx)?;
509        let user_view = ctx.user_view(&user);
510        call.establish_session::<S>(user, user_view.clone(), session.clone(), ctx);
511        if call.path() == Some("/get-session") {
512            return Ok(Some(BeforeEndpointAction::Respond(EndpointResponse::json(
513                &serde_json::json!({"user":user_view,"session":session}),
514            )?)));
515        }
516        Ok(Some(BeforeEndpointAction::Patch(Box::new(
517            EndpointContextPatch {
518                path: call.path().map(str::to_owned),
519                ..EndpointContextPatch::default()
520            },
521        ))))
522    }
523}
524
525fn rejection(error: ApiKeyValidationError, status: Option<u16>) -> AuthResult<EndpointResponse> {
526    let status = status.unwrap_or(error.status());
527    let message = match &error.message {
528        ApiKeyErrorMessage::Text(message) | ApiKeyErrorMessage::CodeMessage { message, .. } => {
529            message.clone()
530        }
531    };
532    let body = alibi_core::utils::json::parse_value(&alibi_core::utils::json::to_string(&error)?)?;
533    Ok(EndpointResponse::error(AuthError::Api {
534        status,
535        code: Some(error.code.as_str().into()),
536        message,
537    })
538    .with_error_body(body))
539}