Skip to content

Commit 8b81410

Browse files
committed
fix(apollo): return GraphQLError if token invalid
We can only return `GraphQLError` in the `didResolveOperation` handler, so return a listener with exactly that
1 parent a6b6b48 commit 8b81410

File tree

6 files changed

+186
-34
lines changed

6 files changed

+186
-34
lines changed

.changeset/neat-lies-train.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": minor
3+
---
4+
5+
Properly return GraphQLError when token is expired/invalid

.changeset/pre.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"mode": "pre",
3+
"tag": "1.6.0-beta.0",
4+
"initialVersions": {
5+
"@labdigital/federated-token-apollo": "1.4.3",
6+
"@labdigital/federated-token": "1.4.3",
7+
"@labdigital/federated-token-react": "1.5.0",
8+
"@labdigital/federated-token-yoga": "1.4.3"
9+
},
10+
"changesets": []
11+
}

packages/apollo/src/gateway.test.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ describe("GatewayAuthPlugin", async () => {
3535

3636
const typeDefs = `#graphql
3737
type Query {
38+
hello(name: String): String!
3839
testToken(create: Boolean, value: String): String!
3940
refreshToken: String!
4041
}
42+
type Mutation {
43+
createToken(create: Boolean, value: String, expired: Boolean): String!
44+
}
4145
`;
4246
const resolvers = {
4347
Query: {
@@ -52,7 +56,7 @@ describe("GatewayAuthPlugin", async () => {
5256
if (create) {
5357
context.federatedToken.setAccessToken("foo", {
5458
token: "bar",
55-
exp: Date.now() + 1000,
59+
exp: Math.floor(Date.now() / 1000 + 1000),
5660
sub: "my-user-id",
5761
});
5862
context.federatedToken.setRefreshToken("foo", "bar");
@@ -64,15 +68,53 @@ describe("GatewayAuthPlugin", async () => {
6468

6569
return JSON.stringify(context.federatedToken);
6670
},
71+
hello: (
72+
_: unknown,
73+
{ name }: { name: string },
74+
context: PublicFederatedTokenContext,
75+
) => {
76+
return `Hello ${name}`;
77+
},
6778
refreshToken: (_: unknown, context: PublicFederatedTokenContext) => {
6879
context.federatedToken?.setAccessToken("foo", {
6980
token: "bar",
70-
exp: Date.now() + 1000,
81+
exp: Math.floor(Date.now() / 1000 - 1000),
7182
sub: "my-user-id",
7283
});
7384
return JSON.stringify(context.federatedToken);
7485
},
7586
},
87+
Mutation: {
88+
createToken: (
89+
_: unknown,
90+
{
91+
create,
92+
value,
93+
expired,
94+
}: { create: boolean; value: string; expired: boolean },
95+
context: PublicFederatedTokenContext,
96+
) => {
97+
if (!context.federatedToken) {
98+
throw new Error("No federated token");
99+
}
100+
if (create) {
101+
context.federatedToken.setAccessToken("foo", {
102+
token: "bar",
103+
exp: Math.floor(
104+
expired ? Date.now() / 1000 - 1000 : Date.now() / 1000 + 1000,
105+
),
106+
sub: "my-user-id",
107+
});
108+
context.federatedToken.setRefreshToken("foo", "bar");
109+
}
110+
111+
if (value) {
112+
context.federatedToken.setValue("value", value);
113+
}
114+
115+
return JSON.stringify(context.federatedToken);
116+
},
117+
},
76118
};
77119

78120
const testServer = new ApolloServer({
@@ -222,4 +264,69 @@ describe("GatewayAuthPlugin", async () => {
222264
assert.isNotEmpty(newAccessToken);
223265
assert.notEqual(newAccessToken, accessToken);
224266
});
267+
268+
it("should return GraphQLError when token expired", async () => {
269+
const context = {
270+
federatedToken: new PublicFederatedToken(),
271+
res: httpMocks.createResponse(),
272+
req: httpMocks.createRequest(),
273+
};
274+
await testServer.executeOperation(
275+
{
276+
query:
277+
"mutation createToken { createToken(create: true, expired: true) }",
278+
http: {
279+
headers: new HeaderMap(),
280+
method: "POST",
281+
search: "",
282+
body: "",
283+
},
284+
},
285+
{
286+
contextValue: context,
287+
},
288+
);
289+
expect(context.res.statusCode).toBe(200);
290+
expect(context.res.get("x-access-token")).toBeDefined();
291+
expect(context.res.get("x-refresh-token")).toBeDefined();
292+
const accessToken = context.res.get("x-access-token");
293+
294+
// Set received token
295+
const newContext = {
296+
federatedToken: new PublicFederatedToken(),
297+
res: httpMocks.createResponse(),
298+
req: httpMocks.createRequest({
299+
headers: {
300+
"x-access-token": `Bearer ${accessToken}`,
301+
},
302+
}),
303+
};
304+
305+
const response = await testServer.executeOperation(
306+
{
307+
query: 'query hello { hello(name: "foobar") }',
308+
http: {
309+
headers: new HeaderMap([["x-access-token", `Bearer ${accessToken}`]]),
310+
method: "POST",
311+
search: "",
312+
body: "",
313+
},
314+
},
315+
{
316+
contextValue: newContext,
317+
},
318+
);
319+
320+
expect(response.http.status).toBe(401);
321+
assert(response.body.kind === "single"); // Make typescript happy
322+
const errors = response.body.singleResult.errors;
323+
expect(errors).toStrictEqual([
324+
{
325+
extensions: {
326+
code: "UNAUTHENTICATED",
327+
},
328+
message: "Your token has expired.",
329+
},
330+
]);
331+
});
225332
});

packages/apollo/src/gateway.ts

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
ApolloServerPlugin,
33
GraphQLRequestContext,
4+
GraphQLRequestContextDidResolveSource,
45
GraphQLRequestListener,
56
} from "@apollo/server";
67
import { PublicFederatedToken } from "@labdigital/federated-token";
@@ -60,23 +61,37 @@ export class GatewayAuthPlugin<TContext extends PublicFederatedTokenContext>
6061
this.tokenSource.deleteAccessToken(contextValue.req, contextValue.res);
6162

6263
if (e instanceof TokenExpiredError) {
63-
throw new GraphQLError("Your token has expired.", {
64-
extensions: {
65-
code: "UNAUTHENTICATED",
66-
http: {
67-
statusCode: 401,
68-
},
64+
return {
65+
didResolveOperation: async (
66+
requestContext: GraphQLRequestContextDidResolveSource<TContext>,
67+
) => {
68+
requestContext.response.http.status = 401;
69+
throw new GraphQLError("Your token has expired.", {
70+
extensions: {
71+
code: "UNAUTHENTICATED",
72+
http: {
73+
statusCode: 401,
74+
},
75+
},
76+
});
6977
},
70-
});
78+
};
7179
} else {
72-
throw new GraphQLError("Your token is invalid.", {
73-
extensions: {
74-
code: "INVALID_TOKEN",
75-
http: {
76-
statusCode: 400,
77-
},
80+
return {
81+
didResolveOperation: async (
82+
requestContext: GraphQLRequestContextDidResolveSource<TContext>,
83+
) => {
84+
requestContext.response.http.status = 401;
85+
throw new GraphQLError("Your token is invalid.", {
86+
extensions: {
87+
code: "INVALID_TOKEN",
88+
http: {
89+
statusCode: 400,
90+
},
91+
},
92+
});
7893
},
79-
});
94+
};
8095
}
8196
}
8297
}
@@ -95,23 +110,37 @@ export class GatewayAuthPlugin<TContext extends PublicFederatedTokenContext>
95110
} catch (e: unknown) {
96111
this.tokenSource.deleteDataToken(contextValue.req, contextValue.res);
97112
if (e instanceof TokenExpiredError) {
98-
throw new GraphQLError("Your token has expired.", {
99-
extensions: {
100-
code: "UNAUTHENTICATED",
101-
http: {
102-
statusCode: 401,
103-
},
113+
return {
114+
didResolveOperation: async (
115+
requestContext: GraphQLRequestContextDidResolveSource<TContext>,
116+
) => {
117+
requestContext.response.http.status = 401;
118+
throw new GraphQLError("Your token has expired.", {
119+
extensions: {
120+
code: "UNAUTHENTICATED",
121+
http: {
122+
statusCode: 401,
123+
},
124+
},
125+
});
104126
},
105-
});
127+
};
106128
} else {
107-
throw new GraphQLError("Your token is invalid.", {
108-
extensions: {
109-
code: "INVALID_TOKEN",
110-
http: {
111-
statusCode: 400,
112-
},
129+
return {
130+
didResolveOperation: async (
131+
requestContext: GraphQLRequestContextDidResolveSource<TContext>,
132+
) => {
133+
requestContext.response.http.status = 401;
134+
throw new GraphQLError("Your token is invalid.", {
135+
extensions: {
136+
code: "INVALID_TOKEN",
137+
http: {
138+
statusCode: 400,
139+
},
140+
},
141+
});
113142
},
114-
});
143+
};
115144
}
116145
}
117146
}

packages/core/src/jwt.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ describe("PublicFederatedToken", async () => {
2424

2525
test("createAccessJWT", async () => {
2626
const token = new PublicFederatedToken();
27-
const expireAt = Date.now() + 60;
27+
const expireAt = Math.floor(Date.now() / 1000 + 60);
2828
token.tokens = {
2929
exampleName: {
3030
token: "exampleToken",
@@ -46,7 +46,7 @@ describe("PublicFederatedToken", async () => {
4646
});
4747

4848
const token = new PublicFederatedToken();
49-
const expireAt = Date.now() + 60;
49+
const expireAt = Math.floor(Date.now() / 1000 + 60);
5050
token.tokens = {
5151
exampleName: {
5252
token: "exampleToken",
@@ -73,7 +73,7 @@ describe("PublicFederatedToken", async () => {
7373
},
7474
},
7575
},
76-
Date.now() + 1000,
76+
Math.floor(Date.now() / 1000 + 1000),
7777
);
7878

7979
const token = new PublicFederatedToken();

packages/core/src/sign.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class TokenSigner {
5050
contentEncryptionAlgorithms: ["A256GCM"],
5151
},
5252
);
53-
const data = new TextDecoder().decode(result.plaintext.buffer);
53+
const data = new TextDecoder().decode(result.plaintext);
5454
return JSON.parse(data);
5555
} catch (e) {
5656
throw createError(e);

0 commit comments

Comments
 (0)