@@ -160,6 +160,27 @@ pub struct TokenRecord {
160160 pub parent_token_id : Option < String > ,
161161}
162162
163+ /// A stored API-key record. Never exposes the secret — only its display prefix.
164+ #[ derive( Debug , Clone , Serialize , Deserialize ) ]
165+ pub struct InboundApiKey {
166+ pub key_id : String ,
167+ pub client_id : String ,
168+ pub key_prefix : String ,
169+ pub label : Option < String > ,
170+ pub revoked : bool ,
171+ pub last_used_at : Option < String > ,
172+ pub expires_at : Option < String > ,
173+ pub created_at : String ,
174+ pub updated_at : String ,
175+ }
176+
177+ /// Identity resolved from a presented API key.
178+ #[ derive( Debug , Clone ) ]
179+ pub struct ApiKeyAuth {
180+ pub key_id : String ,
181+ pub client_id : String ,
182+ }
183+
163184/// OAuth Repository with database persistence
164185pub struct InboundClientRepository {
165186 db : Arc < Mutex < Database > > ,
@@ -566,6 +587,126 @@ impl InboundClientRepository {
566587 hex:: encode ( hasher. finalize ( ) )
567588 }
568589
590+ // =========================================================================
591+ // Inbound client API keys (long-lived, host-issued bearer credentials)
592+ // =========================================================================
593+
594+ /// SHA-256 hex of an API key — the only form ever persisted. Same algorithm
595+ /// as `hash_token`; named separately for intent.
596+ pub fn hash_api_key ( key : & str ) -> String {
597+ Self :: hash_token ( key)
598+ }
599+
600+ /// Persist a freshly-generated API key for a client. The caller generates
601+ /// the random `plaintext` (shown to the user once) and a unique `key_id`;
602+ /// only the SHA-256 hash + a display prefix are stored.
603+ pub async fn create_api_key (
604+ & self ,
605+ key_id : & str ,
606+ client_id : & str ,
607+ plaintext : & str ,
608+ key_prefix : & str ,
609+ label : Option < & str > ,
610+ expires_at : Option < & str > ,
611+ ) -> Result < ( ) > {
612+ let now = chrono:: Utc :: now ( ) . to_rfc3339 ( ) ;
613+ let hash = Self :: hash_api_key ( plaintext) ;
614+ let db = self . db . lock ( ) . await ;
615+ let conn = db. connection ( ) ;
616+ conn. execute (
617+ "INSERT INTO inbound_client_api_keys
618+ (key_id, client_id, key_hash, key_prefix, label, revoked, expires_at, created_at, updated_at)
619+ VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?7)" ,
620+ params ! [ key_id, client_id, hash, key_prefix, label, expires_at, now] ,
621+ ) ?;
622+ info ! (
623+ "[ApiKey] Created key {} for client {}" ,
624+ key_prefix, client_id
625+ ) ;
626+ Ok ( ( ) )
627+ }
628+
629+ /// Validate a presented API key: look up a live (non-revoked, unexpired) key
630+ /// by hash, touch `last_used_at`, and return the owning client.
631+ pub async fn validate_api_key ( & self , presented : & str ) -> Result < Option < ApiKeyAuth > > {
632+ let hash = Self :: hash_api_key ( presented) ;
633+ let now = chrono:: Utc :: now ( ) . to_rfc3339 ( ) ;
634+ let db = self . db . lock ( ) . await ;
635+ let conn = db. connection ( ) ;
636+
637+ let result = conn. query_row (
638+ "SELECT key_id, client_id, expires_at
639+ FROM inbound_client_api_keys
640+ WHERE key_hash = ?1 AND revoked = 0" ,
641+ params ! [ hash] ,
642+ |r| {
643+ Ok ( (
644+ r. get :: < _ , String > ( 0 ) ?,
645+ r. get :: < _ , String > ( 1 ) ?,
646+ r. get :: < _ , Option < String > > ( 2 ) ?,
647+ ) )
648+ } ,
649+ ) ;
650+ let ( key_id, client_id, expires_at) = match result {
651+ Ok ( t) => t,
652+ Err ( rusqlite:: Error :: QueryReturnedNoRows ) => return Ok ( None ) ,
653+ Err ( e) => return Err ( e. into ( ) ) ,
654+ } ;
655+
656+ // ISO-8601 strings compare lexicographically; reject expired keys.
657+ if let Some ( exp) = expires_at. as_deref ( ) {
658+ if exp <= now. as_str ( ) {
659+ return Ok ( None ) ;
660+ }
661+ }
662+
663+ conn. execute (
664+ "UPDATE inbound_client_api_keys SET last_used_at = ?1 WHERE key_id = ?2" ,
665+ params ! [ now, key_id] ,
666+ ) ?;
667+ Ok ( Some ( ApiKeyAuth { key_id, client_id } ) )
668+ }
669+
670+ /// List a client's API keys (no secrets — prefix + metadata only).
671+ pub async fn list_api_keys ( & self , client_id : & str ) -> Result < Vec < InboundApiKey > > {
672+ let db = self . db . lock ( ) . await ;
673+ let conn = db. connection ( ) ;
674+ let mut stmt = conn. prepare (
675+ "SELECT key_id, client_id, key_prefix, label, revoked, last_used_at, expires_at, created_at, updated_at
676+ FROM inbound_client_api_keys WHERE client_id = ?1 ORDER BY created_at DESC" ,
677+ ) ?;
678+ let rows = stmt. query_map ( params ! [ client_id] , |r| {
679+ Ok ( InboundApiKey {
680+ key_id : r. get ( 0 ) ?,
681+ client_id : r. get ( 1 ) ?,
682+ key_prefix : r. get ( 2 ) ?,
683+ label : r. get ( 3 ) ?,
684+ revoked : r. get :: < _ , i32 > ( 4 ) ? != 0 ,
685+ last_used_at : r. get ( 5 ) ?,
686+ expires_at : r. get ( 6 ) ?,
687+ created_at : r. get ( 7 ) ?,
688+ updated_at : r. get ( 8 ) ?,
689+ } )
690+ } ) ?;
691+ let mut keys = Vec :: new ( ) ;
692+ for k in rows {
693+ keys. push ( k?) ;
694+ }
695+ Ok ( keys)
696+ }
697+
698+ /// Revoke a single API key (irreversible — it can never authenticate again).
699+ pub async fn revoke_api_key ( & self , key_id : & str ) -> Result < ( ) > {
700+ let now = chrono:: Utc :: now ( ) . to_rfc3339 ( ) ;
701+ let db = self . db . lock ( ) . await ;
702+ let conn = db. connection ( ) ;
703+ conn. execute (
704+ "UPDATE inbound_client_api_keys SET revoked = 1, updated_at = ?1 WHERE key_id = ?2" ,
705+ params ! [ now, key_id] ,
706+ ) ?;
707+ Ok ( ( ) )
708+ }
709+
569710 /// Save a token record
570711 pub async fn save_token ( & self , record : & TokenRecord ) -> Result < ( ) > {
571712 let db = self . db . lock ( ) . await ;
0 commit comments