Skip to content

Commit 2f030e2

Browse files
committed
fix: do not return 401 on bad aud in refresh token
1 parent a467d41 commit 2f030e2

File tree

3 files changed

+85
-18
lines changed

3 files changed

+85
-18
lines changed

.changeset/polite-adults-relate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@labdigital/federated-token-apollo": patch
3+
---
4+
5+
Do not return a 401 on bad aud, only clear the refresh token.

packages/apollo/src/gateway.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import * as crypto from "node:crypto";
22
import { ApolloServer, HeaderMap } from "@apollo/server";
33
import {
4+
CompositeTokenSource,
45
KeyManager,
56
PublicFederatedToken,
67
TokenSigner,
78
} from "@labdigital/federated-token";
8-
import { HeaderTokenSource } from "@labdigital/federated-token-express-adapter";
9+
import {
10+
CookieTokenSource,
11+
HeaderTokenSource,
12+
} from "@labdigital/federated-token-express-adapter";
913
import type { Request, Response } from "express";
1014
import httpMocks from "node-mocks-http";
1115
import { assert, describe, expect, it } from "vitest";
@@ -272,6 +276,69 @@ describe("GatewayAuthPlugin", async () => {
272276
assert.notEqual(newAccessToken, accessToken);
273277
});
274278

279+
it("should clear invalid refresh token and let request reach resolver", async () => {
280+
const wrongAudienceSigner = new TokenSigner({
281+
...signOptions,
282+
audience: "wrongAudience",
283+
});
284+
285+
const token = new PublicFederatedToken();
286+
token.setRefreshToken("commercetools", "stale-refresh-value");
287+
const staleRefreshToken = await token.createRefreshJWT(wrongAudienceSigner);
288+
289+
const cookieSource = new CookieTokenSource({
290+
refreshTokenPath: "/auth/graphql",
291+
secure: false,
292+
sameSite: "lax",
293+
});
294+
295+
const cookiePlugin = new GatewayAuthPlugin({
296+
signer: signer,
297+
source: new CompositeTokenSource([cookieSource]),
298+
});
299+
300+
const cookieServer = new ApolloServer({
301+
typeDefs,
302+
resolvers,
303+
plugins: [cookiePlugin],
304+
});
305+
306+
const context = {
307+
federatedToken: new PublicFederatedToken(),
308+
res: httpMocks.createResponse(),
309+
req: httpMocks.createRequest({
310+
cookies: {
311+
refreshToken: staleRefreshToken,
312+
},
313+
}),
314+
};
315+
316+
const response = await cookieServer.executeOperation(
317+
{
318+
query: 'query hello { hello(name: "world") }',
319+
http: {
320+
headers: new HeaderMap(),
321+
method: "POST",
322+
search: "",
323+
body: "",
324+
},
325+
},
326+
{
327+
contextValue: context,
328+
},
329+
);
330+
331+
// The request should reach the resolver instead of being blocked with a 401
332+
assert(response.body.kind === "single");
333+
expect(response.body.singleResult.data?.hello).toBe("Hello world");
334+
expect(response.body.singleResult.errors).toBeUndefined();
335+
336+
// The refresh token cookie should be cleared
337+
expect(context.res.cookies.refreshToken).toBeDefined();
338+
expect(context.res.cookies.refreshToken.value).toBe("");
339+
expect(context.res.cookies.refreshToken.options.expires).toBeDefined();
340+
});
341+
275342
it("should return GraphQLError when token expired", async () => {
276343
const context = {
277344
federatedToken: new PublicFederatedToken(),

packages/apollo/src/gateway.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -114,28 +114,23 @@ export class GatewayAuthPlugin<
114114
try {
115115
await token.loadRefreshJWT(this.signer, refreshToken);
116116
} catch (e: unknown) {
117-
this.logger?.error({
118-
msg: "Error during loading of the refresh token",
117+
this.logger?.warn({
118+
msg: "Error during loading of the refresh token, clearing token",
119119
refreshToken: maskToken(refreshToken),
120120
err: e,
121121
});
122122

123+
// Clear the invalid refresh token but don't return a 401.
124+
// Returning a 401 here blocks the entire request before it
125+
// reaches any resolver. This prevents operations like
126+
// CustomerLogout from completing, which in turn prevents
127+
// willSendResponse from properly clearing all related
128+
// cookies (including the non-httpOnly userRefreshTokenExists
129+
// flag cookie). The result is that the client keeps
130+
// retrying refresh with the same stale cookie on every
131+
// subsequent request, permanently blocking flows like
132+
// password reset.
123133
this.tokenSource.deleteRefreshToken(contextValue.req, contextValue.res);
124-
return {
125-
didResolveOperation: async (
126-
requestContext: GraphQLRequestContextDidResolveSource<TContext>,
127-
) => {
128-
requestContext.response.http.status = 401;
129-
throw new GraphQLError("Your refresh token is invalid.", {
130-
extensions: {
131-
code: "INVALID_TOKEN",
132-
http: {
133-
statusCode: 400,
134-
},
135-
},
136-
});
137-
},
138-
};
139134
}
140135
}
141136

0 commit comments

Comments
 (0)