Skip to main content

cratefield_core/
api_key.rs

1//! API-key auth for developer-facing APIs (issue #532): hashed bearer
2//! keys with a public prefix, per-key scopes and a test/live mode.
3//!
4//! A key is `{namespace}_{mode}_{id}_{secret}`, e.g.
5//! `pos_live_3f9a0c1d2e4b5a6c_<64 hex>`. The **prefix** is public —
6//! stored in the clear and the lookup key; the **secret** (32 random
7//! bytes) is shown once at issue time and never stored: the row holds
8//! SHA-256 over the whole token, re-derived and compared in constant
9//! time at verify. SHA-256 rather than ADR 0200's argon2id is
10//! deliberate: 256 random bits leave nothing for a slow search to find,
11//! and argon2 per request would burn the Workers CPU budget on every
12//! call. This is **auth, not routing** (`docs/TENANT-ROUTING.md` §3
13//! keeps rejecting API keys for the host→tenant decision — the
14//! bootstrap is circular): a key identifies a subject *inside* the
15//! already-selected tenant's database.
16//!
17//! ```ignore
18//! let keys = ApiKeys::new(db, clock, Arc::new(OsRandom), "api_keys");
19//! let issued = keys.issue("pos", "acct_123", &["read", "write"], ApiKeyMode::Live).await?;
20//! // `issued.token` is returned exactly once; `issued.prefix` is public.
21//! // Per request, on a route declared `RoutePolicy::ApiKey`:
22//! let principal = require_api_key(&keys, &headers, "read").await?;
23//! // Per-key rate limiting slots into the issue #538 limiter:
24//! check_rate_limit(limiter.as_ref(), &[principal.rate_limit_key()],
25//!     RateLimitFailure::FailClosed).await;
26//! ```
27
28use std::fmt::Write as _;
29use std::sync::Arc;
30
31use axum::http::HeaderMap;
32use sea_query::{Alias, Expr, Query};
33use sha2::{Digest, Sha256};
34use time::OffsetDateTime;
35use time::format_description::well_known::Rfc3339;
36
37use crate::admin::{bearer_token, constant_time_eq};
38use crate::ports::{Clock, Database, DbError, Row, Statement};
39use crate::problem::Problem;
40use crate::problems::SLUGS;
41
42/// Random bytes in the id part of a generated key.
43const ID_BYTES: usize = 8;
44/// Random bytes in the secret part: 256 bits, the reason a slow KDF
45/// would buy nothing (see the module docs).
46const SECRET_BYTES: usize = 32;
47/// The longest namespace an app may choose.
48const MAX_NAMESPACE_CHARS: usize = 32;
49/// How stale `last_used_at` may get before a verify rewrites it.
50const TOUCH_WINDOW_SECONDS: i64 = 60;
51
52/// The columns every insert fills.
53const KEY_COLUMNS: [&str; 7] = [
54    "prefix",
55    "secret_hash",
56    "namespace",
57    "subject",
58    "scopes",
59    "mode",
60    "created_at",
61];
62
63fn iden(name: &str) -> Alias {
64    Alias::new(name)
65}
66
67/// Lowercase hex, the encoding of every key part this module emits.
68fn hex(bytes: &[u8]) -> String {
69    let mut out = String::with_capacity(bytes.len() * 2);
70    for byte in bytes {
71        let _ = write!(out, "{byte:02x}");
72    }
73    out
74}
75
76fn sha256_hex(bytes: &[u8]) -> String {
77    hex(&Sha256::digest(bytes))
78}
79
80/// RFC 3339 for a clock reading; an unformattable instant fails the
81/// operation rather than storing an empty stamp.
82fn stamp(at: OffsetDateTime) -> Result<String, DbError> {
83    at.format(&Rfc3339)
84        .map_err(|err| DbError::Execute(format!("clock reading is not RFC 3339: {err}")))
85}
86
87/// A NOT NULL text column of `row`; absent or unreadable is a schema
88/// mismatch — an error, never an empty hash that would silently 401.
89fn required(row: &Row, name: &str) -> Result<String, DbError> {
90    row.get::<Option<String>>(name)
91        .flatten()
92        .ok_or_else(|| DbError::Query(format!("api key row is missing its `{name}` column")))
93}
94
95/// Whether `s` is a valid namespace: `[a-z0-9]+`, at most
96/// [`MAX_NAMESPACE_CHARS`] of it.
97fn is_valid_namespace(s: &str) -> bool {
98    !s.is_empty()
99        && s.len() <= MAX_NAMESPACE_CHARS
100        && s.bytes()
101            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
102}
103
104fn is_lower_hex(s: &str) -> bool {
105    s.bytes()
106        .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
107}
108
109/// The CSPRNG [`ApiKeys`] draws key material from. Injected like every
110/// environment effect (ADR 0002) — core carries no entropy source, and
111/// the deployment (or a test) supplies its own. An app crate declaring
112/// `getrandom` (the workspace-wide leaf pattern) writes:
113///
114/// ```ignore
115/// struct OsRandom;
116/// impl cratefield_core::RandomBytes for OsRandom {
117///     fn fill(&self, dest: &mut [u8]) -> Result<(), RandomError> {
118///         getrandom::fill(dest).map_err(|err| RandomError(err.to_string()))
119///     }
120/// }
121/// ```
122pub trait RandomBytes: Send + Sync {
123    /// Fills `dest` with cryptographically random bytes.
124    ///
125    /// # Errors
126    ///
127    /// [`RandomError`] when the platform entropy source fails.
128    fn fill(&self, dest: &mut [u8]) -> Result<(), RandomError>;
129}
130
131/// Why an entropy draw failed: no key material existed yet, so the
132/// message carries only the platform's own words.
133#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
134#[error("entropy source failed: {0}")]
135pub struct RandomError(String);
136
137/// Why a key operation failed; in every variant, nothing was written.
138#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139#[non_exhaustive]
140pub enum ApiKeyError {
141    /// The namespace failed the `[a-z0-9]+` check.
142    #[error("invalid key namespace {0:?}: expected [a-z0-9]+")]
143    Namespace(String),
144    /// A scope was empty or contained whitespace, which the
145    /// space-separated storage could not round-trip.
146    #[error("invalid scope {0:?}: empty, or containing whitespace")]
147    Scope(String),
148    /// The prefix named no active key (unknown, or already revoked).
149    #[error("no active key with prefix {0:?}")]
150    UnknownKey(String),
151    /// The entropy source failed.
152    #[error(transparent)]
153    Random(#[from] RandomError),
154    /// A database statement (or an unformattable clock reading) failed.
155    #[error(transparent)]
156    Db(#[from] DbError),
157}
158
159/// Which environment a key belongs to: sandbox or production money.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ApiKeyMode {
162    /// A sandbox key: same shape, separate records.
163    Test,
164    /// A production key.
165    Live,
166}
167
168impl ApiKeyMode {
169    /// The token spelling of the mode.
170    #[must_use]
171    pub const fn as_str(self) -> &'static str {
172        match self {
173            Self::Test => "test",
174            Self::Live => "live",
175        }
176    }
177
178    /// Parses the token spelling; anything else is a malformed token.
179    fn parse(s: &str) -> Option<Self> {
180        match s {
181            "test" => Some(Self::Test),
182            "live" => Some(Self::Live),
183            _ => None,
184        }
185    }
186}
187
188/// A newly minted key: `token` is the only time the plaintext exists —
189/// show it once, store nothing; `prefix` is public and safe to display.
190#[derive(Clone, PartialEq, Eq)]
191pub struct IssuedKey {
192    /// The full plaintext token: `{namespace}_{mode}_{id}_{secret}`.
193    pub token: String,
194    /// The public prefix `{namespace}_{mode}_{id}`, exactly as stored.
195    pub prefix: String,
196}
197
198impl std::fmt::Debug for IssuedKey {
199    /// The plaintext token never reaches a log line (house rule, as
200    /// `KeyRing` and `HmacSigner` in `crate::signer`).
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        f.debug_struct("IssuedKey")
203            .field("prefix", &self.prefix)
204            .field("token", &"[redacted]")
205            .finish()
206    }
207}
208
209/// A verified key's answer to "who is calling, and what may they do?".
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct ApiKeyPrincipal {
212    /// The public prefix the request presented.
213    pub prefix: String,
214    /// The app-chosen namespace the key was issued under.
215    pub namespace: String,
216    /// The opaque subject (tenant/account id) the key speaks for.
217    pub subject: String,
218    /// What the key may do; the gate checks one of these per request.
219    pub scopes: Vec<String>,
220    /// Which environment the key belongs to.
221    pub mode: ApiKeyMode,
222}
223
224impl ApiKeyPrincipal {
225    /// Whether the key carries `scope`; scopes are exact-match strings.
226    #[must_use]
227    pub fn has_scope(&self, scope: &str) -> bool {
228        self.scopes.iter().any(|granted| granted == scope)
229    }
230
231    /// The per-key rate-limit key `apikey:<prefix>`: a
232    /// [`RateLimitPolicy`](crate::RateLimiter) arm matching that prefix
233    /// sets the key's budget, the way `rate_limit_keys`' `ip:`/`email:`
234    /// keys slot into the shared limiter (issue #538).
235    #[must_use]
236    pub fn rate_limit_key(&self) -> String {
237        format!("apikey:{}", self.prefix)
238    }
239
240    /// The [`Subject`](crate::ports::Subject) this key speaks for, for
241    /// code shared with the session auth port; the session id is the
242    /// prefix, so instant revocation already exists — revoke the key.
243    #[must_use]
244    pub fn to_subject(&self) -> crate::ports::Subject {
245        crate::ports::Subject::new(self.subject.clone()).session(self.prefix.clone())
246    }
247}
248
249/// A token that parsed. Nothing here is trustworthy until the hash
250/// compares; the prefix is only a lookup key.
251struct ParsedKey {
252    prefix: String,
253    namespace: String,
254    mode: ApiKeyMode,
255}
256
257/// Parses a public prefix `{namespace}_{mode}_{id}` into namespace and
258/// mode, checking every shape before the database is touched.
259fn parse_prefix(prefix: &str) -> Option<(String, ApiKeyMode)> {
260    let mut parts = prefix.split('_');
261    let namespace = parts.next()?;
262    let mode = parts.next()?;
263    let id = parts.next()?;
264    if parts.next().is_some()
265        || !is_valid_namespace(namespace)
266        || id.len() != ID_BYTES * 2
267        || !is_lower_hex(id)
268    {
269        return None;
270    }
271    Some((namespace.to_owned(), ApiKeyMode::parse(mode)?))
272}
273
274impl ParsedKey {
275    /// Parses `{namespace}_{mode}_{id}_{secret}` strictly: a malformed
276    /// token never costs a query.
277    fn parse(token: &str) -> Option<Self> {
278        let (prefix, secret) = token.rsplit_once('_')?;
279        if secret.len() != SECRET_BYTES * 2 || !is_lower_hex(secret) {
280            return None;
281        }
282        let (namespace, mode) = parse_prefix(prefix)?;
283        Some(Self {
284            prefix: prefix.to_owned(),
285            namespace,
286            mode,
287        })
288    }
289}
290
291/// The stored half of an API key: everything but the secret.
292struct KeyRow {
293    secret_hash: String,
294    subject: String,
295    scopes: String,
296    last_used_at: Option<String>,
297}
298
299/// A store of API keys over the [`Database`] port; the owning app ships
300/// [`create_table_sql`](Self::create_table_sql) as a migration.
301/// Timestamps are RFC 3339 `TEXT` from the [`Clock`] port, the house
302/// shape (`cratefield_core::SendCooldown` documents why lexicographic
303/// order on that format is chronological order).
304#[derive(Clone)]
305pub struct ApiKeys {
306    db: Arc<dyn Database>,
307    clock: Arc<dyn Clock>,
308    rng: Arc<dyn RandomBytes>,
309    table: String,
310}
311
312impl ApiKeys {
313    /// A key store over `db`, timed by `clock`, keyed by `rng`.
314    #[must_use]
315    pub fn new(
316        db: Arc<dyn Database>,
317        clock: Arc<dyn Clock>,
318        rng: Arc<dyn RandomBytes>,
319        table: impl Into<String>,
320    ) -> Self {
321        Self {
322            db,
323            clock,
324            rng,
325            table: table.into(),
326        }
327    }
328
329    /// The portable DDL for the key table (SQLite/D1/Postgres, ADR 0004),
330    /// shipped as a forward-only migration. Only the hash and the public
331    /// prefix are ever stored.
332    #[must_use]
333    pub fn create_table_sql(&self) -> String {
334        format!(
335            "CREATE TABLE IF NOT EXISTS {table} (\n    \
336             prefix TEXT PRIMARY KEY,\n    \
337             secret_hash TEXT NOT NULL,\n    \
338             namespace TEXT NOT NULL,\n    \
339             subject TEXT NOT NULL,\n    \
340             scopes TEXT NOT NULL,\n    \
341             mode TEXT NOT NULL,\n    \
342             created_at TEXT NOT NULL,\n    \
343             last_used_at TEXT,\n    \
344             revoked_at TEXT\n);",
345            table = self.table
346        )
347    }
348
349    /// Draws `ID_BYTES + SECRET_BYTES` fresh bytes and formats the id
350    /// and secret parts.
351    fn generate(&self) -> Result<(String, String), RandomError> {
352        let mut bytes = [0u8; ID_BYTES + SECRET_BYTES];
353        self.rng.fill(&mut bytes)?;
354        let (id, secret) = bytes.split_at(ID_BYTES);
355        Ok((hex(id), hex(secret)))
356    }
357
358    /// Mints a key for `subject` with `scopes`, and stores its hash; the
359    /// returned [`IssuedKey::token`] is shown exactly once.
360    ///
361    /// # Errors
362    ///
363    /// [`ApiKeyError::Namespace`] or [`ApiKeyError::Scope`] on a caller
364    /// mistake, [`ApiKeyError::Random`] or [`ApiKeyError::Db`] — in
365    /// every failure case no key was issued.
366    pub async fn issue(
367        &self,
368        namespace: &str,
369        subject: &str,
370        scopes: &[&str],
371        mode: ApiKeyMode,
372    ) -> Result<IssuedKey, ApiKeyError> {
373        if !is_valid_namespace(namespace) {
374            return Err(ApiKeyError::Namespace(namespace.to_owned()));
375        }
376        for scope in scopes {
377            if scope.is_empty() || scope.contains(char::is_whitespace) {
378                return Err(ApiKeyError::Scope((*scope).to_owned()));
379            }
380        }
381        let (id, secret) = self.generate()?;
382        let prefix = format!("{namespace}_{}_{id}", mode.as_str());
383        let token = format!("{prefix}_{secret}");
384        let now = stamp(self.clock.now())?;
385        let mut insert = Query::insert();
386        insert
387            .into_table(iden(&self.table))
388            .columns(KEY_COLUMNS)
389            .values_panic([
390                prefix.clone().into(),
391                sha256_hex(token.as_bytes()).into(),
392                namespace.to_owned().into(),
393                subject.to_owned().into(),
394                scopes.join(" ").into(),
395                mode.as_str().into(),
396                now.into(),
397            ]);
398        self.db.execute(&Statement::render(&insert)).await?;
399        Ok(IssuedKey { token, prefix })
400    }
401
402    /// Reads one active (unrevoked) key row by prefix.
403    async fn load(&self, prefix: &str) -> Result<Option<KeyRow>, DbError> {
404        let mut select = Query::select();
405        select
406            .columns([
407                "secret_hash",
408                "subject",
409                "scopes",
410                "last_used_at",
411                "revoked_at",
412            ])
413            .from(iden(&self.table))
414            .and_where(Expr::col(iden("prefix")).eq(prefix));
415        let rows = self.db.query(&Statement::render(&select)).await?;
416        let Some(row) = rows.first() else {
417            return Ok(None);
418        };
419        // `None` inside means SQL NULL; the `Option` outside means the
420        // column was absent or unreadable, which revokes nothing.
421        if let Some(Some(_)) = row.get::<Option<String>>("revoked_at") {
422            return Ok(None);
423        }
424        Ok(Some(KeyRow {
425            secret_hash: required(row, "secret_hash")?,
426            subject: required(row, "subject")?,
427            scopes: required(row, "scopes")?,
428            last_used_at: row.get::<Option<String>>("last_used_at").flatten(),
429        }))
430    }
431
432    /// Verifies one presented token: parse, look the prefix up, re-derive
433    /// the hash, compare in constant time; on success touch `last_used_at`
434    /// (once per window, best effort — logged, never a failed request).
435    ///
436    /// `Ok(None)` is every auth failure there is — malformed, unknown,
437    /// revoked, wrong secret — deliberately indistinguishable, so probing
438    /// a gate built on this learns nothing. A database failure is
439    /// [`Err`], and nothing about it says "unauthorized".
440    ///
441    /// # Errors
442    ///
443    /// [`DbError`] when the lookup fails.
444    pub async fn verify(&self, token: &str) -> Result<Option<ApiKeyPrincipal>, DbError> {
445        let Some(parsed) = ParsedKey::parse(token) else {
446            return Ok(None);
447        };
448        let Some(row) = self.load(&parsed.prefix).await? else {
449            return Ok(None);
450        };
451        let presented = sha256_hex(token.as_bytes());
452        if !constant_time_eq(presented.as_bytes(), row.secret_hash.as_bytes()) {
453            return Ok(None);
454        }
455        if let Err(err) = self
456            .touch(&parsed.prefix, row.last_used_at.as_deref())
457            .await
458        {
459            // The key is verified; a cosmetic write must not fail the
460            // request. The prefix is public, so naming it leaks nothing.
461            tracing::warn!(error = %err, prefix = %parsed.prefix, "api key last-used touch failed");
462        }
463        Ok(Some(ApiKeyPrincipal {
464            prefix: parsed.prefix,
465            namespace: parsed.namespace,
466            subject: row.subject,
467            scopes: row
468                .scopes
469                .split(' ')
470                .filter(|scope| !scope.is_empty())
471                .map(str::to_owned)
472                .collect(),
473            mode: parsed.mode,
474        }))
475    }
476
477    /// Whether `last_used_at` is stale enough to rewrite: never set,
478    /// older than the touch window, or unparseable (repaired).
479    fn touch_due(last_used_at: Option<&str>, now: OffsetDateTime) -> bool {
480        let Some(stored) = last_used_at else {
481            return true;
482        };
483        match OffsetDateTime::parse(stored, &Rfc3339) {
484            Ok(then) => (now - then).whole_seconds() >= TOUCH_WINDOW_SECONDS,
485            Err(_) => true,
486        }
487    }
488
489    /// The throttled `last_used_at` write behind [`Self::verify`].
490    async fn touch(&self, prefix: &str, last_used_at: Option<&str>) -> Result<(), DbError> {
491        let now = self.clock.now();
492        if !Self::touch_due(last_used_at, now) {
493            return Ok(());
494        }
495        let mut update = Query::update();
496        update
497            .table(iden(&self.table))
498            .value(iden("last_used_at"), stamp(now)?)
499            .and_where(Expr::col(iden("prefix")).eq(prefix));
500        self.db.execute(&Statement::render(&update)).await?;
501        Ok(())
502    }
503
504    /// Marks a key revoked: every later verify answers `Ok(None)`. The
505    /// row stays, so a later rotate of the same prefix is refused and an
506    /// audit can still see the key existed. Idempotent.
507    ///
508    /// # Errors
509    ///
510    /// [`ApiKeyError::UnknownKey`] when the prefix names no key,
511    /// [`ApiKeyError::Db`] when the write fails.
512    pub async fn revoke(&self, prefix: &str) -> Result<(), ApiKeyError> {
513        let mut update = Query::update();
514        update
515            .table(iden(&self.table))
516            .value(iden("revoked_at"), stamp(self.clock.now())?)
517            .and_where(Expr::col(iden("prefix")).eq(prefix));
518        if self.db.execute(&Statement::render(&update)).await? == 0 {
519            return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
520        }
521        Ok(())
522    }
523
524    /// Rotates a key: mints a fresh token for the same namespace, mode,
525    /// subject and scopes and revokes the old one, both statements in
526    /// one [`Database::batch_atomic`] batch (issue #126) — the old key
527    /// never keeps working while the new one does not exist yet, nor the
528    /// reverse. The copy is an `INSERT ... SELECT` keyed to the old row
529    /// still being active, so a rotate racing a revoke — or a second
530    /// rotate — mints nothing instead of a second live key.
531    ///
532    /// # Errors
533    ///
534    /// [`ApiKeyError::UnknownKey`] when the prefix names no active key
535    /// (a revoked one included — rotate the replacement instead),
536    /// [`ApiKeyError::Random`] or [`ApiKeyError::Db`] as [`Self::issue`].
537    pub async fn rotate(&self, prefix: &str) -> Result<IssuedKey, ApiKeyError> {
538        let Some((namespace, mode)) = parse_prefix(prefix) else {
539            return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
540        };
541        let (id, secret) = self.generate()?;
542        let new_prefix = format!("{namespace}_{}_{id}", mode.as_str());
543        let token = format!("{new_prefix}_{secret}");
544        let now = stamp(self.clock.now())?;
545        // Copy the still-active row into the new key.
546        let mut insert = Query::insert();
547        insert
548            .into_table(iden(&self.table))
549            .columns(KEY_COLUMNS)
550            .select_from(
551                Query::select()
552                    .expr(Expr::val(new_prefix.clone()))
553                    .expr(Expr::val(sha256_hex(token.as_bytes())))
554                    .expr(Expr::col(iden("namespace")))
555                    .expr(Expr::col(iden("subject")))
556                    .expr(Expr::col(iden("scopes")))
557                    .expr(Expr::col(iden("mode")))
558                    .expr(Expr::val(now.clone()))
559                    .from(iden(&self.table))
560                    .and_where(Expr::col(iden("prefix")).eq(prefix))
561                    .and_where(Expr::col(iden("revoked_at")).is_null())
562                    .to_owned(),
563            )
564            .map_err(|err| DbError::Execute(format!("rotate copy is malformed: {err}")))?;
565        let mut revoke_old = Query::update();
566        revoke_old
567            .table(iden(&self.table))
568            .value(iden("revoked_at"), now)
569            .and_where(Expr::col(iden("prefix")).eq(prefix))
570            .and_where(Expr::col(iden("revoked_at")).is_null());
571        self.db
572            .batch_atomic(&[Statement::render(&insert), Statement::render(&revoke_old)])
573            .await?;
574        // batch_atomic reports no row counts, so confirm the copy landed:
575        // an unknown or already-revoked prefix inserted nothing.
576        if self.load(&new_prefix).await?.is_none() {
577            return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
578        }
579        Ok(IssuedKey {
580            token,
581            prefix: new_prefix,
582        })
583    }
584}
585
586/// The per-request gate, the API-key sibling of
587/// `cratefield_core::require_admin`: reads `Authorization: Bearer
588/// <token>`, verifies it against `keys`, and demands `scope`. A missing
589/// header, malformed token, unknown prefix, revoked key or wrong secret
590/// all answer `401 api-key-unauthorized`, one uniform problem so a probe
591/// cannot tell which held; a valid key without `scope` answers
592/// `403 api-key-forbidden`; a store failure answers `503 not-ready`
593/// (generic detail — the error itself is only logged).
594///
595/// # Errors
596///
597/// The [`Problem`] described above; carry it straight into the handler's
598/// `Err`.
599pub async fn require_api_key(
600    keys: &ApiKeys,
601    headers: &HeaderMap,
602    scope: &str,
603) -> Result<ApiKeyPrincipal, Problem> {
604    let unauthorized = || Problem::new(&SLUGS.api_key_unauthorized);
605    let Some(token) = bearer_token(headers) else {
606        return Err(unauthorized());
607    };
608    let Some(principal) = keys.verify(token).await.map_err(|err| {
609        tracing::warn!(error = %err, "api key lookup failed");
610        Problem::new(&SLUGS.not_ready).with_detail("api key store lookup failed")
611    })?
612    else {
613        return Err(unauthorized());
614    };
615    if !principal.has_scope(scope) {
616        return Err(Problem::new(&SLUGS.api_key_forbidden)
617            .with_detail(format!("the key lacks the required scope {scope:?}")));
618    }
619    Ok(principal)
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn parse_accepts_the_documented_shape() {
628        let token = "pos_live_3f9a0c1d2e4b5a6c_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
629        let parsed = ParsedKey::parse(token).expect("parses");
630        assert_eq!(parsed.prefix, "pos_live_3f9a0c1d2e4b5a6c");
631        assert_eq!(parsed.namespace, "pos");
632        assert_eq!(parsed.mode, ApiKeyMode::Live);
633    }
634
635    #[test]
636    fn parse_rejects_every_malformed_shape() {
637        let good_secret = "ab".repeat(SECRET_BYTES);
638        let good_id = "cd".repeat(ID_BYTES);
639        let malformed: Vec<String> = vec![
640            String::new(),
641            format!("pos_live_{good_id}"),
642            format!("pos_live_{good_id}_{good_secret}_extra"),
643            format!("pos_test_{good_id}_short"),
644            format!("pos_test_{good_id}_{}", "AB".repeat(SECRET_BYTES)),
645            format!("pos_live_3f9a_{good_secret}"),
646            format!("pos_sand_{good_id}_{good_secret}"),
647            format!("Pos_live_{good_id}_{good_secret}"),
648            format!("pos-api_live_{good_id}_{good_secret}"),
649            format!(
650                "{}s_live_{good_id}_{good_secret}",
651                "a".repeat(MAX_NAMESPACE_CHARS)
652            ),
653            format!("pos_live_{good_id}_{good_secret} "),
654        ];
655        for token in &malformed {
656            assert!(ParsedKey::parse(token).is_none(), "{token:?} parsed");
657        }
658    }
659}