Skip to content

Commit 5394ccc

Browse files
authored
feat: add verification for v2 auth (#3155)
1 parent 3649109 commit 5394ccc

11 files changed

Lines changed: 885 additions & 145 deletions

File tree

src/backend/core/http/expressAugmentation.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,40 +35,18 @@ declare global {
3535
// eslint-disable-next-line @typescript-eslint/no-namespace
3636
namespace Express {
3737
interface Request {
38-
/**
39-
* Populated by the global auth probe when a valid token is
40-
* attached to the request (Bearer header, auth_token body/query,
41-
* session cookie, or socket handshake). Absent for anonymous
42-
* requests — route-level gates decide whether to reject.
43-
*/
4438
actor?: Actor;
4539

4640
/** The raw token string, if one was presented and parsed. */
4741
token?: string;
4842

49-
/**
50-
* Set by the global auth probe when a token was extracted from
51-
* the request but failed to resolve to an actor (bad signature,
52-
* expired, references a deleted session/user/app, or has a
53-
* legacy shape v2 can't authenticate). Lets `requireAuthGate`
54-
* distinguish "no token" from "token present but invalid" and
55-
* emit the legacy `token_auth_failed` error so old clients
56-
* trigger their re-login flow.
57-
*/
5843
tokenAuthFailed?: boolean;
5944

60-
/**
61-
* Raw request body bytes, captured by the global JSON parser's
62-
* `verify` callback. Available for any JSON request — needed by
63-
* webhook handlers that verify an HMAC over the exact bytes the
64-
* sender signed (e.g., AppStore prod webhooks, BroadcastService
65-
* inter-instance hooks).
66-
*
67-
* Set only when the global JSON parser actually ran (request had
68-
* `Content-Type: application/json` or one of the recognized
69-
* JSON-as-text variants). For non-JSON requests it stays
70-
* `undefined`.
71-
*/
45+
requiresReauth?: {
46+
reason: 'token_v1' | 'session_revoked' | 'session_expired';
47+
auth_id?: string;
48+
};
49+
7250
rawBody?: Buffer;
7351

7452
/** Parsed user-agent, populated by the global UA-parsing middleware. */

src/backend/core/http/middleware/authProbe.test.ts

Lines changed: 242 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
3742
interface 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

4451
const 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(/^auth-v2:metrics:\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

Comments
 (0)