diff --git a/Cargo.lock b/Cargo.lock index 4b86d1ac71..c02b396455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11563,6 +11563,7 @@ dependencies = [ "actix_helpers", "anyhow", "artcraft_api_defs", + "artcraft_api_keys", "artcraft_router", "async-stripe 0.26.0", "async-trait", diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index 0cda8305f8..8863de2412 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -36,6 +36,7 @@ actix_artcraft.workspace = true actix_cors_configs = { path = "../../../lib/actix_cors_configs" } actix_helpers.workspace = true artcraft_api_defs.workspace = true +artcraft_api_keys.workspace = true artcraft_router.workspace = true bootstrap.workspace = true bucket_paths.workspace = true diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs index 4625a6a2bd..39fc1f499e 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs @@ -75,7 +75,7 @@ pub async fn create_api_key_handler( ip_address: &ip_address, name: &name, maybe_description: maybe_description.as_deref(), - api_key: &api_key, + api_key: api_key.as_str(), mysql_executor: &mut *conn, phantom: PhantomData, }).await.map_err(|err| { @@ -86,6 +86,8 @@ pub async fn create_api_key_handler( Ok(Json(CreateApiKeySuccessResponse { success: true, api_key_token: token, - api_key, + // Return the full secret value (never the redacted Debug/Display form). This is the only + // time it is ever exposed. + api_key: api_key.as_str().to_string(), })) } diff --git a/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_api_key.rs b/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_api_key.rs index 8b51c6ae59..866ad268d9 100644 --- a/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_api_key.rs +++ b/crates/service/web/storyteller_web/src/http_server/web_utils/get_authorization_header_api_key.rs @@ -1,74 +1,23 @@ use actix_http::header::{HeaderMap, HeaderName}; use actix_web::HttpRequest; -/// `Authorization: Bearer ` — the widely-accepted style for most APIs. -const BEARER_SCHEME: &str = "bearer"; - -/// `Authorization: Key ` — legacy GitHub-style / Fal-style. -const KEY_SCHEME: &str = "key"; - +use artcraft_api_keys::ArtcraftApiKey; const AUTHORIZATION_HEADER_NAME: HeaderName = HeaderName::from_static("authorization"); -/// Extract an API key from a request's `Authorization` header. +/// Extract an [`ArtcraftApiKey`] from a request's `Authorization` header. /// -/// The header name is matched case-insensitively (HTTP header names always are), -/// and the value is accepted in three forms: -/// -/// - `Authorization: Bearer ` (widely-accepted style for most APIs) -/// - `Authorization: Key ` (legacy GitHub-style / Fal-style) -/// - `Authorization: ` (AWS-style — the bare key, no scheme) -/// -/// The scheme keyword is matched case-insensitively. Returns `None` if the -/// header is absent, unreadable (non-ASCII bytes), empty, carries an -/// unsupported scheme (e.g. `Basic ...`), or names a scheme with no credential. -pub fn get_authorization_header_api_key(http_request: &HttpRequest) -> Option { +/// The header name is matched case-insensitively (HTTP header names always are). The value parsing +/// — the `Bearer `, `Key `, and bare `` forms — lives in the +/// [`artcraft_api_keys`] crate. Returns `None` if the header is absent, unreadable (non-ASCII +/// bytes), or does not contain a usable key. +pub fn get_authorization_header_api_key(http_request: &HttpRequest) -> Option { let header_map: &HeaderMap = http_request.headers(); let header_value = header_map.get(AUTHORIZATION_HEADER_NAME)? .to_str() .ok()?; - parse_authorization_header_api_key(header_value) -} - -fn parse_authorization_header_api_key(header_value: &str) -> Option { - let trimmed = header_value.trim(); - if trimmed.is_empty() { - return None; - } - - match trimmed.split_once(char::is_whitespace) { - // A scheme word followed by a value: "Bearer " / "Key ". - Some((scheme, rest)) => { - if is_supported_scheme(scheme) { - let key = rest.trim(); - if key.is_empty() { - // A supported scheme with no credential. - None - } else { - Some(key.to_string()) - } - } else { - // An unsupported scheme (e.g. "Basic ..."). A bare API key never - // contains whitespace, so a multi-token value we don't recognize is - // not a usable key. - None - } - } - // No whitespace: AWS-style bare API key ("") — unless the value is - // just a bare scheme keyword (a malformed header carrying no credential). - None => { - if is_supported_scheme(trimmed) { - None - } else { - Some(trimmed.to_string()) - } - } - } -} - -fn is_supported_scheme(word: &str) -> bool { - word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME) + ArtcraftApiKey::parse_from_authorization_header_value(header_value) } #[cfg(test)] @@ -111,7 +60,7 @@ mod tests { .insert_header((header_name, value.as_str())) .to_http_request(); assert_eq!( - get_authorization_header_api_key(&http_request), + get_authorization_header_api_key(&http_request).map(|key| key.as_str().to_string()), Some(SAMPLE_KEY.to_string()), "failed for header name {header_name:?}"); } @@ -178,10 +127,18 @@ mod tests { } } + // Thin string-returning adapters over the real implementation so the original test cases above + // can stay unchanged after the parser moved into the `artcraft_api_keys` crate. + + fn parse_authorization_header_api_key(header_value: &str) -> Option { + ArtcraftApiKey::parse_from_authorization_header_value(header_value) + .map(|key| key.as_str().to_string()) + } + fn api_key_for_authorization_header(value: &str) -> Option { let http_request = TestRequest::default() .insert_header(("Authorization", value)) .to_http_request(); - get_authorization_header_api_key(&http_request) + get_authorization_header_api_key(&http_request).map(|key| key.as_str().to_string()) } } diff --git a/crates/service/web/storyteller_web/src/util/generate_api_key.rs b/crates/service/web/storyteller_web/src/util/generate_api_key.rs index e6e486a3e8..10f7c3e770 100644 --- a/crates/service/web/storyteller_web/src/util/generate_api_key.rs +++ b/crates/service/web/storyteller_web/src/util/generate_api_key.rs @@ -1,5 +1,7 @@ use rand::Rng; +use artcraft_api_keys::ArtcraftApiKey; + /// All Artcraft API keys begin with this prefix. It counts toward the total key length. /// /// The underscores in this literal are the ONLY underscores permitted in a key — the random @@ -27,7 +29,10 @@ const ENTROPY_CHAR_COUNT: usize = 40; /// /// Infallible: it draws from the thread-local CSPRNG (`rand::rng()`, a ChaCha-based, OS-seeded /// generator), which never fails. -pub fn generate_api_key() -> String { +/// +/// The full secret lives inside the returned [`ArtcraftApiKey`]; use `as_str()` to obtain it for +/// storage (`Debug`/`Display` deliberately redact it). +pub fn generate_api_key() -> ArtcraftApiKey { let mut rng = rand::rng(); let mut key = String::with_capacity(API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); @@ -38,7 +43,7 @@ pub fn generate_api_key() -> String { key.push(CROCKFORD_LOWERCASE_CHARSET[index] as char); } - key + ArtcraftApiKey(key) } #[cfg(test)] @@ -49,19 +54,19 @@ mod tests { #[test] fn starts_with_prefix() { - assert!(generate_api_key().starts_with("artcraft_api_")); - assert!(generate_api_key().starts_with(API_KEY_PREFIX)); + assert!(generate_api_key().as_str().starts_with("artcraft_api_")); + assert!(generate_api_key().as_str().starts_with(API_KEY_PREFIX)); } #[test] fn has_expected_total_length() { - assert_eq!(generate_api_key().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); + assert_eq!(generate_api_key().as_str().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); } #[test] fn entropy_suffix_is_crockford_lowercase() { let key = generate_api_key(); - let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + let suffix = key.as_str().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); assert_eq!(suffix.len(), ENTROPY_CHAR_COUNT); for byte in suffix.bytes() { @@ -79,21 +84,21 @@ mod tests { fn forbidden_characters_are_absent() { let key = generate_api_key(); for forbidden in ['.', '+', '/'] { - assert!(!key.contains(forbidden), "key must not contain {:?}", forbidden); + assert!(!key.as_str().contains(forbidden), "key must not contain {:?}", forbidden); } } #[test] fn underscores_only_appear_in_the_prefix() { let key = generate_api_key(); - let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + let suffix = key.as_str().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); assert!(!suffix.contains('_'), "the random body must not contain underscores"); } #[test] fn entropy_suffix_is_alphanumeric() { let key = generate_api_key(); - let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + let suffix = key.as_str().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric())); }