@@ -35,6 +35,7 @@ import { AppManager } from "../../app";
3535import { CacheManager } from "../../cache" ;
3636import { getUserContext } from "../../context/execution-context" ;
3737import { ServiceContext } from "../../context/service-context" ;
38+ import { AuthenticationError } from "../../errors/authentication" ;
3839import { StreamManager } from "../../stream" ;
3940import type { ITelemetry , TelemetryProvider } from "../../telemetry" ;
4041import { TelemetryManager } from "../../telemetry" ;
@@ -124,6 +125,17 @@ class CallablePlugin extends Plugin<BasePluginConfig> {
124125 }
125126}
126127
128+ /**
129+ * Exposes the `protected resolveUserId(req)` so the whitespace-normalization
130+ * tests can assert on the resolved identity directly (the value that feeds
131+ * the analytics per-user cache key / executorKey).
132+ */
133+ class ResolveProbePlugin extends ProbePlugin {
134+ resolve ( req : express . Request ) : string {
135+ return this . resolveUserId ( req ) ;
136+ }
137+ }
138+
127139/** Exports returns `undefined` — must be treated as `{}`. */
128140class NullExportsPlugin extends Plugin < BasePluginConfig > {
129141 exports ( ) : undefined {
@@ -152,6 +164,41 @@ function createReqWithoutToken(): express.Request {
152164 } as unknown as express . Request ;
153165}
154166
167+ /**
168+ * OBO request with a caller-supplied `x-forwarded-user` value. Used by the
169+ * whitespace-normalization tests to feed padded / whitespace-only identities
170+ * through the same code path as `createReqWithObo`.
171+ */
172+ function createReqWithUser ( forwardedUser : string ) : express . Request {
173+ return {
174+ header : ( name : string ) => {
175+ const map : Record < string , string > = {
176+ "x-forwarded-access-token" : "user-token-abc" ,
177+ "x-forwarded-user" : forwardedUser ,
178+ "x-forwarded-email" : "alice@example.com" ,
179+ } ;
180+ return map [ name . toLowerCase ( ) ] ;
181+ } ,
182+ } as unknown as express . Request ;
183+ }
184+
185+ /**
186+ * OBO request with a caller-supplied `x-forwarded-access-token` value (and a
187+ * valid user). Used by the token-trim tests.
188+ */
189+ function createReqWithToken ( forwardedToken : string ) : express . Request {
190+ return {
191+ header : ( name : string ) => {
192+ const map : Record < string , string > = {
193+ "x-forwarded-access-token" : forwardedToken ,
194+ "x-forwarded-user" : "alice" ,
195+ "x-forwarded-email" : "alice@example.com" ,
196+ } ;
197+ return map [ name . toLowerCase ( ) ] ;
198+ } ,
199+ } as unknown as express . Request ;
200+ }
201+
155202describe ( "Plugin.asUser proxy" , ( ) => {
156203 let mockTelemetry : ITelemetry ;
157204 let mockCache : CacheManager ;
@@ -584,4 +631,143 @@ describe("Plugin.asUser proxy", () => {
584631 expect ( exports . label ) . toBe ( "hello" ) ;
585632 } ) ;
586633 } ) ;
634+
635+ // ── Whitespace normalization of x-forwarded-user (PR0 / OBO hardening) ──
636+ //
637+ // The core OBO path trims `x-forwarded-user` at both read sites
638+ // (`resolveUserId` and `asUser`). These tests lock that behavior so a
639+ // whitespace-variant header can never (a) mint a distinct identity or
640+ // (b) fork the per-user analytics cache key (which derives from the
641+ // value `resolveUserId` returns — see analytics `executorKey`).
642+ describe ( "x-forwarded-user whitespace normalization" , ( ) => {
643+ test ( "resolveUserId trims a padded x-forwarded-user to the bare id" , ( ) => {
644+ const plugin = new ResolveProbePlugin ( config ) ;
645+
646+ expect ( plugin . resolve ( createReqWithUser ( " alice " ) ) ) . toBe ( "alice" ) ;
647+ } ) ;
648+
649+ test ( "resolveUserId returns the same id for padded and unpadded headers" , ( ) => {
650+ const plugin = new ResolveProbePlugin ( config ) ;
651+
652+ const padded = plugin . resolve ( createReqWithUser ( " alice " ) ) ;
653+ const unpadded = plugin . resolve ( createReqWithUser ( "alice" ) ) ;
654+
655+ // Same resolved id => same analytics per-user cache key (the cache key
656+ // derives from this resolved id via `executorKey`), so whitespace can
657+ // neither fork the cache nor bypass per-user isolation.
658+ expect ( padded ) . toBe ( unpadded ) ;
659+ expect ( padded ) . toBe ( "alice" ) ;
660+ } ) ;
661+
662+ test ( "asUser with a padded header builds the same identity as the unpadded case" , ( ) => {
663+ const plugin = new ProbePlugin ( config ) ;
664+
665+ const padded = plugin . asUser ( createReqWithUser ( " alice " ) ) . observeSync ( ) ;
666+ const unpadded = plugin . asUser ( createReqWithUser ( "alice" ) ) . observeSync ( ) ;
667+
668+ // The identity flowing into the user context is the trimmed value.
669+ expect ( padded ) . toBe ( "alice" ) ;
670+ expect ( unpadded ) . toBe ( "alice" ) ;
671+ expect ( padded ) . toBe ( unpadded ) ;
672+ } ) ;
673+
674+ test ( "asUser passes the trimmed id into ServiceContext.createUserContext" , ( ) => {
675+ const plugin = new ProbePlugin ( config ) ;
676+
677+ plugin . asUser ( createReqWithUser ( " alice " ) ) ;
678+
679+ // The first positional arg is the token, the second is the user id —
680+ // it must be the trimmed value, never the padded " alice ".
681+ expect ( serviceContextMock . createUserContextSpy ) . toHaveBeenCalledWith (
682+ "user-token-abc" ,
683+ "alice" ,
684+ undefined ,
685+ "alice@example.com" ,
686+ ) ;
687+ } ) ;
688+
689+ describe ( "whitespace-only header takes the missing-header path" , ( ) => {
690+ let originalNodeEnv : string | undefined ;
691+
692+ beforeEach ( ( ) => {
693+ originalNodeEnv = process . env . NODE_ENV ;
694+ } ) ;
695+
696+ afterEach ( ( ) => {
697+ process . env . NODE_ENV = originalNodeEnv ;
698+ } ) ;
699+
700+ test ( "resolveUserId throws AuthenticationError in production" , ( ) => {
701+ process . env . NODE_ENV = "production" ;
702+ const plugin = new ResolveProbePlugin ( config ) ;
703+
704+ // A whitespace-only header trims to "" (falsy) and is treated as
705+ // missing — it must NOT become a " " identity.
706+ expect ( ( ) => plugin . resolve ( createReqWithUser ( " " ) ) ) . toThrow (
707+ AuthenticationError ,
708+ ) ;
709+ // The message must be the purpose-built missingUserId() text, not the
710+ // doubled "Missing Missing … in request headers" the old missingToken()
711+ // call produced.
712+ expect ( ( ) => plugin . resolve ( createReqWithUser ( " " ) ) ) . toThrow (
713+ / U s e r I D n o t a v a i l a b l e i n r e q u e s t h e a d e r s / ,
714+ ) ;
715+ } ) ;
716+
717+ test ( "resolveUserId falls back to the context user id in development" , ( ) => {
718+ process . env . NODE_ENV = "development" ;
719+ const plugin = new ResolveProbePlugin ( config ) ;
720+
721+ // Dev fallback resolves to the current context user id (here: the
722+ // mocked service principal), never the raw " " header.
723+ const resolved = plugin . resolve ( createReqWithUser ( " " ) ) ;
724+ expect ( resolved ) . not . toBe ( " " ) ;
725+ expect ( resolved ) . toBe ( serviceContextMock . serviceContext . serviceUserId ) ;
726+ } ) ;
727+ } ) ;
728+ } ) ;
729+
730+ // `asUser` also trims `x-forwarded-access-token` at read time, so a
731+ // whitespace-only token is treated as missing (not forwarded to the SDK as a
732+ // bogus credential), and a padded token is normalized before it reaches
733+ // ServiceContext.
734+ describe ( "x-forwarded-access-token whitespace handling" , ( ) => {
735+ let originalNodeEnv : string | undefined ;
736+
737+ beforeEach ( ( ) => {
738+ originalNodeEnv = process . env . NODE_ENV ;
739+ } ) ;
740+
741+ afterEach ( ( ) => {
742+ process . env . NODE_ENV = originalNodeEnv ;
743+ } ) ;
744+
745+ test ( "asUser throws in production when the token is whitespace-only" , ( ) => {
746+ process . env . NODE_ENV = "production" ;
747+ const plugin = new ProbePlugin ( config ) ;
748+
749+ // " " trims to "" (falsy) → treated as a missing token rather than
750+ // being forwarded to ServiceContext.createUserContext as a bogus value.
751+ expect ( ( ) => plugin . asUser ( createReqWithToken ( " " ) ) ) . toThrow (
752+ AuthenticationError ,
753+ ) ;
754+ expect ( ( ) => plugin . asUser ( createReqWithToken ( " " ) ) ) . toThrow (
755+ / M i s s i n g u s e r t o k e n i n r e q u e s t h e a d e r s / ,
756+ ) ;
757+ } ) ;
758+
759+ test ( "asUser passes the trimmed token into ServiceContext.createUserContext" , ( ) => {
760+ const plugin = new ProbePlugin ( config ) ;
761+
762+ plugin . asUser ( createReqWithToken ( " user-token-abc " ) ) ;
763+
764+ // First positional arg is the token — it must be the trimmed value.
765+ expect ( serviceContextMock . createUserContextSpy ) . toHaveBeenCalledWith (
766+ "user-token-abc" ,
767+ "alice" ,
768+ undefined ,
769+ "alice@example.com" ,
770+ ) ;
771+ } ) ;
772+ } ) ;
587773} ) ;
0 commit comments