diff --git a/.changeset/polite-adults-relate.md b/.changeset/polite-adults-relate.md new file mode 100644 index 0000000..4276ff7 --- /dev/null +++ b/.changeset/polite-adults-relate.md @@ -0,0 +1,5 @@ +--- +"@labdigital/federated-token-apollo": patch +--- + +Do not return a 401 on bad aud, only clear the refresh token. diff --git a/packages/apollo/src/gateway.test.ts b/packages/apollo/src/gateway.test.ts index 76bad5d..cb641ee 100644 --- a/packages/apollo/src/gateway.test.ts +++ b/packages/apollo/src/gateway.test.ts @@ -1,11 +1,15 @@ import * as crypto from "node:crypto"; import { ApolloServer, HeaderMap } from "@apollo/server"; import { + CompositeTokenSource, KeyManager, PublicFederatedToken, TokenSigner, } from "@labdigital/federated-token"; -import { HeaderTokenSource } from "@labdigital/federated-token-express-adapter"; +import { + CookieTokenSource, + HeaderTokenSource, +} from "@labdigital/federated-token-express-adapter"; import type { Request, Response } from "express"; import httpMocks from "node-mocks-http"; import { assert, describe, expect, it } from "vitest"; @@ -272,6 +276,69 @@ describe("GatewayAuthPlugin", async () => { assert.notEqual(newAccessToken, accessToken); }); + it("should clear invalid refresh token and let request reach resolver", async () => { + const wrongAudienceSigner = new TokenSigner({ + ...signOptions, + audience: "wrongAudience", + }); + + const token = new PublicFederatedToken(); + token.setRefreshToken("commercetools", "stale-refresh-value"); + const staleRefreshToken = await token.createRefreshJWT(wrongAudienceSigner); + + const cookieSource = new CookieTokenSource({ + refreshTokenPath: "/auth/graphql", + secure: false, + sameSite: "lax", + }); + + const cookiePlugin = new GatewayAuthPlugin({ + signer: signer, + source: new CompositeTokenSource([cookieSource]), + }); + + const cookieServer = new ApolloServer({ + typeDefs, + resolvers, + plugins: [cookiePlugin], + }); + + const context = { + federatedToken: new PublicFederatedToken(), + res: httpMocks.createResponse(), + req: httpMocks.createRequest({ + cookies: { + refreshToken: staleRefreshToken, + }, + }), + }; + + const response = await cookieServer.executeOperation( + { + query: 'query hello { hello(name: "world") }', + http: { + headers: new HeaderMap(), + method: "POST", + search: "", + body: "", + }, + }, + { + contextValue: context, + }, + ); + + // The request should reach the resolver instead of being blocked with a 401 + assert(response.body.kind === "single"); + expect(response.body.singleResult.data?.hello).toBe("Hello world"); + expect(response.body.singleResult.errors).toBeUndefined(); + + // The refresh token cookie should be cleared + expect(context.res.cookies.refreshToken).toBeDefined(); + expect(context.res.cookies.refreshToken.value).toBe(""); + expect(context.res.cookies.refreshToken.options.expires).toBeDefined(); + }); + it("should return GraphQLError when token expired", async () => { const context = { federatedToken: new PublicFederatedToken(), diff --git a/packages/apollo/src/gateway.ts b/packages/apollo/src/gateway.ts index 36d3402..4a0e195 100644 --- a/packages/apollo/src/gateway.ts +++ b/packages/apollo/src/gateway.ts @@ -114,28 +114,23 @@ export class GatewayAuthPlugin< try { await token.loadRefreshJWT(this.signer, refreshToken); } catch (e: unknown) { - this.logger?.error({ - msg: "Error during loading of the refresh token", + this.logger?.warn({ + msg: "Error during loading of the refresh token, clearing token", refreshToken: maskToken(refreshToken), err: e, }); + // Clear the invalid refresh token but don't return a 401. + // Returning a 401 here blocks the entire request before it + // reaches any resolver. This prevents operations like + // CustomerLogout from completing, which in turn prevents + // willSendResponse from properly clearing all related + // cookies (including the non-httpOnly userRefreshTokenExists + // flag cookie). The result is that the client keeps + // retrying refresh with the same stale cookie on every + // subsequent request, permanently blocking flows like + // password reset. this.tokenSource.deleteRefreshToken(contextValue.req, contextValue.res); - return { - didResolveOperation: async ( - requestContext: GraphQLRequestContextDidResolveSource, - ) => { - requestContext.response.http.status = 401; - throw new GraphQLError("Your refresh token is invalid.", { - extensions: { - code: "INVALID_TOKEN", - http: { - statusCode: 400, - }, - }, - }); - }, - }; } }