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#[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
125fn 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 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 pub fn create_endpoint(
254 body: &CreateKeyRequest,
255 ) -> AuthResult<ServerEndpoint<CreateKeyResponse>> {
256 ServerEndpoint::new("api-key", "createApiKey").with_body(body)
257 }
258
259 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}