@@ -34,27 +34,48 @@ import { createAuthProbe } from './authProbe';
3434// test wants. This is mocking at a real boundary (a service), which the
3535// AGENTS.md guidance explicitly allows.
3636
37+ type AuthResultLike =
38+ | { actor : Actor }
39+ | { reauth : { reason : string ; auth_id ?: string } }
40+ | { invalid : true } ;
41+
3742interface StubAuth {
3843 service : AuthService ;
3944 seenTokens : string [ ] ;
40- /** What the next call to authenticateFromToken should return . */
45+ /** Legacy setter — accepts the old Actor|null|'throw' shape . */
4146 setNext : ( next : Actor | null | 'throw' ) => void ;
47+ /** AUTH-4: set the full AuthResult to be returned by `authenticate()`. */
48+ setNextResult : ( next : AuthResultLike | 'throw' ) => void ;
4249}
4350
4451const makeStubAuth = ( defaultActor : Actor | null = null ) : StubAuth => {
4552 const seenTokens : string [ ] = [ ] ;
46- let nextResult : Actor | null | 'throw' = defaultActor ;
53+ let nextResult : AuthResultLike | 'throw' = defaultActor
54+ ? { actor : defaultActor }
55+ : { invalid : true } ;
4756 const service = {
48- authenticateFromToken : async ( token : string ) => {
57+ // AUTH-4 entry point used by the probe.
58+ authenticate : async ( token : string ) => {
4959 seenTokens . push ( token ) ;
5060 if ( nextResult === 'throw' ) throw new Error ( 'verify failed' ) ;
5161 return nextResult ;
5262 } ,
63+ // Back-compat wrapper for callers that still want Actor | null.
64+ authenticateFromToken : async ( token : string ) => {
65+ seenTokens . push ( token ) ;
66+ if ( nextResult === 'throw' ) throw new Error ( 'verify failed' ) ;
67+ return 'actor' in nextResult ? nextResult . actor : null ;
68+ } ,
5369 } as unknown as AuthService ;
5470 return {
5571 service,
5672 seenTokens,
5773 setNext : ( n ) => {
74+ if ( n === 'throw' ) nextResult = 'throw' ;
75+ else if ( n === null ) nextResult = { invalid : true } ;
76+ else nextResult = { actor : n } ;
77+ } ,
78+ setNextResult : ( n ) => {
5879 nextResult = n ;
5980 } ,
6081 } ;
@@ -522,6 +543,224 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
522543 } ) ;
523544} ) ;
524545
546+ // ── AUTH-4: reauth signal + KV counters ─────────────────────────────
547+
548+ describe ( 'createAuthProbe — AUTH-4 reauth signal' , ( ) => {
549+ /** Capture KV increments without a real store. */
550+ const makeKvStub = ( ) => {
551+ const calls : Array < {
552+ key : string ;
553+ pathAndAmountMap : Record < string , number > ;
554+ } > = [ ] ;
555+ return {
556+ calls,
557+ store : {
558+ incr : async ( args : {
559+ key : string ;
560+ pathAndAmountMap : Record < string , number > ;
561+ } ) => {
562+ calls . push ( args ) ;
563+ return { res : { } as Record < string , number > , usage : 0 } ;
564+ } ,
565+ } ,
566+ } ;
567+ } ;
568+
569+ it ( 'sets requiresReauth and counts v1 + reauth.token_v1 for a legacy v1 token' , async ( ) => {
570+ const stub = makeStubAuth ( ) ;
571+ stub . setNextResult ( {
572+ reauth : { reason : 'token_v1' , auth_id : 'u-legacy' } ,
573+ // Some legacy paths resolve an actor anyway (lazy-backfill).
574+ // The probe must still set requiresReauth; gate emits 401.
575+ actor : { user : { uuid : 'u-legacy' } } ,
576+ } as never ) ;
577+ const kv = makeKvStub ( ) ;
578+ const probe = createAuthProbe ( {
579+ authService : stub . service ,
580+ kvStore : kv . store ,
581+ } ) ;
582+ const { req } = await runProbe (
583+ probe ,
584+ makeReq ( { headers : { authorization : 'Bearer v1-tok' } } ) ,
585+ ) ;
586+ expect ( req . requiresReauth ) . toEqual ( {
587+ reason : 'token_v1' ,
588+ auth_id : 'u-legacy' ,
589+ } ) ;
590+ // Both increments fire under the same day-bucketed key.
591+ expect ( kv . calls ) . toHaveLength ( 1 ) ;
592+ expect ( kv . calls [ 0 ] . key ) . toMatch ( / ^ a u t h - v 2 : m e t r i c s : \d { 4 } - \d { 2 } - \d { 2 } $ / ) ;
593+ expect ( kv . calls [ 0 ] . pathAndAmountMap ) . toEqual ( {
594+ v1 : 1 ,
595+ 'reauth.token_v1' : 1 ,
596+ } ) ;
597+ } ) ;
598+
599+ it ( 'sets requiresReauth and counts reauth.session_revoked' , async ( ) => {
600+ const stub = makeStubAuth ( ) ;
601+ stub . setNextResult ( {
602+ reauth : { reason : 'session_revoked' , auth_id : 'u-1' } ,
603+ } ) ;
604+ const kv = makeKvStub ( ) ;
605+ const probe = createAuthProbe ( {
606+ authService : stub . service ,
607+ kvStore : kv . store ,
608+ } ) ;
609+ const { req } = await runProbe (
610+ probe ,
611+ makeReq ( { headers : { authorization : 'Bearer tok' } } ) ,
612+ ) ;
613+ expect ( req . requiresReauth ?. reason ) . toBe ( 'session_revoked' ) ;
614+ expect ( kv . calls [ 0 ] . pathAndAmountMap ) . toEqual ( {
615+ v1 : 1 ,
616+ 'reauth.session_revoked' : 1 ,
617+ } ) ;
618+ } ) ;
619+
620+ it ( 'sets requiresReauth and counts reauth.session_expired' , async ( ) => {
621+ const stub = makeStubAuth ( ) ;
622+ stub . setNextResult ( {
623+ reauth : { reason : 'session_expired' , auth_id : 'u-1' } ,
624+ } ) ;
625+ const kv = makeKvStub ( ) ;
626+ const probe = createAuthProbe ( {
627+ authService : stub . service ,
628+ kvStore : kv . store ,
629+ } ) ;
630+ const { req } = await runProbe (
631+ probe ,
632+ makeReq ( { headers : { authorization : 'Bearer tok' } } ) ,
633+ ) ;
634+ expect ( req . requiresReauth ?. reason ) . toBe ( 'session_expired' ) ;
635+ expect ( kv . calls [ 0 ] . pathAndAmountMap ) . toEqual ( {
636+ v1 : 1 ,
637+ 'reauth.session_expired' : 1 ,
638+ } ) ;
639+ } ) ;
640+
641+ it ( 'counts v2 verify (no reauth) for a healthy token' , async ( ) => {
642+ const stub = makeStubAuth ( { user : { uuid : 'u-1' } } ) ;
643+ const kv = makeKvStub ( ) ;
644+ const probe = createAuthProbe ( {
645+ authService : stub . service ,
646+ kvStore : kv . store ,
647+ } ) ;
648+ const { req } = await runProbe (
649+ probe ,
650+ makeReq ( { headers : { authorization : 'Bearer good-tok' } } ) ,
651+ ) ;
652+ expect ( req . actor ) . toBeTruthy ( ) ;
653+ expect ( req . requiresReauth ) . toBeUndefined ( ) ;
654+ expect ( kv . calls [ 0 ] . pathAndAmountMap ) . toEqual ( { v2 : 1 } ) ;
655+ } ) ;
656+
657+ it ( 'does not increment counters when no token is presented' , async ( ) => {
658+ const stub = makeStubAuth ( ) ;
659+ const kv = makeKvStub ( ) ;
660+ const probe = createAuthProbe ( {
661+ authService : stub . service ,
662+ kvStore : kv . store ,
663+ } ) ;
664+ await runProbe ( probe , makeReq ( { } ) ) ;
665+ // Anonymous-OK routes pay no metrics cost.
666+ expect ( kv . calls ) . toHaveLength ( 0 ) ;
667+ } ) ;
668+
669+ it ( 'absorbs KV failures — never rejects on the hot path' , async ( ) => {
670+ const stub = makeStubAuth ( { user : { uuid : 'u-1' } } ) ;
671+ // Failing KV: increments throw. Probe must still succeed.
672+ const failingKv = {
673+ incr : async ( ) => {
674+ throw new Error ( 'kv unavailable' ) ;
675+ } ,
676+ } ;
677+ const probe = createAuthProbe ( {
678+ authService : stub . service ,
679+ kvStore : failingKv ,
680+ } ) ;
681+ const { req, next } = await runProbe (
682+ probe ,
683+ makeReq ( { headers : { authorization : 'Bearer good-tok' } } ) ,
684+ ) ;
685+ expect ( next ) . toHaveBeenCalledWith ( ) ;
686+ expect ( req . actor ) . toBeTruthy ( ) ;
687+ } ) ;
688+
689+ it ( 'works without a kvStore (counters become no-ops)' , async ( ) => {
690+ const stub = makeStubAuth ( { user : { uuid : 'u-1' } } ) ;
691+ // Production may not always pass a KV — fresh installs without
692+ // dynamo wired still need a functioning probe.
693+ const probe = createAuthProbe ( { authService : stub . service } ) ;
694+ const { req } = await runProbe (
695+ probe ,
696+ makeReq ( { headers : { authorization : 'Bearer good-tok' } } ) ,
697+ ) ;
698+ expect ( req . actor ) . toBeTruthy ( ) ;
699+ expect ( req . requiresReauth ) . toBeUndefined ( ) ;
700+ } ) ;
701+
702+ it ( 'logs `[auth-v2] reauth reason=<r> auth_id=<id>` per event' , async ( ) => {
703+ // The log line is the human-facing forensic counterpart to the
704+ // KV counter. Asserting the exact shape so ops can `grep
705+ // '\[auth-v2\] reauth'` and trust the format won't drift.
706+ const stub = makeStubAuth ( ) ;
707+ stub . setNextResult ( {
708+ reauth : { reason : 'session_revoked' , auth_id : 'u-grep' } ,
709+ } ) ;
710+ const infoSpy = vi . spyOn ( console , 'info' ) . mockImplementation ( ( ) => { } ) ;
711+ try {
712+ const probe = createAuthProbe ( { authService : stub . service } ) ;
713+ await runProbe (
714+ probe ,
715+ makeReq ( { headers : { authorization : 'Bearer tok' } } ) ,
716+ ) ;
717+ expect ( infoSpy ) . toHaveBeenCalledWith (
718+ '[auth-v2] reauth reason=session_revoked auth_id=u-grep' ,
719+ ) ;
720+ } finally {
721+ infoSpy . mockRestore ( ) ;
722+ }
723+ } ) ;
724+
725+ it ( 'logs `auth_id=-` when the reauth result has no auth_id' , async ( ) => {
726+ const stub = makeStubAuth ( ) ;
727+ stub . setNextResult ( {
728+ reauth : { reason : 'session_expired' } ,
729+ } ) ;
730+ const infoSpy = vi . spyOn ( console , 'info' ) . mockImplementation ( ( ) => { } ) ;
731+ try {
732+ const probe = createAuthProbe ( { authService : stub . service } ) ;
733+ await runProbe (
734+ probe ,
735+ makeReq ( { headers : { authorization : 'Bearer tok' } } ) ,
736+ ) ;
737+ expect ( infoSpy ) . toHaveBeenCalledWith (
738+ '[auth-v2] reauth reason=session_expired auth_id=-' ,
739+ ) ;
740+ } finally {
741+ infoSpy . mockRestore ( ) ;
742+ }
743+ } ) ;
744+
745+ it ( 'does not emit a reauth log line on a healthy v2 verify' , async ( ) => {
746+ const stub = makeStubAuth ( { user : { uuid : 'u-1' } } ) ;
747+ const infoSpy = vi . spyOn ( console , 'info' ) . mockImplementation ( ( ) => { } ) ;
748+ try {
749+ const probe = createAuthProbe ( { authService : stub . service } ) ;
750+ await runProbe (
751+ probe ,
752+ makeReq ( { headers : { authorization : 'Bearer good-tok' } } ) ,
753+ ) ;
754+ const reauthCalls = infoSpy . mock . calls . filter ( ( args ) =>
755+ String ( args [ 0 ] ) . startsWith ( '[auth-v2] reauth' ) ,
756+ ) ;
757+ expect ( reauthCalls ) . toHaveLength ( 0 ) ;
758+ } finally {
759+ infoSpy . mockRestore ( ) ;
760+ }
761+ } ) ;
762+ } ) ;
763+
525764// ── Server-backed end-to-end (real AuthService + DB) ────────────────
526765//
527766// The unit tests above cover the extraction logic in isolation. This
0 commit comments