Skip to main content

better_auth_api/plugins/api_key/
verification.rs

1use better_auth_core::entity::AuthUser;
2use better_auth_core::store::ConsumeApiKeyResult;
3use better_auth_core::wire::{ApiKeyView, SessionView};
4use better_auth_core::{
5    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, BeforeRequestAction,
6};
7use serde::Serialize;
8
9use super::{ApiKeyEndpoint, ApiKeyErrorCode, ApiKeyPlugin, ApiKeyReferences, config_id_matches};
10
11/// Inputs for server-only API key verification. Verification consumes one use.
12pub struct VerifyApiKey<'a> {
13    /// The plaintext API key presented by the caller.
14    pub key: &'a str,
15    /// Restrict verification to this configuration, or use the default lookup configuration.
16    pub config_id: Option<&'a str>,
17    /// Required resource permissions, checked before quota or rate-limit consumption.
18    pub permissions: Option<&'a serde_json::Value>,
19}
20
21/// Additional information returned when a key reaches its rate limit.
22#[derive(Debug, Serialize)]
23pub struct ApiKeyErrorDetails {
24    /// Milliseconds until the key's rate-limit window elapses.
25    #[serde(rename = "tryAgainIn")]
26    pub try_again_in: f64,
27}
28
29/// An API key rejection with the upstream error code and response fields.
30#[derive(Debug, Serialize)]
31#[serde(untagged)]
32pub enum ApiKeyErrorMessage {
33    /// A public error message.
34    Text(String),
35    /// Upstream returns its complete error constant on selected verification failures.
36    Code {
37        code: ApiKeyErrorCode,
38        message: String,
39    },
40}
41
42impl std::fmt::Display for ApiKeyErrorMessage {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Text(message) | Self::Code { message, .. } => formatter.write_str(message),
46        }
47    }
48}
49
50/// An API key rejection with the upstream error code and response fields.
51#[derive(Debug, Serialize)]
52pub struct ApiKeyValidationError {
53    /// Stable upstream API key error code.
54    pub code: ApiKeyErrorCode,
55    /// Upstream error message.
56    pub message: ApiKeyErrorMessage,
57    /// Rate-limit timing, when the rejection is a rate limit.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub details: Option<ApiKeyErrorDetails>,
60}
61
62impl ApiKeyValidationError {
63    fn new(code: ApiKeyErrorCode) -> Self {
64        Self {
65            code,
66            message: ApiKeyErrorMessage::Text(code.message().to_owned()),
67            details: None,
68        }
69    }
70
71    fn invalid_constant(code: ApiKeyErrorCode) -> Self {
72        Self {
73            code,
74            message: ApiKeyErrorMessage::Code {
75                code: ApiKeyErrorCode::InvalidApiKey,
76                message: ApiKeyErrorCode::InvalidApiKey.message().to_owned(),
77            },
78            details: None,
79        }
80    }
81
82    fn status(&self) -> u16 {
83        match self.code {
84            ApiKeyErrorCode::NoDefaultConfiguration => 400,
85            ApiKeyErrorCode::RateLimited | ApiKeyErrorCode::UsageExceeded => 429,
86            _ => 401,
87        }
88    }
89
90    fn response(&self) -> AuthResult<AuthResponse> {
91        Ok(AuthResponse::json(self.status(), self)?)
92    }
93}
94
95/// Distinguishes a rejected credential from a storage or infrastructure failure.
96#[derive(Debug)]
97pub enum ApiKeyVerificationError {
98    /// The credential or its permissions, quota, or configuration was rejected.
99    Validation(ApiKeyValidationError),
100    /// An application API error caught by unscoped verification, including its complete response body.
101    Rejected(AuthError),
102    /// An internal operation failed. The original typed error is preserved.
103    Internal(AuthError),
104    /// An endpoint hook or scoped validator failed before the verification catch boundary.
105    Endpoint(AuthError),
106}
107
108impl std::fmt::Display for ApiKeyVerificationError {
109    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            Self::Validation(error) => write!(formatter, "{}", error.message),
112            Self::Internal(error) | Self::Endpoint(error) | Self::Rejected(error) => {
113                write!(formatter, "API key verification failed: {error}")
114            }
115        }
116    }
117}
118
119impl std::error::Error for ApiKeyVerificationError {
120    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
121        match self {
122            Self::Internal(error) | Self::Endpoint(error) | Self::Rejected(error) => Some(error),
123            Self::Validation(_) => None,
124        }
125    }
126}
127
128impl ApiKeyVerificationError {
129    /// Render the server-only endpoint's upstream response while retaining typed errors until this boundary.
130    pub fn into_response(self) -> AuthResult<AuthResponse> {
131        let error = match self {
132            Self::Endpoint(error) => return Ok(error.to_auth_response()),
133            Self::Rejected(error) => {
134                let body: serde_json::Value =
135                    serde_json::from_slice(&error.to_auth_response().body.bytes()?)?;
136                return Ok(AuthResponse::json(
137                    200,
138                    &serde_json::json!({"valid":false,"error":body,"key":null}),
139                )?);
140            }
141            Self::Validation(error) => error,
142            Self::Internal(error) => {
143                better_auth_core::observability::logger::current().error(
144                    "Failed to validate API key",
145                    &[better_auth_core::observability::LogArgument::Error(&error)],
146                );
147                ApiKeyValidationError::invalid_constant(ApiKeyErrorCode::InvalidApiKey)
148            }
149        };
150        Ok(AuthResponse::json(
151            200,
152            &serde_json::json!({"valid":false,"error":error,"key":null}),
153        )?)
154    }
155}
156
157impl From<AuthError> for ApiKeyVerificationError {
158    fn from(error: AuthError) -> Self {
159        Self::Internal(error)
160    }
161}
162
163impl From<ApiKeyErrorCode> for ApiKeyVerificationError {
164    fn from(code: ApiKeyErrorCode) -> Self {
165        Self::Validation(ApiKeyValidationError::new(code))
166    }
167}
168
169impl ApiKeyPlugin {
170    /// Verify a machine credential without a user session or public HTTP endpoint.
171    ///
172    /// A successful verification consumes quota and a rate-limit slot. Permissions
173    /// and configuration mismatches do not consume usage. The returned key omits
174    /// the plaintext credential and stored hash.
175    ///
176    /// Without `config_id`, lookup uses the default configuration's hashing
177    /// setting. Validation then uses the configuration that issued the key.
178    pub async fn verify_api_key(
179        &self,
180        input: &VerifyApiKey<'_>,
181        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
182    ) -> Result<ApiKeyView, ApiKeyVerificationError> {
183        let mut body = serde_json::Map::from_iter([("key".into(), input.key.into())]);
184        if let Some(config_id) = input.config_id {
185            let _ = body.insert("configId".into(), config_id.into());
186        }
187        if let Some(permissions) = input.permissions {
188            let _ = body.insert("permissions".into(), permissions.clone());
189        }
190        let body = serde_json::Value::Object(body);
191        let endpoint = ApiKeyEndpoint::new(ctx, None, None, &body);
192        let session = self
193            .authenticate_api_key(endpoint, ctx)
194            .await
195            .map_err(ApiKeyVerificationError::Endpoint)?;
196        let _ = super::request::validate_value("verifyApiKey", Some(&body))
197            .map_err(ApiKeyVerificationError::Endpoint)?;
198        let endpoint = ApiKeyEndpoint {
199            path: Some(if session.is_some() { "/" } else { "virtual:" }),
200            ..endpoint
201        };
202        let config = self
203            .resolve_configuration(input.config_id)
204            .map_err(ApiKeyVerificationError::Endpoint)?;
205        if input.config_id.is_some()
206            && let Some(validator) = &config.custom_api_key_validator
207            && !validator
208                .validate(input.key, endpoint)
209                .await
210                .map_err(ApiKeyVerificationError::Endpoint)?
211        {
212            return Err(ApiKeyVerificationError::Validation(
213                ApiKeyValidationError::invalid_constant(ApiKeyErrorCode::KeyNotFound),
214            ));
215        }
216        let key = self
217            .validate_api_key(input, ctx, endpoint, input.config_id.is_none())
218            .await?;
219        let config = self
220            .resolve_configuration(Some(&key.config_id))
221            .map_err(ApiKeyVerificationError::Endpoint)?;
222        super::metadata::single(key, config, ctx)
223            .await
224            .map_err(ApiKeyVerificationError::Endpoint)
225    }
226
227    async fn validate_api_key(
228        &self,
229        input: &VerifyApiKey<'_>,
230        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
231        endpoint: ApiKeyEndpoint<'_>,
232        run_custom_validator: bool,
233    ) -> Result<ApiKeyView, ApiKeyVerificationError> {
234        let lookup_config = self
235            .resolve_configuration(input.config_id)
236            .map_err(|_| ApiKeyErrorCode::NoDefaultConfiguration)?;
237        let hashed = if lookup_config.disable_key_hashing {
238            input.key.to_owned()
239        } else {
240            Self::hash_key(input.key)
241        };
242        let api_key = super::storage::get_by_hash(lookup_config, ctx, &hashed)
243            .await?
244            .ok_or(ApiKeyErrorCode::InvalidApiKey)?;
245
246        if input
247            .config_id
248            .is_some_and(|expected| !config_id_matches(&api_key.config_id, expected))
249        {
250            return Err(ApiKeyErrorCode::InvalidApiKey.into());
251        }
252        let config = self
253            .resolve_configuration(Some(&api_key.config_id))
254            .map_err(|_| ApiKeyErrorCode::NoDefaultConfiguration)?;
255
256        if run_custom_validator && let Some(validator) = &config.custom_api_key_validator {
257            match validator.validate(input.key, endpoint).await {
258                Ok(true) => {}
259                Ok(false) => return Err(ApiKeyErrorCode::KeyNotFound.into()),
260                Err(
261                    error @ (AuthError::Internal(_)
262                    | AuthError::Database(_)
263                    | AuthError::Config(_)
264                    | AuthError::Serialization(_)),
265                ) => return Err(ApiKeyVerificationError::Internal(error)),
266                Err(error) => return Err(ApiKeyVerificationError::Rejected(error)),
267            }
268        }
269
270        if !api_key.enabled.is_truthy()? {
271            return Err(ApiKeyErrorCode::KeyDisabled.into());
272        }
273        if let Some(expires_at) = &api_key.expires_at
274            && chrono::Utc::now().timestamp_millis() as f64 > expires_at.milliseconds()
275        {
276            super::storage::delete_for_verification(config, ctx, &api_key).await?;
277            return Err(ApiKeyErrorCode::KeyExpired.into());
278        }
279
280        if let Some(required) = input.permissions {
281            let permitted = api_key.permissions.as_deref().is_some_and(|permissions| {
282                super::handlers::check_permissions(permissions, required)
283            });
284            if !permitted {
285                return Err(ApiKeyErrorCode::KeyNotFound.into());
286            }
287        }
288
289        let updated = match super::storage::consume(config, ctx, &api_key).await? {
290            ConsumeApiKeyResult::Allowed(key) => key,
291            ConsumeApiKeyResult::RateLimited { try_again_in } => {
292                let mut error = ApiKeyValidationError::new(ApiKeyErrorCode::RateLimited);
293                error.details = Some(ApiKeyErrorDetails { try_again_in });
294                return Err(ApiKeyVerificationError::Validation(error));
295            }
296            ConsumeApiKeyResult::UsageExhausted => {
297                return Err(ApiKeyErrorCode::UsageExceeded.into());
298            }
299        };
300        Ok(ApiKeyView::from(updated.as_ref()))
301    }
302
303    pub(super) async fn api_key_session(
304        &self,
305        req: &AuthRequest,
306        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
307    ) -> AuthResult<Option<BeforeRequestAction>> {
308        let body = if let Some(body) = req.parsed_http_body() {
309            body.clone()
310        } else {
311            match req.body.as_deref().filter(|body| !body.is_empty()) {
312                Some(body)
313                    if req.headers.iter().any(|(name, value)| {
314                        name.eq_ignore_ascii_case("content-type")
315                            && value
316                                .to_ascii_lowercase()
317                                .contains("application/x-www-form-urlencoded")
318                    }) =>
319                {
320                    serde_json::Value::Object(
321                        url::form_urlencoded::parse(body)
322                            .map(|(name, value)| {
323                                (
324                                    name.into_owned(),
325                                    serde_json::Value::String(value.into_owned()),
326                                )
327                            })
328                            .collect(),
329                    )
330                }
331                Some(body) => crate::plugins::json_body::decode(body).map_err(AuthError::from)?,
332                None => serde_json::Value::Null,
333            }
334        };
335        let endpoint = ApiKeyEndpoint::new(ctx, Some(req), Some(req.path()), &body);
336        let (session, user) = match self.authenticate_api_key(endpoint, ctx).await {
337            Ok(Some(session)) => session,
338            Ok(None) => return Ok(None),
339            Err(error @ AuthError::Response(_)) => {
340                return Ok(Some(BeforeRequestAction::Respond(error.to_auth_response())));
341            }
342            Err(error) => return Err(error),
343        };
344        // Upstream answers this path in its hook before the route method gate.
345        if req.path() == "/get-session" {
346            return Ok(Some(BeforeRequestAction::Respond(AuthResponse::json(
347                200,
348                &serde_json::json!({
349                    "user": user,
350                    "session": {
351                        "id": session.id,
352                        "token": session.token,
353                        "userId": session.user_id,
354                        "userAgent": session.user_agent,
355                        "ipAddress": session.ip_address,
356                        "createdAt": better_auth_core::FieldValue::from(session.created_at.clone()).json()?,
357                        "updatedAt": better_auth_core::FieldValue::from(session.updated_at.clone()).json()?,
358                        "expiresAt": better_auth_core::FieldValue::from(session.expires_at.clone()).json()?,
359                    },
360                }),
361            )?)));
362        }
363        Ok(Some(BeforeRequestAction::InjectSession {
364            session: Box::new(session),
365        }))
366    }
367
368    pub(super) async fn authenticate_api_key(
369        &self,
370        endpoint: ApiKeyEndpoint<'_>,
371        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
372    ) -> AuthResult<Option<(SessionView, better_auth_core::wire::UserView)>> {
373        // Upstream invokes extraction once in the hook matcher, then again in its handler.
374        match self.find_session_key(endpoint) {
375            Ok(None) => return Ok(None),
376            Ok(Some(_)) => {}
377            Err(error) => {
378                better_auth_core::observability::logger::current().error(
379                    "API key hook matcher failed",
380                    &[better_auth_core::observability::LogArgument::Error(&error)],
381                );
382                return Err(AuthResponse::json(500, &serde_json::json!({
383                    "message": "An error occurred during hook matcher execution. Check the logs for more details."
384                }))?.into());
385            }
386        }
387        let context = better_auth_core::hooks::current_request_hook_context();
388        let route = context
389            .as_ref()
390            .map(|context| context.path.as_deref().unwrap_or("/:virtual"))
391            .or(endpoint.path)
392            .unwrap_or("/");
393        let operation_id = context
394            .as_ref()
395            .and_then(|context| context.operation_id.as_deref())
396            .unwrap_or(route);
397        better_auth_core::observability::with_span(
398            &ctx.config.experimental.instrumentation,
399            &format!("hook before {route} plugin:api-key"),
400            better_auth_core::observability::SpanAttributes {
401                route: Some(route),
402                operation_id: Some(operation_id),
403                hook_type: Some("before"),
404                context: Some("plugin:api-key"),
405                ..Default::default()
406            },
407            async {
408                let endpoint = ApiKeyEndpoint {
409                    path: endpoint.path.or(Some("/")),
410                    ..endpoint
411                };
412                let Some((config, key)) = self
413                    .find_session_key(endpoint)
414                    .map_err(|error| super::callbacks::callback_error(error, endpoint.request))?
415                else {
416                    return Err(AuthError::internal(
417                        "API key getter stopped matching during hook execution",
418                    ));
419                };
420                if key.encode_utf16().count() < config.key_length {
421                    return Err(AuthResponse::json(
422                        403,
423                        &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
424                    )?
425                    .into());
426                }
427                if let Some(validator) = &config.custom_api_key_validator
428                    && !validator.validate(&key, endpoint).await.map_err(|error| {
429                        super::callbacks::callback_error(error, endpoint.request)
430                    })?
431                {
432                    return Err(AuthResponse::json(
433                        403,
434                        &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
435                    )?
436                    .into());
437                }
438                let input = VerifyApiKey {
439                    key: &key,
440                    config_id: Some(&config.config_id),
441                    permissions: None,
442                };
443                let view = match self.validate_api_key(&input, ctx, endpoint, false).await {
444                    Ok(view) => view,
445                    Err(ApiKeyVerificationError::Validation(error)) => {
446                        return Err(error.response()?.into());
447                    }
448                    Err(
449                        ApiKeyVerificationError::Internal(error)
450                        | ApiKeyVerificationError::Endpoint(error)
451                        | ApiKeyVerificationError::Rejected(error),
452                    ) => return Err(error),
453                };
454                self.maybe_delete_expired(ctx).await;
455
456                if config.references != ApiKeyReferences::User {
457                    return Err(ApiKeyValidationError::new(
458                        ApiKeyErrorCode::InvalidReferenceIdFromApiKey,
459                    )
460                    .response()?
461                    .into());
462                }
463                let Some(user) = ctx.database.get_user_by_id(&view.reference_id).await? else {
464                    return Err(ApiKeyValidationError::new(
465                        ApiKeyErrorCode::InvalidReferenceIdFromApiKey,
466                    )
467                    .response()?
468                    .into());
469                };
470
471                let now = chrono::Utc::now();
472                let expires_at = match view.expires_at {
473                    Some(value) => value,
474                    // Upstream passes its session lifetime in seconds to getDate(..., "ms").
475                    None => (now
476                        + chrono::Duration::milliseconds(
477                            ctx.config.session.expires_in().num_seconds(),
478                        ))
479                    .into(),
480                };
481                let meta = endpoint.request.map(|req| {
482                    better_auth_core::RequestMeta::from_request_with_config(
483                        req,
484                        &ctx.config.advanced.ip_address,
485                    )
486                });
487                let session = SessionView {
488                    visible_fields: None,
489                    id: view.id,
490                    token: key.to_owned(),
491                    user_id: user.id().into_owned(),
492                    created_at: now.into(),
493                    updated_at: now.into(),
494                    expires_at,
495                    ip_address: meta.as_ref().and_then(|meta| meta.ip_address.clone()),
496                    user_agent: meta.and_then(|meta| meta.user_agent),
497                    impersonated_by: None,
498                    active_organization_id: None,
499                    active_team_id: None,
500                    active: true,
501                    additional_fields: Default::default(),
502                };
503                Ok(Some((session, ctx.user_view(&user).await?)))
504            },
505        )
506        .await
507    }
508
509    fn find_session_key(
510        &self,
511        endpoint: ApiKeyEndpoint<'_>,
512    ) -> AuthResult<Option<(&super::ApiKeyConfig, String)>> {
513        for config in &self.configurations {
514            if !config.enable_session_for_api_keys {
515                continue;
516            }
517            let key = if let Some(getter) = &config.custom_api_key_getter {
518                getter.get(endpoint)?
519            } else {
520                config.api_key_headers.iter().find_map(|header| {
521                    endpoint
522                        .request
523                        .and_then(|request| request.headers.get(&header.to_ascii_lowercase()))
524                        .filter(|key| !key.is_empty())
525                        .cloned()
526                })
527            };
528            if let Some(key) = key.filter(|key| !key.is_empty()) {
529                return Ok(Some((config, key)));
530            }
531        }
532        Ok(None)
533    }
534}