Compare commits

...
Author SHA1 Message Date
Brandon Thomas de780b0355 pass strong type 2026-06-15 19:55:30 -04:00
Brandon Thomas cb5abeff08 rename methods 2026-06-15 19:42:08 -04:00
Brandon Thomas 78c688e104 to_string() 2026-06-15 19:39:17 -04:00
Brandon Thomas b3d281a7f5 simplify 2026-06-15 19:36:50 -04:00
Brandon Thomas fe0610b779 parsing in the lib 2026-06-15 19:25:17 -04:00
Brandon Thomas 24e2b44207 debug more 2026-06-15 19:14:38 -04:00
Brandon Thomas db344608b6 rename crate 2026-06-15 19:10:11 -04:00
Brandon Thomas 146b78dcb6 crate for singualr type 2026-06-15 19:08:58 -04:00
Brandon Thomas c822872f36 api key parsing 2026-06-15 18:49:48 -04:00
12 changed files with 427 additions and 12 deletions
Generated
+9
View File
@@ -712,6 +712,13 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_api_keys"
version = "0.0.1"
dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "artcraft_client"
version = "0.0.1"
@@ -7297,6 +7304,7 @@ name = "mysql_queries"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_keys",
"bucket_paths",
"chrono",
"composite_identifiers",
@@ -11556,6 +11564,7 @@ dependencies = [
"actix_helpers",
"anyhow",
"artcraft_api_defs",
"artcraft_api_keys",
"artcraft_router",
"async-stripe 0.26.0",
"async-trait",
+2
View File
@@ -7,6 +7,7 @@ members = [
# API clients for external services
# These might be custom implementations or wrappers over other client libraries.
"crates/api_clients/artcraft/artcraft_api_defs",
"crates/api_clients/artcraft/artcraft_api_keys",
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
"crates/api_clients/cloud_storage",
@@ -232,6 +233,7 @@ workspace-build-acceleration = { path = "crates/workspace-build-acceleration" }
actix_artcraft = { path = "crates/lib/actix_artcraft" }
actix_helpers = { path = "crates/lib/actix_helpers" }
artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" }
artcraft_api_keys = { path = "crates/api_clients/artcraft/artcraft_api_keys" }
artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" }
artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" }
bootstrap = { path = "crates/lib/bootstrap" }
@@ -0,0 +1,20 @@
[package]
name = "artcraft_api_keys"
edition = "2024"
version = "0.0.1"
authors = [
"Brandon Thomas <bt@brand.io>",
"Brandon Thomas <echelon@gmail.com>",
]
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "artcraft_api_keys"
path = "src/lib.rs"
[dependencies]
# Hackari workspace (faster compile times)
workspace-build-acceleration.workspace = true
@@ -0,0 +1,104 @@
use std::fmt;
use crate::parse_from_header_value::parse_from_header_value;
pub mod parse_from_header_value;
/// Number of leading characters shown when an [`ArtcraftApiKey`] is formatted. The remainder of
/// the secret is redacted so the full key never lands in logs or error messages.
const REDACTED_PREFIX_CHAR_COUNT: usize = 20;
/// An Artcraft API key (e.g. `artcraft_api_<entropy>`).
///
/// `Debug` and `Display` both redact the value, printing only the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis so the full secret is never
/// accidentally logged.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ArtcraftApiKey(pub String);
impl ArtcraftApiKey {
pub fn new_from_str(value: &str) -> ArtcraftApiKey {
ArtcraftApiKey(value.to_string())
}
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header, accepting the
/// `Bearer <key>`, `Key <key>`, and bare `<key>` forms. See [`parse_from_header_value`] for the
/// full parsing rules.
pub fn parse_from_authorization_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
parse_from_header_value(header_value)
}
/// The full, unredacted key value as a borrowed `&str`.
///
/// NB: unlike `Display`/`Debug` (which redact), this exposes the complete secret — the
/// `_be_careful` suffix is a reminder to use it only where the real value is genuinely needed
/// (storage, the create-key response, tests), never in logs or error messages.
pub fn as_str_be_careful(&self) -> &str {
&self.0
}
/// The full, unredacted key value as an owned `String`. See [`Self::as_str_be_careful`] for the
/// caveat behind the `_be_careful` suffix.
pub fn to_string_be_careful(&self) -> String {
self.0.clone()
}
/// The redacted form used by both `Debug` and `Display`: the first
/// [`REDACTED_PREFIX_CHAR_COUNT`] characters followed by an ellipsis.
fn redacted(&self) -> String {
let prefix: String = self.0.chars().take(REDACTED_PREFIX_CHAR_COUNT).collect();
format!("{prefix}…")
}
}
impl fmt::Display for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.redacted())
}
}
impl fmt::Debug for ArtcraftApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ArtcraftApiKey({:?})", self.redacted())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
#[test]
fn new_from_str_and_as_str_round_trip() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
assert_eq!(key.0, SAMPLE_KEY);
}
#[test]
fn display_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key}"), "artcraft_api_55ax0zh…");
}
#[test]
fn debug_redacts_to_first_twenty_characters() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
assert_eq!(format!("{key:?}"), "ArtcraftApiKey(\"artcraft_api_55ax0zh…\")");
}
#[test]
fn formatting_does_not_leak_the_full_secret() {
let key = ArtcraftApiKey::new_from_str(SAMPLE_KEY);
let suffix = &SAMPLE_KEY[REDACTED_PREFIX_CHAR_COUNT..];
assert!(!format!("{key}").contains(suffix));
assert!(!format!("{key:?}").contains(suffix));
}
#[test]
fn shorter_than_prefix_is_printed_in_full() {
let key = ArtcraftApiKey::new_from_str("abc");
assert_eq!(format!("{key}"), "abc…");
}
}
@@ -0,0 +1,125 @@
use crate::ArtcraftApiKey;
/// `Bearer <api_key>` — the widely-accepted style for most APIs.
const BEARER_SCHEME: &str = "bearer";
/// `Key <api_key>` — legacy GitHub-style / Fal-style.
const KEY_SCHEME: &str = "key";
/// Parse an [`ArtcraftApiKey`] from the value of an HTTP `Authorization` header.
///
/// The value is accepted in three forms:
///
/// - `Bearer <api_key>` (widely-accepted style for most APIs)
/// - `Key <api_key>` (legacy GitHub-style / Fal-style)
/// - `<api_key>` (AWS-style — the bare key, no scheme)
///
/// The scheme keyword is matched case-insensitively, and surrounding/inner whitespace is trimmed.
/// Returns `None` if the value is empty, carries an unsupported scheme (e.g. `Basic ...`), or names
/// a scheme with no credential.
pub fn parse_from_header_value(header_value: &str) -> Option<ArtcraftApiKey> {
let trimmed = header_value.trim();
if trimmed.is_empty() {
return None;
}
let key = match trimmed.split_once(char::is_whitespace) {
// A scheme word followed by a value: "Bearer <key>" / "Key <key>".
Some((scheme, rest)) => {
if is_supported_scheme(scheme) {
let key = rest.trim();
if key.is_empty() {
// A supported scheme with no credential.
return None;
}
key
} 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.
return None;
}
}
// No whitespace: AWS-style bare API key ("<api_key>") — unless the value is
// just a bare scheme keyword (a malformed header carrying no credential).
None => {
if is_supported_scheme(trimmed) {
return None;
}
trimmed
}
};
Some(ArtcraftApiKey::new_from_str(key))
}
fn is_supported_scheme(word: &str) -> bool {
word.eq_ignore_ascii_case(BEARER_SCHEME) || word.eq_ignore_ascii_case(KEY_SCHEME)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
fn parsed(header_value: &str) -> Option<String> {
parse_from_header_value(header_value).map(|key| key.to_string_be_careful())
}
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(parsed("Bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("bearer abc"), Some("abc".to_string()));
assert_eq!(parsed("BEARER abc"), Some("abc".to_string()));
assert_eq!(parsed("BeArEr abc"), Some("abc".to_string()));
}
#[test]
fn key_scheme_is_case_insensitive() {
assert_eq!(parsed("Key abc"), Some("abc".to_string()));
assert_eq!(parsed("key abc"), Some("abc".to_string()));
assert_eq!(parsed("KEY abc"), Some("abc".to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(parsed("abc"), Some("abc".to_string()));
assert_eq!(parsed(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
}
#[test]
fn surrounding_and_inner_whitespace_is_trimmed() {
assert_eq!(parsed(" Bearer abc "), Some("abc".to_string()));
assert_eq!(parsed("\tKey\tabc\t"), Some("abc".to_string()));
assert_eq!(parsed(" abc "), Some("abc".to_string()));
}
#[test]
fn empty_or_blank_is_none() {
assert_eq!(parsed(""), None);
assert_eq!(parsed(" "), None);
}
#[test]
fn scheme_with_no_credential_is_none() {
assert_eq!(parsed("Bearer"), None);
assert_eq!(parsed("Bearer "), None);
assert_eq!(parsed("Key"), None);
assert_eq!(parsed("key "), None);
}
#[test]
fn unsupported_scheme_is_none() {
assert_eq!(parsed("Basic dXNlcjpwYXNz"), None);
assert_eq!(parsed("Digest abc"), None);
// Two non-scheme tokens — a bare key never contains whitespace.
assert_eq!(parsed("abc def"), None);
}
#[test]
fn returns_an_artcraft_api_key() {
let key = parse_from_header_value(&format!("Bearer {SAMPLE_KEY}")).expect("should parse");
assert_eq!(key.as_str_be_careful(), SAMPLE_KEY);
}
}
@@ -20,6 +20,7 @@ path = "src/lib.rs"
workspace-build-acceleration.workspace = true
# Internal
artcraft_api_keys.workspace = true
bucket_paths.workspace = true
composite_identifiers = { path = "../../../schema/public/composite_identifiers" }
enums = { workspace = true, features = ["database"] }
@@ -2,6 +2,7 @@ use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use artcraft_api_keys::ArtcraftApiKey;
use tokens::tokens::api_keys::ApiKeyToken;
use tokens::tokens::users::UserToken;
@@ -13,7 +14,7 @@ where
pub ip_address: &'e str,
pub name: &'e str,
pub maybe_description: Option<&'e str>,
pub api_key: &'e str,
pub api_key: &'e ArtcraftApiKey,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
@@ -44,7 +45,7 @@ SET
created_at = NOW()
"#,
token.as_str(),
args.api_key,
args.api_key.as_str_be_careful(), // NB: This is okay in this case.
args.name,
args.maybe_description,
args.owner_user_token.as_str(),
@@ -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
@@ -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.to_string_be_careful(), // NB: This is okay in this case
}))
}
@@ -0,0 +1,144 @@
use actix_http::header::{HeaderMap, HeaderName};
use actix_web::HttpRequest;
use artcraft_api_keys::ArtcraftApiKey;
const AUTHORIZATION_HEADER_NAME: HeaderName = HeaderName::from_static("authorization");
/// Extract an [`ArtcraftApiKey`] from a request's `Authorization` header.
///
/// The header name is matched case-insensitively (HTTP header names always are). The value parsing
/// — the `Bearer <api_key>`, `Key <api_key>`, and bare `<api_key>` 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<ArtcraftApiKey> {
let header_map: &HeaderMap = http_request.headers();
let header_value = header_map.get(AUTHORIZATION_HEADER_NAME)?
.to_str()
.ok()?;
ArtcraftApiKey::parse_from_authorization_header_value(header_value)
}
#[cfg(test)]
mod tests {
use actix_web::test::TestRequest;
use super::*;
const SAMPLE_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
mod request_header_tests {
use super::*;
#[test]
fn bearer_scheme() {
assert_eq!(
api_key_for_authorization_header(&format!("Bearer {SAMPLE_KEY}")),
Some(SAMPLE_KEY.to_string()));
}
#[test]
fn key_scheme() {
assert_eq!(
api_key_for_authorization_header(&format!("Key {SAMPLE_KEY}")),
Some(SAMPLE_KEY.to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(
api_key_for_authorization_header(SAMPLE_KEY),
Some(SAMPLE_KEY.to_string()));
}
#[test]
fn header_name_is_case_insensitive() {
let value = format!("Bearer {SAMPLE_KEY}");
for header_name in ["authorization", "Authorization", "AUTHORIZATION"] {
let http_request = TestRequest::default()
.insert_header((header_name, value.as_str()))
.to_http_request();
assert_eq!(
get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful()),
Some(SAMPLE_KEY.to_string()),
"failed for header name {header_name:?}");
}
}
#[test]
fn missing_header_returns_none() {
let http_request = TestRequest::default().to_http_request();
assert_eq!(get_authorization_header_api_key(&http_request), None);
}
}
mod parsing_tests {
use super::*;
#[test]
fn bearer_scheme_is_case_insensitive() {
assert_eq!(parse_authorization_header_api_key("Bearer abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("bearer abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("BEARER abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("BeArEr abc"), Some("abc".to_string()));
}
#[test]
fn key_scheme_is_case_insensitive() {
assert_eq!(parse_authorization_header_api_key("Key abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("key abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("KEY abc"), Some("abc".to_string()));
}
#[test]
fn bare_key_aws_style() {
assert_eq!(parse_authorization_header_api_key("abc"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key(SAMPLE_KEY), Some(SAMPLE_KEY.to_string()));
}
#[test]
fn surrounding_and_inner_whitespace_is_trimmed() {
assert_eq!(parse_authorization_header_api_key(" Bearer abc "), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key("\tKey\tabc\t"), Some("abc".to_string()));
assert_eq!(parse_authorization_header_api_key(" abc "), Some("abc".to_string()));
}
#[test]
fn empty_or_blank_is_none() {
assert_eq!(parse_authorization_header_api_key(""), None);
assert_eq!(parse_authorization_header_api_key(" "), None);
}
#[test]
fn scheme_with_no_credential_is_none() {
assert_eq!(parse_authorization_header_api_key("Bearer"), None);
assert_eq!(parse_authorization_header_api_key("Bearer "), None);
assert_eq!(parse_authorization_header_api_key("Key"), None);
assert_eq!(parse_authorization_header_api_key("key "), None);
}
#[test]
fn unsupported_scheme_is_none() {
assert_eq!(parse_authorization_header_api_key("Basic dXNlcjpwYXNz"), None);
assert_eq!(parse_authorization_header_api_key("Digest abc"), None);
// Two non-scheme tokens — a bare key never contains whitespace.
assert_eq!(parse_authorization_header_api_key("abc def"), None);
}
}
// 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<String> {
ArtcraftApiKey::parse_from_authorization_header_value(header_value)
.map(|key| key.to_string_be_careful())
}
fn api_key_for_authorization_header(value: &str) -> Option<String> {
let http_request = TestRequest::default()
.insert_header(("Authorization", value))
.to_http_request();
get_authorization_header_api_key(&http_request).map(|key| key.to_string_be_careful())
}
}
@@ -1,5 +1,6 @@
pub mod bucket_urls;
pub mod filter_model_name;
pub mod get_authorization_header_api_key;
pub mod get_host_header;
pub mod get_request_platform_type;
pub mod handle_multipart_error;
@@ -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_be_careful().starts_with("artcraft_api_"));
assert!(generate_api_key().as_str_be_careful().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_be_careful().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_be_careful().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_be_careful().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_be_careful().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_be_careful().strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix");
assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric()));
}