Skip to content

Commit 98c36dc

Browse files
authored
fix: DPoP nonce retry race issue (#2580)
1 parent b06d30f commit 98c36dc

6 files changed

Lines changed: 243 additions & 235 deletions

File tree

src/server/auth-client-provider.test.ts

Lines changed: 0 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -503,105 +503,6 @@ describe("AuthClientProvider", () => {
503503
});
504504
});
505505

506-
describe("proxy fetchers", () => {
507-
it("should cache proxy fetcher by key", async () => {
508-
const provider = new AuthClientProvider({
509-
domain: "example.auth0.com",
510-
createAuthClient: createAuthClientMock
511-
});
512-
513-
const fetcher1 = { mock: "fetcher1" };
514-
const fetcher2 = { mock: "fetcher2" };
515-
516-
const factory1 = vi.fn().mockResolvedValue(fetcher1);
517-
const factory2 = vi.fn().mockResolvedValue(fetcher2);
518-
519-
const result1a = await provider.getProxyFetcher("key1", factory1);
520-
const result1b = await provider.getProxyFetcher("key1", factory1);
521-
const result2 = await provider.getProxyFetcher("key2", factory2);
522-
523-
expect(result1a).toBe(fetcher1);
524-
expect(result1b).toBe(fetcher1); // Same fetcher from cache
525-
expect(result1a).toBe(result1b); // From cache
526-
expect(result2).toBe(fetcher2);
527-
expect(factory1).toHaveBeenCalledTimes(1); // Only called once
528-
expect(factory2).toHaveBeenCalledTimes(1);
529-
});
530-
531-
it("should not call factory if fetcher is cached", async () => {
532-
const provider = new AuthClientProvider({
533-
domain: "example.auth0.com",
534-
createAuthClient: createAuthClientMock
535-
});
536-
537-
const factory = vi.fn().mockResolvedValue({ mock: "fetcher" });
538-
539-
await provider.getProxyFetcher("key", factory);
540-
await provider.getProxyFetcher("key", factory);
541-
await provider.getProxyFetcher("key", factory);
542-
543-
expect(factory).toHaveBeenCalledTimes(1);
544-
});
545-
546-
it("should use LRU behavior for proxy fetchers", async () => {
547-
const provider = new AuthClientProvider({
548-
domain: "example.auth0.com",
549-
createAuthClient: createAuthClientMock
550-
});
551-
552-
// Fill cache to near capacity (MAX_PROXY_FETCHERS is 100)
553-
const factories: { [key: string]: any } = {};
554-
const fetchers: { [key: string]: any } = {};
555-
556-
// Create 99 fetchers to fill most of the cache
557-
for (let i = 0; i < 99; i++) {
558-
const key = `key${i}`;
559-
const factory = vi.fn().mockResolvedValue({ id: `fetcher-${i}` });
560-
factories[key] = factory;
561-
fetchers[key] = await provider.getProxyFetcher(key, factory);
562-
}
563-
564-
// Now we have 99 cached fetchers
565-
// Add fetcher A (will be candidate for eviction if we exceed limit)
566-
const factoryA = vi.fn().mockResolvedValue({ id: "fetcher-a" });
567-
const fetcherA = await provider.getProxyFetcher("keyA", factoryA);
568-
expect(fetcherA.id).toBe("fetcher-a");
569-
expect(factoryA).toHaveBeenCalledTimes(1);
570-
571-
// We now have 100 cached fetchers (at max)
572-
573-
// Access A again - should promote it to end
574-
const fetcherAAgain = await provider.getProxyFetcher("keyA", factoryA);
575-
expect(fetcherAAgain).toBe(fetcherA);
576-
expect(factoryA).toHaveBeenCalledTimes(1); // Still 1 call
577-
578-
// Add 2 more fetchers to trigger eviction
579-
// This should evict the oldest entries (key0 and key1)
580-
const factoryB = vi.fn().mockResolvedValue({ id: "fetcher-b" });
581-
const factoryC = vi.fn().mockResolvedValue({ id: "fetcher-c" });
582-
await provider.getProxyFetcher("keyB", factoryB);
583-
await provider.getProxyFetcher("keyC", factoryC);
584-
585-
// Now key0 should be evicted (it was the oldest)
586-
const factory0New = vi.fn().mockResolvedValue({ id: "fetcher-0-new" });
587-
const _fetcher0Again = await provider.getProxyFetcher(
588-
"key0",
589-
factory0New
590-
);
591-
// key0 should have been evicted and factory should be called
592-
expect(factory0New).toHaveBeenCalledTimes(1);
593-
594-
// A should still be cached (it was promoted)
595-
const factoryANew = vi.fn().mockResolvedValue({ id: "fetcher-a-new" });
596-
const fetcherAAgain2 = await provider.getProxyFetcher(
597-
"keyA",
598-
factoryANew
599-
);
600-
expect(fetcherAAgain2).toBe(fetcherA); // Same instance
601-
expect(factoryANew).not.toHaveBeenCalled(); // Factory not called - was cached
602-
});
603-
});
604-
605506
describe("mode detection", () => {
606507
it("should detect static mode", () => {
607508
const provider = new AuthClientProvider({

src/server/auth-client-provider.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,6 @@ interface AuthClientProviderOptions {
3434
*/
3535
const MAX_DOMAIN_CLIENTS = 100;
3636

37-
/**
38-
* Maximum number of proxy fetchers to cache.
39-
* This prevents unbounded memory growth when many unique audience/domain
40-
* combinations are used. Uses LRU eviction matching the domain clients pattern.
41-
*/
42-
const MAX_PROXY_FETCHERS = 100;
43-
4437
/**
4538
* AuthClientProvider manages creating and caching AuthClient instances for MCD mode.
4639
*
@@ -61,7 +54,6 @@ export class AuthClientProvider {
6154
private domainClients: LruMap<string, AuthClient>;
6255

6356
private createAuthClientFactory: (domain: string) => AuthClient;
64-
private proxyFetchers: LruMap<string, any>;
6557

6658
/**
6759
* Creates a new AuthClientProvider instance.
@@ -76,7 +68,6 @@ export class AuthClientProvider {
7668

7769
// Initialize LRU caches
7870
this.domainClients = new LruMap(MAX_DOMAIN_CLIENTS);
79-
this.proxyFetchers = new LruMap(MAX_PROXY_FETCHERS);
8071

8172
// Detect mode and validate configuration
8273
if (typeof options.domain === "string") {
@@ -192,33 +183,6 @@ export class AuthClientProvider {
192183
return newClient;
193184
}
194185

195-
/**
196-
* Gets a proxy fetcher from cache or creates one via the provided factory.
197-
*
198-
* Proxy fetchers are cached per key to avoid creating multiple instances
199-
* for the same audience or configuration.
200-
*
201-
* @param key - A unique key for the fetcher (e.g., "domain:audience")
202-
* @param factory - Factory function to create the fetcher if not cached
203-
* @returns The cached or newly created fetcher
204-
*
205-
* @internal
206-
*/
207-
async getProxyFetcher(
208-
key: string,
209-
factory: () => Promise<any>
210-
): Promise<any> {
211-
// Check cache first (LruMap.get() handles promotion)
212-
const fetcher = this.proxyFetchers.get(key);
213-
if (fetcher) {
214-
return fetcher;
215-
}
216-
// Create new fetcher (LruMap.set() handles eviction)
217-
const newFetcher = await factory();
218-
this.proxyFetchers.set(key, newFetcher);
219-
return newFetcher;
220-
}
221-
222186
/**
223187
* Resolves the domain from request headers using the resolver function.
224188
*

src/server/auth-client.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10039,6 +10039,66 @@ ykwV8CV22wKDubrDje1vchfTL/ygX6p27RKpJm8eAH7k3EwVeg3NDfNVzQ==
1003910039
expect(authClient["clientMetadata"][oauth.clockSkew]).toBe(15);
1004010040
expect(authClient["clientMetadata"][oauth.clockTolerance]).toBe(30);
1004110041
});
10042+
10043+
it("should ignore a provided dpopHandle when DPoP is disabled on the client", async () => {
10044+
const secret = await generateSecret(32);
10045+
const transactionStore = new TransactionStore({ secret });
10046+
const sessionStore = new StatelessSessionStore({ secret });
10047+
const dpopHandle = { privateKey: "test", publicKey: "test" } as any;
10048+
10049+
const authClient = new AuthClient({
10050+
transactionStore,
10051+
sessionStore,
10052+
domain: DEFAULT.domain,
10053+
clientId: DEFAULT.clientId,
10054+
clientSecret: DEFAULT.clientSecret,
10055+
secret,
10056+
appBaseUrl: DEFAULT.appBaseUrl,
10057+
routes: getDefaultRoutes(),
10058+
useDPoP: false,
10059+
fetch: getMockAuthorizationServer()
10060+
});
10061+
10062+
const fetcher = await authClient.fetcherFactory({
10063+
getAccessToken: vi.fn().mockResolvedValue("at_123"),
10064+
dpopHandle
10065+
});
10066+
10067+
expect((fetcher as any).config.dpopHandle).toBeUndefined();
10068+
expect((fetcher as any).hooks.isDpopEnabled()).toBe(false);
10069+
});
10070+
10071+
it("should ignore a provided dpopHandle when DPoP is disabled for the fetcher", async () => {
10072+
const secret = await generateSecret(32);
10073+
const transactionStore = new TransactionStore({ secret });
10074+
const sessionStore = new StatelessSessionStore({ secret });
10075+
const { generateDpopKeyPair } = await import("../utils/dpopRetry.js");
10076+
const dpopKeyPair = await generateDpopKeyPair();
10077+
const dpopHandle = { privateKey: "test", publicKey: "test" } as any;
10078+
10079+
const authClient = new AuthClient({
10080+
transactionStore,
10081+
sessionStore,
10082+
domain: DEFAULT.domain,
10083+
clientId: DEFAULT.clientId,
10084+
clientSecret: DEFAULT.clientSecret,
10085+
secret,
10086+
appBaseUrl: DEFAULT.appBaseUrl,
10087+
routes: getDefaultRoutes(),
10088+
useDPoP: true,
10089+
dpopKeyPair,
10090+
fetch: getMockAuthorizationServer()
10091+
});
10092+
10093+
const fetcher = await authClient.fetcherFactory({
10094+
getAccessToken: vi.fn().mockResolvedValue("at_123"),
10095+
useDPoP: false,
10096+
dpopHandle
10097+
});
10098+
10099+
expect((fetcher as any).config.dpopHandle).toBeUndefined();
10100+
expect((fetcher as any).hooks.isDpopEnabled()).toBe(false);
10101+
});
1004210102
});
1004310103
});
1004410104

src/server/auth-client.ts

Lines changed: 26 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ export class AuthClient {
331331

332332
private readonly mfaTokenTtl: number;
333333

334-
private proxyFetchers: { [audience: string]: Fetcher<Response> } = {};
334+
private proxyDpopHandles: { [audience: string]: oauth.DPoPHandle } = {};
335335

336336
/**
337337
* Maximum allowed response body size (1 MB). Responses exceeding this limit
@@ -2948,12 +2948,14 @@ export class AuthClient {
29482948
throw discoveryError;
29492949
}
29502950

2951+
const shouldUseDpop = this.useDPoP && (options.useDPoP ?? true);
2952+
29512953
const fetcherConfig: FetcherConfig<TOutput> = {
29522954
// Fetcher-scoped DPoP handle and nonce management
2953-
dpopHandle:
2954-
this.useDPoP && (options.useDPoP ?? true)
2955-
? oauth.DPoP(this.clientMetadata, this.dpopKeyPair!)
2956-
: undefined,
2955+
dpopHandle: shouldUseDpop
2956+
? (options.dpopHandle ??
2957+
oauth.DPoP(this.clientMetadata, this.dpopKeyPair!))
2958+
: undefined,
29572959
httpOptions: this.httpOptions,
29582960
allowInsecureRequests: this.allowInsecureRequests,
29592961
retryConfig: this.dpopOptions?.retry,
@@ -2964,7 +2966,7 @@ export class AuthClient {
29642966

29652967
const fetcherHooks: FetcherHooks = {
29662968
getAccessToken: options.getAccessToken,
2967-
isDpopEnabled: () => options.useDPoP ?? this.useDPoP ?? false
2969+
isDpopEnabled: () => shouldUseDpop
29682970
};
29692971

29702972
return new Fetcher<TOutput>(fetcherConfig, fetcherHooks);
@@ -3508,38 +3510,24 @@ export class AuthClient {
35083510
return tokenSetResponse.tokenSet;
35093511
};
35103512

3511-
// Get/create fetcher instance with domain-aware caching
3512-
// In MCD mode, use provider.getProxyFetcher for shared caching across AuthClient instances
3513-
// In static mode, use local proxyFetchers cache
3514-
const cacheKey = this.provider
3515-
? `${this.domain}:${options.audience}`
3516-
: options.audience;
3517-
3518-
let fetcher: Fetcher<Response>;
3519-
if (this.provider) {
3520-
fetcher = await this.provider.getProxyFetcher(cacheKey, async () => {
3521-
return this.fetcherFactory({
3522-
useDPoP: this.useDPoP,
3523-
fetch: this.fetch,
3524-
getAccessToken: getAccessToken
3525-
});
3526-
});
3527-
} else {
3528-
// Static mode or no provider: use local cache
3529-
fetcher = this.proxyFetchers[options.audience];
3530-
if (!fetcher) {
3531-
fetcher = await this.fetcherFactory({
3532-
useDPoP: this.useDPoP,
3533-
fetch: this.fetch,
3534-
getAccessToken: getAccessToken
3535-
});
3536-
this.proxyFetchers[options.audience] = fetcher;
3537-
}
3538-
}
3513+
// Cache only the DPoP handle so nonce state is shared across proxied
3514+
// requests for the same audience on this AuthClient instance. Always create
3515+
// a fresh request-bound fetcher so token resolution remains scoped to the
3516+
// current session instead of being shared or mutated.
3517+
const dpopHandle =
3518+
this.useDPoP && this.dpopKeyPair
3519+
? (this.proxyDpopHandles[options.audience] ??= oauth.DPoP(
3520+
this.clientMetadata,
3521+
this.dpopKeyPair
3522+
))
3523+
: undefined;
35393524

3540-
// Override getAccessToken for the current request
3541-
// @ts-expect-error Override fetcher's getAccessToken to capture token set side effects
3542-
fetcher.getAccessToken = getAccessToken;
3525+
const fetcher = await this.fetcherFactory({
3526+
useDPoP: this.useDPoP,
3527+
fetch: this.fetch,
3528+
getAccessToken,
3529+
dpopHandle
3530+
});
35433531

35443532
try {
35453533
const response = await fetcher.fetchWithAuth(
@@ -3891,6 +3879,7 @@ type GetTokenSetResponse = {
38913879
export type FetcherFactoryOptions<TOutput extends Response> = {
38923880
useDPoP?: boolean;
38933881
getAccessToken: AccessTokenFactory;
3882+
dpopHandle?: oauth.DPoPHandle;
38943883
} & FetcherMinimalConfig<TOutput>;
38953884

38963885
/**

src/server/mcd.integration.test.ts

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -732,57 +732,6 @@ describe("MCD Integration Tests (Units 6-12)", () => {
732732

733733
expect(client.domain).toBe("example.com");
734734
});
735-
736-
it("U11-9: Proxy fetcher cached by key", async () => {
737-
const createAuthClient = vi.fn(
738-
() =>
739-
({
740-
domain: "example.com"
741-
}) as any
742-
);
743-
744-
const provider = new AuthClientProvider({
745-
domain: "example.com",
746-
createAuthClient
747-
});
748-
749-
const factory = vi.fn(async () => ({ fetch: vi.fn() }) as any);
750-
const fetcher1 = await provider.getProxyFetcher(
751-
"domain1:audience1",
752-
factory
753-
);
754-
const fetcher2 = await provider.getProxyFetcher(
755-
"domain1:audience1",
756-
factory
757-
);
758-
759-
expect(fetcher1).toBe(fetcher2);
760-
expect(factory).toHaveBeenCalledTimes(1);
761-
});
762-
763-
it("U11-10: Different keys have separate fetchers", async () => {
764-
const createAuthClient = vi.fn(
765-
() =>
766-
({
767-
domain: "example.com"
768-
}) as any
769-
);
770-
771-
const provider = new AuthClientProvider({
772-
domain: "example.com",
773-
createAuthClient
774-
});
775-
776-
const factory1 = vi.fn(async () => ({ fetch: "fetcher1" }) as any);
777-
const factory2 = vi.fn(async () => ({ fetch: "fetcher2" }) as any);
778-
779-
const fetcher1 = await provider.getProxyFetcher("key1", factory1);
780-
const fetcher2 = await provider.getProxyFetcher("key2", factory2);
781-
782-
expect(fetcher1).not.toBe(fetcher2);
783-
expect(factory1).toHaveBeenCalledTimes(1);
784-
expect(factory2).toHaveBeenCalledTimes(1);
785-
});
786735
});
787736

788737
// ===== Unit 12 Tests: openid Scope Enforcement =====

0 commit comments

Comments
 (0)