@@ -6,7 +6,7 @@ import { requireRole } from '../middleware/rbac';
66import { validate } from '../middleware/validate' ;
77import logger from '../config/logger' ;
88import { createApiKeySchema } from '../schemas/api-key' ;
9- import { NotFoundError } from '../errors' ;
9+ import { NotFoundError , BadRequestError } from '../errors' ;
1010import { auditApiKeyEvent } from '../services/audit-service' ;
1111import { createApiKeyLimiter } from '../middleware/rate-limit-factory' ;
1212
@@ -66,7 +66,7 @@ router.post(
6666 return res . status ( 201 ) . json ( { success : true , key, scopes } ) ;
6767 } catch ( error ) {
6868 logger . error ( 'Create API key error:' , error ) ;
69- return res . status ( 500 ) . json ( { error : String ( error ) || 'Internal server error' } ) ;
69+ return res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
7070 }
7171 } ,
7272) ;
@@ -139,4 +139,80 @@ router.get('/:id/usage', requireRole('owner', 'admin'), requireScope('subscripti
139139 res . json ( { success : true , data } ) ;
140140} ) ;
141141
142+ /**
143+ * POST /api/keys/:id/rotate
144+ * Atomically revoke an existing key and issue a replacement with the same
145+ * name and scopes. The old key stops working immediately; the new plaintext
146+ * key is returned once and never stored.
147+ */
148+ router . post (
149+ '/:id/rotate' ,
150+ requireRole ( 'owner' , 'admin' ) ,
151+ requireScope ( 'subscriptions:write' ) ,
152+ async ( req : AuthenticatedRequest , res : Response ) => {
153+ const { data : existing , error : fetchError } = await supabase
154+ . from ( 'api_keys' )
155+ . select ( 'id, service_name, scopes, revoked' )
156+ . eq ( 'id' , req . params . id )
157+ . eq ( 'user_id' , req . user ! . id )
158+ . maybeSingle ( ) ;
159+
160+ if ( fetchError || ! existing ) {
161+ throw new NotFoundError ( 'API key not found' ) ;
162+ }
163+
164+ if ( existing . revoked ) {
165+ throw new BadRequestError ( 'Cannot rotate a revoked API key' ) ;
166+ }
167+
168+ const { key : newKey , hash : newHash } = generateApiKey ( ) ;
169+
170+ // Revoke the old key
171+ const { error : revokeError } = await supabase
172+ . from ( 'api_keys' )
173+ . update ( { revoked : true , updated_at : new Date ( ) . toISOString ( ) } )
174+ . eq ( 'id' , req . params . id )
175+ . eq ( 'user_id' , req . user ! . id ) ;
176+
177+ if ( revokeError ) {
178+ logger . error ( 'Failed to revoke old API key during rotation' , { error : revokeError } ) ;
179+ return res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
180+ }
181+
182+ // Insert replacement key
183+ const { error : insertError } = await supabase . from ( 'api_keys' ) . insert ( [
184+ {
185+ user_id : req . user ! . id ,
186+ service_name : existing . service_name ,
187+ key_hash : newHash ,
188+ scopes : existing . scopes ,
189+ revoked : false ,
190+ last_used_at : null ,
191+ request_count : 0 ,
192+ } ,
193+ ] ) ;
194+
195+ if ( insertError ) {
196+ logger . error ( 'Failed to insert replacement API key during rotation' , { error : insertError } ) ;
197+ // Attempt to un-revoke the original to avoid a total lockout
198+ await supabase
199+ . from ( 'api_keys' )
200+ . update ( { revoked : false , updated_at : new Date ( ) . toISOString ( ) } )
201+ . eq ( 'id' , req . params . id )
202+ . eq ( 'user_id' , req . user ! . id ) ;
203+ return res . status ( 500 ) . json ( { error : 'Internal server error' } ) ;
204+ }
205+
206+ await auditApiKeyEvent ( 'api_key.rotated' , req . user ! . id , {
207+ oldKeyId : req . params . id ,
208+ keyName : existing . service_name ,
209+ scopes : existing . scopes ,
210+ ipAddress : req . ip ,
211+ userAgent : req . headers [ 'user-agent' ] ,
212+ } ) ;
213+
214+ return res . status ( 201 ) . json ( { success : true , key : newKey , scopes : existing . scopes } ) ;
215+ } ,
216+ ) ;
217+
142218export default router ;
0 commit comments