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