blueprint_auth/
auth_token.rs

1use axum::http::StatusCode;
2use std::collections::BTreeMap;
3
4use crate::{
5    api_tokens::{ApiToken, ParseApiTokenError},
6    paseto_tokens::{AccessTokenClaims, PasetoError},
7    types::ServiceId,
8};
9
10/// Unified authentication token that handles both legacy and new token types
11#[derive(Debug, Clone)]
12pub enum AuthToken {
13    /// Legacy API token format: "id|token"
14    Legacy(ApiToken),
15    /// Long-lived API key: "ak_xxxxx.yyyyy"
16    ApiKey(String),
17    /// Short-lived Paseto access token: "v4.local.xxxxx"
18    AccessToken(AccessTokenClaims),
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum AuthTokenError {
23    #[error("Invalid token format")]
24    InvalidFormat,
25
26    #[error("Legacy token error: {0}")]
27    LegacyToken(#[from] ParseApiTokenError),
28
29    #[error("Paseto token error: {0}")]
30    PasetoToken(#[from] PasetoError),
31
32    #[error("Malformed API key")]
33    MalformedApiKey,
34}
35
36impl AuthToken {
37    /// Parse a token string into the appropriate AuthToken type
38    pub fn parse(token_str: &str) -> Result<Self, AuthTokenError> {
39        if token_str.starts_with("v4.local.") {
40            // This is a Paseto access token - we'll validate it in the proxy handler
41            // For now, we just store the string and validate later with the key
42            return Err(AuthTokenError::InvalidFormat); // Will be handled by PasetoTokenManager
43        } else if token_str.contains('|') {
44            // This is a legacy token: "id|token"
45            let legacy_token = ApiToken::from_str(token_str)?;
46            return Ok(AuthToken::Legacy(legacy_token));
47        } else if token_str.contains('.') {
48            // This is an API key: "prefix_xxxxx.yyyyy"
49            // We check for dot last to avoid confusion with Paseto tokens
50            return Ok(AuthToken::ApiKey(token_str.to_string()));
51        }
52
53        Err(AuthTokenError::InvalidFormat)
54    }
55
56    /// Get the service ID if available (only for validated tokens)
57    pub fn service_id(&self) -> Option<ServiceId> {
58        match self {
59            AuthToken::Legacy(_) => None, // Need to look up in database
60            AuthToken::ApiKey(_) => None, // Need to look up in database
61            AuthToken::AccessToken(claims) => Some(claims.service_id),
62        }
63    }
64
65    /// Get additional headers if available (only for access tokens)
66    pub fn additional_headers(&self) -> BTreeMap<String, String> {
67        match self {
68            AuthToken::AccessToken(claims) => claims.additional_headers.clone(),
69            _ => BTreeMap::new(),
70        }
71    }
72
73    /// Check if token is expired (only applicable to access tokens)
74    pub fn is_expired(&self) -> bool {
75        match self {
76            AuthToken::AccessToken(claims) => claims.is_expired(),
77            _ => false, // Legacy tokens and API keys have their own expiration handling
78        }
79    }
80}
81
82/// Extraction result from request parsing
83#[derive(Debug)]
84pub enum TokenExtractionResult {
85    /// Legacy token that needs database validation
86    Legacy(ApiToken),
87    /// API key that needs database validation and exchange
88    ApiKey(String),
89    /// Validated Paseto access token ready to use
90    ValidatedAccessToken(AccessTokenClaims),
91}
92
93impl<S> axum::extract::FromRequestParts<S> for AuthToken
94where
95    S: Send + Sync,
96{
97    type Rejection = axum::response::Response;
98
99    async fn from_request_parts(
100        parts: &mut axum::http::request::Parts,
101        _state: &S,
102    ) -> Result<Self, Self::Rejection> {
103        use axum::response::IntoResponse;
104
105        let header = match parts.headers.get(crate::types::headers::AUTHORIZATION) {
106            Some(header) => header,
107            None => {
108                return Err(
109                    (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response()
110                );
111            }
112        };
113
114        let header_str = match header.to_str() {
115            Ok(header_str) if header_str.starts_with("Bearer ") => &header_str[7..],
116            Ok(_) => {
117                return Err((
118                    StatusCode::BAD_REQUEST,
119                    "Invalid Authorization header; expected Bearer token",
120                )
121                    .into_response());
122            }
123            Err(_) => {
124                return Err((
125                    StatusCode::BAD_REQUEST,
126                    "Invalid Authorization header; not valid UTF-8",
127                )
128                    .into_response());
129            }
130        };
131
132        match AuthToken::parse(header_str) {
133            Ok(token) => Ok(token),
134            Err(AuthTokenError::InvalidFormat) => {
135                // Special handling for Paseto tokens - they need the manager to validate
136                if header_str.starts_with("v4.local.") {
137                    // We'll handle Paseto validation in the proxy layer
138                    // For now, store the raw string for later processing
139                    Err(
140                        (StatusCode::BAD_REQUEST, "Paseto token validation required")
141                            .into_response(),
142                    )
143                } else {
144                    Err((StatusCode::BAD_REQUEST, "Invalid token format").into_response())
145                }
146            }
147            Err(e) => Err((StatusCode::BAD_REQUEST, format!("Invalid token: {e}")).into_response()),
148        }
149    }
150}
151
152/// Token exchange request for converting API keys to access tokens
153#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
154pub struct TokenExchangeRequest {
155    /// Optional additional headers to include in the access token
156    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
157    pub additional_headers: BTreeMap<String, String>,
158    /// Optional custom TTL in seconds (if not provided, uses default)
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub ttl_seconds: Option<u64>,
161}
162
163/// Token exchange response containing the new access token
164#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
165pub struct TokenExchangeResponse {
166    /// The new Paseto access token
167    pub access_token: String,
168    /// Token type (always "Bearer")
169    pub token_type: String,
170    /// Expiration timestamp (seconds since epoch)
171    pub expires_at: u64,
172    /// Time to live in seconds
173    pub expires_in: u64,
174}
175
176impl TokenExchangeResponse {
177    pub fn new(access_token: String, expires_at: u64) -> Self {
178        let now = std::time::SystemTime::now()
179            .duration_since(std::time::UNIX_EPOCH)
180            .unwrap_or_default()
181            .as_secs();
182
183        Self {
184            access_token,
185            token_type: "Bearer".to_string(),
186            expires_at,
187            expires_in: expires_at.saturating_sub(now),
188        }
189    }
190}