Skip to content

Commit

Permalink
refactor: split cookies for auth vs guest users
Browse files Browse the repository at this point in the history
This is a pretty big refactor where we now use different cookie names
for authenticated users vs non-authenticated (guest) users. By using
differing cookie names we can pass only the cookies for authenticated
through a CDN, making sure that the CDN still caches pages for guest
users
  • Loading branch information
mvantellingen committed Oct 23, 2024
1 parent 1f72aa6 commit 62de2da
Show file tree
Hide file tree
Showing 17 changed files with 783 additions and 301 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
NOTES.md
node_modules/*
**/node_modules/*
**/dist/*
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ implemented via 4 cookies:
- `tokenFingerprint` - A random string that is used to protect the AccessToken
cookie from CSRF attacks. It is stored as HTTP_ONLY cookie.
- `refreshToken` - The refresh token, if any. It is stored as HTTP_ONLY cookie.
- `refreshTokenExists` - A boolean value that indicates if a refresh token
- `sessionRefreshTokenExists` - A boolean value that indicates if a refresh token
exists for the user. It is used to determine if the user is new or not.

Note that this expects the "cookie-parser" express middleware to be used.
5 changes: 3 additions & 2 deletions packages/apollo/src/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { KeyManager, TokenSigner } from "@labdigital/federated-token";
import { HeaderTokenSource } from "@labdigital/federated-token";
import { PublicFederatedTokenContext } from "./context";

describe("GatewayAuthPlugin", () => {
describe("GatewayAuthPlugin", async () => {
const signOptions = {
encryptKeys: new KeyManager([
{
Expand All @@ -25,6 +25,7 @@ describe("GatewayAuthPlugin", () => {
audience: "exampleAudience",
issuer: "exampleIssuer",
};

const signer = new TokenSigner(signOptions);

const plugin = new GatewayAuthPlugin({
Expand Down Expand Up @@ -216,7 +217,7 @@ describe("GatewayAuthPlugin", () => {
contextValue: newContext,
},
);
const newAccessToken = newContext.res.get("x-access-token");
const newAccessToken = newContext.res.get("x-data-token");

assert.isNotEmpty(newAccessToken);
assert.notEqual(newAccessToken, accessToken);
Expand Down
52 changes: 38 additions & 14 deletions packages/apollo/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class GatewayAuthPlugin<TContext extends PublicFederatedTokenContext>

const accessToken = this.tokenSource.getAccessToken(request);
const refreshToken = this.tokenSource.getRefreshToken(request);
const fingerprint = this.tokenSource.getFingerprint(request);
const dataToken = this.tokenSource.getDataToken(request);

if (!accessToken && !refreshToken) {
return;
Expand All @@ -64,9 +64,9 @@ export class GatewayAuthPlugin<TContext extends PublicFederatedTokenContext>
// accessToken is expired/invalid anyway
if (accessToken && !refreshToken) {
try {
await token.loadAccessJWT(this.signer, accessToken, fingerprint);
await token.loadAccessJWT(this.signer, accessToken, dataToken);
} catch (e: unknown) {
this.tokenSource.deleteAccessToken(contextValue.res);
this.tokenSource.deleteAccessToken(contextValue.req, contextValue.res);

if (e instanceof TokenExpiredError) {
throw new GraphQLError("Your token has expired.", {
Expand Down Expand Up @@ -94,7 +94,7 @@ export class GatewayAuthPlugin<TContext extends PublicFederatedTokenContext>
try {
await token.loadRefreshJWT(this.signer, refreshToken);
} catch (e: unknown) {
this.tokenSource.deleteRefreshToken(contextValue.res);
this.tokenSource.deleteRefreshToken(contextValue.req, contextValue.res);
}
}
}
Expand All @@ -106,24 +106,48 @@ export class GatewayAuthPlugin<TContext extends PublicFederatedTokenContext>
const token = contextValue?.federatedToken;
const { req: request, res: response } = contextValue;


if (token?.shouldDestroyToken()) {
this.tokenSource.deleteAccessToken(request, response);
this.tokenSource.deleteRefreshToken(request, response);
this.tokenSource.deleteDataToken(request, response)
return
}

// Downstream services modified the tokens, so create a new JWT and set
// it on the response
// TODO: We should optimize this, if only the values are modified then
// we shouldn't have to create a new nested JWE
if (token?.isAccessTokenModified() || token?.isValueModified()) {
const { accessToken, fingerprint } = await token.createAccessJWT(
this.signer,
);
if (token?.isAccessTokenModified()) {
const accessToken = await token.createAccessJWT(this.signer);
if (accessToken) {
this.tokenSource.setAccessToken(
request,
response,
accessToken,
token.isAuthenticated(),
);
}
}

if (accessToken && fingerprint) {
this.tokenSource.setAccessToken(request, response, accessToken);
this.tokenSource.setFingerprint(request, response, fingerprint);
if (token?.isValueModified()) {
const dataToken = await token.createDataJWT(this.signer);
if (dataToken) {
this.tokenSource.setDataToken(
request,
response,
dataToken,
token.isAuthenticated(),
);
}
}

if (token?.isRefreshTokenModified()) {
const refreshToken = await token.createRefreshJWT(this.signer);
this.tokenSource.setRefreshToken(request, response, refreshToken);
this.tokenSource.setRefreshToken(
request,
response,
refreshToken,
token.isAuthenticated(),
);
}
}
}
19 changes: 0 additions & 19 deletions packages/core/src/fingerprint.ts

This file was deleted.

72 changes: 31 additions & 41 deletions packages/core/src/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as crypto from "crypto";
import { describe, expect, test } from "vitest";
import { generateFingerprint, hashFingerprint } from "./fingerprint";
import { PublicFederatedToken } from "./jwt";
import { KeyManager, TokenSigner } from "./sign";

Expand Down Expand Up @@ -38,12 +37,11 @@ describe("PublicFederatedToken", async () => {
value2: "exampleValue2",
};

const { accessToken, fingerprint } = await token.createAccessJWT(signer);

expect(fingerprint).lengthOf(32);
const accessToken = await token.createAccessJWT(signer);
const dataToken = await token.createDataJWT(signer);

const newToken = new PublicFederatedToken();
await newToken.loadAccessJWT(signer, accessToken, fingerprint);
await newToken.loadAccessJWT(signer, accessToken, dataToken);
expect(newToken.tokens).toStrictEqual(token.tokens);
expect(newToken.refreshTokens).toStrictEqual(token.refreshTokens);
expect(newToken.values).toStrictEqual(token.values);
Expand All @@ -69,56 +67,48 @@ describe("PublicFederatedToken", async () => {
value2: "exampleValue2",
};

const { accessToken, fingerprint } = await token.createAccessJWT(signer);

expect(fingerprint).lengthOf(32);
const accessToken = await token.createAccessJWT(signer);
const dataToken = await token.createDataJWT(signer);

const newToken = new PublicFederatedToken();
await newToken.loadAccessJWT(signer, accessToken, fingerprint);
await newToken.loadAccessJWT(signer, accessToken, dataToken);
expect(newToken.tokens).toStrictEqual(token.tokens);
expect(newToken.refreshTokens).toStrictEqual(token.refreshTokens);
expect(newToken.values).toStrictEqual(token.values);
});

test("loadAccessJWT", async () => {
const token = new PublicFederatedToken();
const exampleJWT = await signer.signJWT({
const time = 1729258233173;

const dataJWT = await signer.signJWT({
exp: Date.now() + 1000,
jwe: await signer.encryptObject(token.tokens),
value1: "exampleValue1",
value2: "exampleValue2",
values: {
value1: "exampleValue1",
value2: "exampleValue2",
},
});

await token.loadAccessJWT(signer, exampleJWT);

const result = await signer.verifyJWT(exampleJWT);
expect(token.tokens).toStrictEqual(
await signer.decryptObject(result.payload.jwe as string),
const tokenJWT = await signer.encryptJWT(
{
tokens: {
exampleName: {
token: "exampleToken",
exp: time,
sub: "exampleSubject",
},
},
},
Date.now() + 1000,
);
expect(token.values).toStrictEqual({
value1: "exampleValue1",
value2: "exampleValue2",
});
});

test("loadAccessJWT with Fingerprint", async () => {
const token = new PublicFederatedToken();
const fingerprint = generateFingerprint();
const exampleJWT = await signer.signJWT({
exp: Date.now() + 1000,
jwe: await signer.encryptObject(token.tokens),
value1: "exampleValue1",
value2: "exampleValue2",
_fingerprint: hashFingerprint(fingerprint),
await token.loadAccessJWT(signer, tokenJWT, dataJWT);
expect(token.tokens).toStrictEqual({
exampleName: {
token: "exampleToken",
exp: time,
sub: "exampleSubject",
},
});

expect(fingerprint).lengthOf(32);
await token.loadAccessJWT(signer, exampleJWT, fingerprint);

const result = await signer.verifyJWT(exampleJWT);
expect(token.tokens).toStrictEqual(
await signer.decryptObject(result.payload.jwe as string),
);
expect(token.values).toStrictEqual({
value1: "exampleValue1",
value2: "exampleValue2",
Expand Down
69 changes: 24 additions & 45 deletions packages/core/src/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import {
generateFingerprint,
hashFingerprint,
validateFingerprint,
} from "./fingerprint";
import { TokenSigner } from "./sign";
import { FederatedToken } from "./token";
import { TokenExpiredError, TokenInvalidError } from "./errors";
Expand All @@ -19,31 +14,33 @@ export class PublicFederatedToken extends FederatedToken {
// signed token (not encrypted). The jwe attribute is encrypted however.
// This is all done when the GraphQL gateway sends the response back to the
// client.
async createAccessJWT(signer: TokenSigner) {
const exp = this.getExpireTime();
const fingerprint = generateFingerprint();

const payload: JWTPayload = {
...this.values,
// Create the JWT for the client, this JWT is only signed and used to store
// JWT values
async createDataJWT(signer: TokenSigner): Promise<string | undefined> {
if (!this.values) {
return;
}

const exp = this.getExpireTime();
const payload = {
values: this.values,
exp,
sub: signer.getSubject(this),
jwe: await signer.encryptObject(this.tokens),
_fingerprint: hashFingerprint(fingerprint),
};

const token = await signer.signJWT(payload);
return {
accessToken: token,
fingerprint: fingerprint,
};
return await signer.signJWT(payload);
}

// Create the access JWT. This JWT is send to the client. Is used in the
// userToken / sessionToken and should be encrypted and HTTP_ONLY
async createAccessJWT(signer: TokenSigner) {
const exp = this.getExpireTime();
return await signer.encryptJWT({ tokens: this.tokens }, exp);
}

async loadAccessJWT(
signer: TokenSigner,
value: string,
fingerprint?: string,
) {
const result = await signer.verifyJWT(value);
async loadAccessJWT(signer: TokenSigner, value: string, data?: string) {
const result = await signer.decryptJWT(value);
if (!result) {
throw new Error("Invalid JWT");
}
Expand All @@ -60,28 +57,10 @@ export class PublicFederatedToken extends FederatedToken {
throw new TokenExpiredError("JWT expired");
}

if (
fingerprint &&
!validateFingerprint(fingerprint, payload._fingerprint)
) {
throw new TokenInvalidError("Invalid fingerprint");
}

this.tokens = await signer.decryptObject(payload.jwe);
const knownKeys = [
"jwe",
"iat",
"exp",
"aud",
"sub",
"jti",
"iss",
"_fingerprint",
];
for (const k in payload) {
if (!knownKeys.includes(k)) {
this.values[k] = payload[k];
}
this.tokens = payload.tokens;
if (data) {
const result = await signer.verifyJWT(data);
this.values = result.payload.values as Record<string, any>;
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ describe("FederatedToken", () => {

const serialized = Buffer.from(
JSON.stringify({
isAuthenticated: false,
tokens: {
exampleName: {
token: "exampleToken",
Expand Down
Loading

0 comments on commit 62de2da

Please sign in to comment.