diff --git a/site/gatsby-site/server/context.ts b/site/gatsby-site/server/context.ts index b9f0f76a17..d0865b5b01 100644 --- a/site/gatsby-site/server/context.ts +++ b/site/gatsby-site/server/context.ts @@ -1,21 +1,21 @@ import { IncomingMessage } from "http"; import { MongoClient } from "mongodb"; import config from "./config"; +import * as crypto from 'crypto'; import * as reporter from "./reporter"; - function extractToken(header: string) { - - if (header && header!.startsWith('Bearer ')) { - + if (header && header.startsWith('Bearer ')) { return header.substring(7); } - return null; } -export const verifyToken = async (token: string) => { +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} +export const verifyToken = async (token: string) => { const loginResponse = await fetch( `https://realm.mongodb.com/api/admin/v3.0/auth/providers/mongodb-cloud/login`, { @@ -31,7 +31,7 @@ export const verifyToken = async (token: string) => { ); if (!loginResponse.ok) { - throw new Error(`Error login into admin api! \n\n ${await loginResponse.text()}`); + throw new Error(`Error logging into admin API! \n\n ${await loginResponse.text()}`); } const loginData = await loginResponse.json(); @@ -56,36 +56,76 @@ export const verifyToken = async (token: string) => { } async function getUser(userId: string, client: MongoClient) { - const db = client.db('customData'); - const collection = db.collection('users'); - const userData = await collection.findOne({ userId }); return { id: userId, roles: userData?.roles, + }; +} + +async function getTokenCache(tokenHash: string, client: MongoClient) { + const db = client.db('customData'); + const collection = db.collection('tokenCache'); + const cached = await collection.findOne({ tokenHash }); + + if (cached && cached.expiration > Date.now()) { + return cached; + + } else if (cached) { + + await deleteTokenCache(tokenHash, client); } + + return null; } -async function getUserFromHeader(header: string, client: MongoClient) { +async function deleteTokenCache(tokenHash: string, client: MongoClient) { + const db = client.db('customData'); + const collection = db.collection('tokenCache'); + await collection.deleteMany({ tokenHash }); +} + +async function setTokenCache(tokenHash: string, userId: string, client: MongoClient) { + const db = client.db('customData'); + const collection = db.collection('tokenCache'); + + await collection.deleteMany({ userId }); + const expiration = Date.now() + 30 * 60 * 1000; + + await collection.insertOne({ tokenHash, userId, expiration }); +} + +async function getUserFromHeader(header: string, client: MongoClient) { const token = extractToken(header); if (token) { + const tokenHash = hashToken(token); + const cached = await getTokenCache(tokenHash, client); - const data = await verifyToken(token); + let userId = null; - if (data == 'token expired') { - - throw new Error('Token expired'); - } + if (cached) { + userId = cached.userId; + } else { + const data = await verifyToken(token); - if (data.sub) { + if (data === 'token expired') { + await deleteTokenCache(tokenHash, client); + throw new Error('Token expired'); + } - const userData = await getUser(data.sub, client); + if (data.sub) { + userId = data.sub; + } + } + if (userId) { + const userData = await getUser(userId, client); + await setTokenCache(tokenHash, userId, client); return userData; } } @@ -94,17 +134,11 @@ async function getUserFromHeader(header: string, client: MongoClient) { } export const context = async ({ req, client }: { req: IncomingMessage, client: MongoClient }) => { - try { - const user = await getUserFromHeader(req.headers.authorization!, client); - return { user, req, client }; - } - catch (e) { - + } catch (e) { reporter.error(e as Error); - throw e; } -} \ No newline at end of file +}; \ No newline at end of file diff --git a/site/gatsby-site/server/rules.ts b/site/gatsby-site/server/rules.ts index 86aaa6abbe..090a62611f 100644 --- a/site/gatsby-site/server/rules.ts +++ b/site/gatsby-site/server/rules.ts @@ -6,9 +6,9 @@ export const isRole = (role: string) => rule()( const { user } = context; - const meetsRole = user && user.roles && user.roles.includes(role); + const meetsRole = user?.roles?.includes(role); - const meetsAdmin = user?.roles.includes('admin'); + const meetsAdmin = user?.roles?.includes('admin'); const meetsSelf = role == 'self' && user?.id === (info.variableValues?.filter as any)?.userId?.EQ; diff --git a/site/gatsby-site/server/tests/mutation-fields.spec.ts b/site/gatsby-site/server/tests/mutation-fields.spec.ts index ac94fc84d5..c054098743 100644 --- a/site/gatsby-site/server/tests/mutation-fields.spec.ts +++ b/site/gatsby-site/server/tests/mutation-fields.spec.ts @@ -2,7 +2,7 @@ import { expect, jest, it } from '@jest/globals'; import { ApolloServer } from "@apollo/server"; import { pluralize, singularize } from "../utils"; import capitalize from 'lodash/capitalize'; -import { makeRequest, seedFixture, startTestServer } from "./utils"; +import { makeRequest, seedCollection, seedFixture, startTestServer } from "./utils"; import * as context from '../context'; import quickaddsFixture from './fixtures/quickadds'; @@ -52,6 +52,10 @@ fixtures.forEach((collection) => { await server?.stop(); }); + beforeEach(async () => { + await seedCollection({ database: 'customData', name: 'tokenCache', docs: [], drop: true }); + }); + if (collection.testInsertOne !== null) { const testData = collection.testInsertOne!; @@ -78,7 +82,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[insertOneFieldName]).toMatchObject(testData.result) @@ -91,7 +95,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); @@ -123,7 +127,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[insertManyFieldName]).toMatchObject(testData.result); } @@ -135,7 +139,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } @@ -169,7 +173,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[updateOneFieldName]).toMatchObject(testData.result) @@ -182,7 +186,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } @@ -217,7 +221,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[updateManyFieldName]).toMatchObject(testData.result); @@ -230,7 +234,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } @@ -261,7 +265,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[deleteOneFieldName]).toMatchObject(testData.result); } @@ -272,7 +276,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } @@ -303,7 +307,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[deleteManyFieldName]).toMatchObject(testData.result); @@ -316,7 +320,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } @@ -350,7 +354,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[upsertOneFieldName]).toMatchObject(testData.result) @@ -363,7 +367,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } @@ -395,7 +399,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy1'); expect(response.body.data[upsertOneFieldName]).toMatchObject(testData.result) @@ -408,7 +412,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, mutationData); + const response = await makeRequest(url, mutationData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } diff --git a/site/gatsby-site/server/tests/query-fields.spec.ts b/site/gatsby-site/server/tests/query-fields.spec.ts index 25d75ce508..002622d2db 100644 --- a/site/gatsby-site/server/tests/query-fields.spec.ts +++ b/site/gatsby-site/server/tests/query-fields.spec.ts @@ -1,5 +1,5 @@ import { ApolloServer } from "@apollo/server"; -import { makeRequest, seedFixture, startTestServer } from "./utils"; +import { makeRequest, seedCollection, seedFixture, startTestServer } from "./utils"; import { pluralize, singularize } from "../utils"; import capitalize from 'lodash/capitalize'; @@ -13,12 +13,12 @@ import submissionsFixture from './fixtures/submissions'; import * as context from '../context'; const fixtures = [ - quickaddsFixture, - reportsFixture, - entitiesFixture, - incidentsFixture, + // quickaddsFixture, + // reportsFixture, + // entitiesFixture, + // incidentsFixture, usersFixture, - submissionsFixture, + // submissionsFixture, ] fixtures.forEach((collection) => { @@ -40,6 +40,10 @@ fixtures.forEach((collection) => { await server?.stop(); }); + beforeEach(async () => { + await seedCollection({ database: 'customData', name: 'tokenCache', docs: [], drop: true }); + }); + if (collection.testSingular) { const testData = collection.testSingular!; @@ -64,7 +68,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy1'); expect(response.body.data[singularName]).toMatchObject(testData.result); } @@ -76,7 +80,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } @@ -107,7 +111,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy1'); expect(response.body.data[pluralName]).toMatchObject(testData.result); @@ -120,7 +124,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); @@ -153,7 +157,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy1'); expect(response.body.data[pluralName]).toMatchObject(testData.result); @@ -165,7 +169,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); @@ -197,7 +201,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy1'); expect(response.body.data[pluralName]).toHaveLength(testData.result.length); @@ -211,7 +215,7 @@ fixtures.forEach((collection) => { jest.spyOn(context, 'verifyToken').mockResolvedValue({ sub: user.userId }) - const response = await makeRequest(url, queryData); + const response = await makeRequest(url, queryData, 'dummy2'); expect(response.body.errors[0].message).toBe('not authorized'); } diff --git a/site/gatsby-site/server/tests/utils.ts b/site/gatsby-site/server/tests/utils.ts index aa0b9776cf..1a4713d5b4 100644 --- a/site/gatsby-site/server/tests/utils.ts +++ b/site/gatsby-site/server/tests/utils.ts @@ -50,11 +50,11 @@ export const seedUsers = async (users: { userId: string, roles: string[] | null }); } -export const makeRequest = async (url: string, data: { variables?: Record, query: string }) => { +export const makeRequest = async (url: string, data: { variables?: Record, query: string }, token?: string) => { return supertest(url) .post('/') - .set('Authorization', `Bearer dummyToken`) + .set('Authorization', `Bearer ${token ?? 'dummyToken'}`) .send(data); } diff --git a/site/gatsby-site/src/contexts/userContext/UserContextProvider.js b/site/gatsby-site/src/contexts/userContext/UserContextProvider.js index 42d3bccb0c..dab5c117e5 100644 --- a/site/gatsby-site/src/contexts/userContext/UserContextProvider.js +++ b/site/gatsby-site/src/contexts/userContext/UserContextProvider.js @@ -11,6 +11,25 @@ import useLocalizePath from '../../components/i18n/useLocalizePath'; import { removeTypenameFromVariables } from '@apollo/client/link/remove-typename'; import CustomButton from '../../elements/Button'; +function decodeAccessToken(accessToken) { + const parts = accessToken.split('.'); + + const encodedPayload = parts[1]; + + const decodedPayload = atob(encodedPayload); + + const parsedPayload = JSON.parse(decodedPayload); + + const { exp: expires, iat: issuedAt, sub: subject, user_data: userData = {} } = parsedPayload; + + return { expires, issuedAt, subject, userData }; +} + +function isTokenExpired(accessToken) { + const { expires } = decodeAccessToken(accessToken); + + return Date.now() >= expires * 1000; +} // https://github.com/mongodb-university/realm-graphql-apollo-react/blob/master/src/index.js const getApolloCLient = (getValidAccessToken) => @@ -173,7 +192,7 @@ export const UserContextProvider = ({ children }) => { const getValidAccessToken = async () => { if (!realmApp.currentUser) { await login(); - } else { + } else if (isTokenExpired(realmApp.currentUser.accessToken)) { await realmApp.currentUser.refreshCustomData(); }