From 0e9e076ea338e515863e68eca8e2b36d487c7033 Mon Sep 17 00:00:00 2001 From: zkldi Date: Wed, 11 Aug 2021 21:36:13 +0100 Subject: [PATCH] Add and support reauth calls for KAI type APIs Fixes #271 --- server/.github/test.conf.json5 | 6 +- .../import-types/api/arc-ddr/parser.ts | 1 + .../import-types/api/arc-iidx/parser.ts | 1 + .../import-types/api/arc-sdvx/parser.ts | 1 + .../common/api-kai/iidx/class-handler.test.ts | 2 +- .../common/api-kai/iidx/class-handler.ts | 2 +- .../common/api-kai/iidx/parser.ts | 4 +- .../common/api-kai/reauth.test.ts | 131 ++++++++++++++++++ .../import-types/common/api-kai/reauth.ts | 97 +++++++++++++ .../common/api-kai/sdvx/class-handler.test.ts | 2 +- .../common/api-kai/sdvx/class-handler.ts | 2 +- .../common/api-kai/sdvx/parser.ts | 4 +- .../common/api-kai/traverse-api.test.ts | 67 +++++++-- .../common/api-kai/traverse-api.ts | 80 ++++++++++- .../import-types/common/api-kai/utils.ts | 23 +++ server/src/lib/setup/config.ts | 2 +- server/src/test-utils/mock-fetch.ts | 1 + server/src/utils/misc.ts | 14 +- 18 files changed, 406 insertions(+), 34 deletions(-) create mode 100644 server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts create mode 100644 server/src/lib/score-import/import-types/common/api-kai/reauth.ts create mode 100644 server/src/lib/score-import/import-types/common/api-kai/utils.ts diff --git a/server/.github/test.conf.json5 b/server/.github/test.conf.json5 index cc974850e..1501b8c1b 100644 --- a/server/.github/test.conf.json5 +++ b/server/.github/test.conf.json5 @@ -10,10 +10,14 @@ EAG_API_URL: "https://eag.example.com", MIN_API_URL: "https://min.example.com", ARC_API_URL: "https://arc.example.com", + FLO_OAUTH2_INFO: { + CLIENT_ID: "DUMMY_CLIENT_ID", + CLIENT_SECRET: "DUMMY_CLIENT_SECRET", + }, ARC_AUTH_TOKEN: "unused", CDN_FILE_ROOT: "./local-cdn", PORT: 8080, TYPE: "omni", ENABLE_SERVER_HTTPS: false, - RUN_OWN_CDN: true + RUN_OWN_CDN: true, } diff --git a/server/src/lib/score-import/import-types/api/arc-ddr/parser.ts b/server/src/lib/score-import/import-types/api/arc-ddr/parser.ts index e169a7fb7..067fec233 100644 --- a/server/src/lib/score-import/import-types/api/arc-ddr/parser.ts +++ b/server/src/lib/score-import/import-types/api/arc-ddr/parser.ts @@ -21,6 +21,7 @@ export async function ParseArcDDR( `/api/v1/ddr/16/player_bests?profile_id=${authDoc.accountID}`, ServerConfig.ARC_AUTH_TOKEN, logger, + null, fetch ), context: {}, diff --git a/server/src/lib/score-import/import-types/api/arc-iidx/parser.ts b/server/src/lib/score-import/import-types/api/arc-iidx/parser.ts index f20400102..ca08ef1c1 100644 --- a/server/src/lib/score-import/import-types/api/arc-iidx/parser.ts +++ b/server/src/lib/score-import/import-types/api/arc-iidx/parser.ts @@ -22,6 +22,7 @@ export async function ParseArcIIDX( `/api/v1/iidx/27/player_bests?profile_id=${authDoc.accountID}`, ServerConfig.ARC_AUTH_TOKEN, logger, + null, fetch ), context: {}, diff --git a/server/src/lib/score-import/import-types/api/arc-sdvx/parser.ts b/server/src/lib/score-import/import-types/api/arc-sdvx/parser.ts index 40ebc0193..9d74fac99 100644 --- a/server/src/lib/score-import/import-types/api/arc-sdvx/parser.ts +++ b/server/src/lib/score-import/import-types/api/arc-sdvx/parser.ts @@ -21,6 +21,7 @@ export async function ParseArcSDVX( `/api/v1/sdvx/5/player_bests?profile_id=${authDoc.accountID}`, ServerConfig.ARC_AUTH_TOKEN, logger, + null, fetch ), context: {}, diff --git a/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts b/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts index b7f64d4f2..df5b84207 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.test.ts @@ -4,7 +4,7 @@ import t from "tap"; import { CloseAllConnections } from "test-utils/close-connections"; import { MockBasicFetch, MockJSONFetch } from "test-utils/mock-fetch"; import ResetDBState from "test-utils/resets"; -import { KaiTypeToBaseURL } from "utils/misc"; +import { KaiTypeToBaseURL } from "../utils"; import { CreateKaiIIDXClassHandler } from "./class-handler"; const logger = CreateLogCtx(__filename); diff --git a/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts b/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts index d7ab488b9..07ae08802 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/iidx/class-handler.ts @@ -1,7 +1,7 @@ import { IIDXDans } from "lib/constants/classes"; import { ClassHandler } from "lib/score-import/framework/user-game-stats/types"; import nodeFetch from "utils/fetch"; -import { KaiTypeToBaseURL } from "utils/misc"; +import { KaiTypeToBaseURL } from "../utils"; export async function CreateKaiIIDXClassHandler( kaiType: "FLO" | "EAG", diff --git a/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts b/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts index a98de4906..0e5d28277 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts @@ -4,8 +4,9 @@ import { KaiAuthDocument } from "tachi-common"; import { KaiContext } from "../types"; import { TraverseKaiAPI } from "../traverse-api"; import { ParserFunctionReturns } from "../../types"; -import { KaiTypeToBaseURL } from "utils/misc"; +import { KaiTypeToBaseURL } from "../utils"; import { CreateKaiIIDXClassHandler } from "./class-handler"; +import { CreateKaiReauthFunction } from "../reauth"; export async function ParseKaiIIDX( service: "FLO" | "EAG", @@ -21,6 +22,7 @@ export async function ParseKaiIIDX( "/api/iidx/v2/play_history", authDoc.token, logger, + CreateKaiReauthFunction(service, authDoc, logger, fetch), fetch ), context: { diff --git a/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts b/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts new file mode 100644 index 000000000..b8321893b --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts @@ -0,0 +1,131 @@ +import deepmerge from "deepmerge"; +import db from "external/mongo/db"; +import CreateLogCtx from "lib/logger/logger"; +import { ServerConfig } from "lib/setup/config"; +import { KaiAuthDocument } from "tachi-common"; +import t from "tap"; +import { CloseAllConnections } from "test-utils/close-connections"; +import { MockJSONFetch } from "test-utils/mock-fetch"; +import ResetDBState from "test-utils/resets"; +import { CreateKaiReauthFunction } from "./reauth"; + +const logger = CreateLogCtx(__filename); + +const authDoc: KaiAuthDocument = { + refreshToken: "REFRESH_TOKEN", + service: "FLO", + token: "foobar", + userID: 1, +}; + +t.test("#CreateKaiReauthFunction", (t) => { + t.beforeEach(ResetDBState); + // eslint-disable-next-line no-return-await + t.beforeEach(async () => await db["kai-auth-tokens"].remove({})); + + if (!ServerConfig.FLO_OAUTH2_INFO) { + throw new Error( + `Panic in test - No dummy FLO_OAUTH2_INFO configured, and the test depends on some dummy data here.` + ); + } + + t.test("Should create a working reauthentication for the service.", async (t) => { + const mockFetch = MockJSONFetch({ + [`${ServerConfig.FLO_API_URL}/oauth/token?refresh_token=${ + authDoc.refreshToken + }&grant_type=refresh_token&client_secret=${ + ServerConfig.FLO_OAUTH2_INFO!.CLIENT_SECRET + }&client_id=${ServerConfig.FLO_OAUTH2_INFO!.CLIENT_ID}`]: { + refresh_token: "NEW_REFRESH_TOKEN", + access_token: "NEW_ACCESS_TOKEN", + }, + }); + + await db["kai-auth-tokens"].insert(deepmerge(authDoc, {})); + + const reauthFn = CreateKaiReauthFunction("FLO", authDoc, logger, mockFetch); + + t.equal(reauthFn.length, 0, "Should return a function with arity 0."); + + const data = await reauthFn(); + + t.equal(data, "NEW_ACCESS_TOKEN"); + + const dbChange = await db["kai-auth-tokens"].findOne({ + userID: 1, + service: "FLO", + }); + + // should also update the db + t.equal(dbChange?.refreshToken, "NEW_REFRESH_TOKEN"); + t.equal(dbChange?.token, "NEW_ACCESS_TOKEN"); + + t.end(); + }); + + t.test("Should throw on fetch error.", async (t) => { + // will fail + const mockFetch = MockJSONFetch({}); + + await db["kai-auth-tokens"].insert(deepmerge(authDoc, {})); + + const reauthFn = CreateKaiReauthFunction("FLO", authDoc, logger, mockFetch); + + t.rejects(() => reauthFn(), { + message: "An error has occured while attempting reauthentication.", + }); + + const dbChange = await db["kai-auth-tokens"].findOne({ + userID: 1, + service: "FLO", + }); + + t.equal( + dbChange?.refreshToken, + authDoc.refreshToken, + "DB should not be changed for refreshToken." + ); + t.equal(dbChange?.token, authDoc.token, "DB should not be changed for token."); + + t.end(); + }); + + t.test("Should throw on invalid JSON response", async (t) => { + const mockFetch = MockJSONFetch({ + [`${ServerConfig.FLO_API_URL}/oauth/token?refresh_token=${ + authDoc.refreshToken + }&grant_type=refresh_token&client_secret=${ + ServerConfig.FLO_OAUTH2_INFO!.CLIENT_SECRET + }&client_id=${ServerConfig.FLO_OAUTH2_INFO!.CLIENT_ID}`]: { + // missing refresh_token + access_token: "NEW_ACCESS_TOKEN", + }, + }); + + await db["kai-auth-tokens"].insert(deepmerge(authDoc, {})); + + const reauthFn = CreateKaiReauthFunction("FLO", authDoc, logger, mockFetch); + + t.rejects(() => reauthFn(), { + message: "An error has occured while attempting reauthentication.", + }); + + const dbChange = await db["kai-auth-tokens"].findOne({ + userID: 1, + service: "FLO", + }); + + t.equal( + dbChange?.refreshToken, + authDoc.refreshToken, + "DB should not be changed for refreshToken." + ); + t.equal(dbChange?.token, authDoc.token, "DB should not be changed for token."); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/lib/score-import/import-types/common/api-kai/reauth.ts b/server/src/lib/score-import/import-types/common/api-kai/reauth.ts new file mode 100644 index 000000000..a1ae7e3e3 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-kai/reauth.ts @@ -0,0 +1,97 @@ +import db from "external/mongo/db"; +import { KtLogger } from "lib/logger/logger"; +import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error"; +import { GetKaiTypeClientCredentials, KaiTypeToBaseURL } from "./utils"; +import nodeFetch from "utils/fetch"; +import p from "prudence"; +import { KaiAuthDocument } from "tachi-common"; + +const REAUTH_SCHEMA = { + access_token: "string", + refresh_token: "string", +}; + +export function CreateKaiReauthFunction( + kaiType: "FLO" | "EAG" | "MIN", + authDoc: KaiAuthDocument, + logger: KtLogger, + fetch = nodeFetch +) { + const maybeCredentials = GetKaiTypeClientCredentials(kaiType); + + /* istanbul ignore next */ + if (!maybeCredentials) { + logger.error( + `No CLIENT_ID or CLIENT_SECRET was configured for ${kaiType}. Cannot create reauth function.` + ); + throw new ScoreImportFatalError( + 500, + `Fatal error in performing authentication. This has been reported.` + ); + } + + const { CLIENT_ID, CLIENT_SECRET } = maybeCredentials; + + return async () => { + let res; + try { + res = await fetch( + `${KaiTypeToBaseURL(kaiType)}/oauth/token?refresh_token=${ + authDoc.refreshToken + }&grant_type=refresh_token&client_secret=${CLIENT_SECRET}&client_id=${CLIENT_ID}` + ); + } catch (err) { + logger.error(`Unexpected error while fetching reauth?`, { res, err }); + throw new ScoreImportFatalError( + 500, + "An error has occured while attempting reauthentication." + ); + } + + /* istanbul ignore next */ + if (res.status !== 200) { + logger.error(`Unexpected ${res.status} error while fetching reauth?`, { res }); + throw new ScoreImportFatalError( + 500, + "An error has occured while attempting reauthentication." + ); + } + + let json; + /* istanbul ignore next */ + try { + json = await res.json(); + } catch (err) { + logger.error(`Invalid JSON body in successful reauth response.`, { res, err }); + throw new ScoreImportFatalError( + 500, + "An error has occured while attempting reauthentication." + ); + } + + const err = p(json, REAUTH_SCHEMA, {}, { allowExcessKeys: true, throwOnNonObject: false }); + + if (err) { + logger.error(`Invalid JSON body in successful reauth response.`, { err, json }); + throw new ScoreImportFatalError( + 500, + "An error has occured while attempting reauthentication." + ); + } + + await db["kai-auth-tokens"].update( + { + userID: authDoc.userID, + service: authDoc.service, + }, + { + $set: { + refreshToken: json.refresh_token, + token: json.access_token, + }, + } + ); + + return json.access_token; + }; +} diff --git a/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts b/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts index 09e5ca0c3..fe547e3e5 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.test.ts @@ -4,7 +4,7 @@ import t from "tap"; import { CloseAllConnections } from "test-utils/close-connections"; import { MockBasicFetch, MockJSONFetch } from "test-utils/mock-fetch"; import ResetDBState from "test-utils/resets"; -import { KaiTypeToBaseURL } from "utils/misc"; +import { KaiTypeToBaseURL } from "../utils"; import { CreateKaiSDVXClassHandler } from "./class-handler"; const logger = CreateLogCtx(__filename); diff --git a/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts b/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts index 85eb8ebad..5e9fbc78b 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/sdvx/class-handler.ts @@ -1,7 +1,7 @@ import { SDVXDans } from "lib/constants/classes"; import { ClassHandler } from "lib/score-import/framework/user-game-stats/types"; import nodeFetch from "utils/fetch"; -import { KaiTypeToBaseURL } from "utils/misc"; +import { KaiTypeToBaseURL } from "../utils"; export async function CreateKaiSDVXClassHandler( kaiType: "FLO" | "EAG" | "MIN", diff --git a/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts b/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts index 392b7e101..042bf4c4d 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts @@ -4,8 +4,9 @@ import { KaiAuthDocument } from "tachi-common"; import { KaiContext } from "../types"; import { TraverseKaiAPI } from "../traverse-api"; import { ParserFunctionReturns } from "../../types"; -import { KaiTypeToBaseURL } from "utils/misc"; +import { KaiTypeToBaseURL } from "../utils"; import { CreateKaiSDVXClassHandler } from "./class-handler"; +import { CreateKaiReauthFunction } from "../reauth"; export async function ParseKaiSDVX( service: "FLO" | "EAG" | "MIN", @@ -21,6 +22,7 @@ export async function ParseKaiSDVX( "/api/sdvx/v1/play_history", authDoc.token, logger, + CreateKaiReauthFunction(service, authDoc, logger, fetch), fetch ), context: { diff --git a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.test.ts b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.test.ts index 6941f7726..bafd2d820 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.test.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.test.ts @@ -1,7 +1,7 @@ import t from "tap"; import { CloseAllConnections } from "test-utils/close-connections"; import { agta } from "test-utils/misc"; -import { MockJSONFetch } from "test-utils/mock-fetch"; +import { MockBasicFetch, MockJSONFetch } from "test-utils/mock-fetch"; import ResetDBState from "test-utils/resets"; import { NodeFetch } from "utils/fetch"; import CreateLogCtx from "lib/logger/logger"; @@ -31,7 +31,7 @@ t.test("#TraverseKaiAPI", (t) => { }, }); - const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI); + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); const elements = await agta(res); @@ -55,7 +55,7 @@ t.test("#TraverseKaiAPI", (t) => { _items: [5, 6], }, }); - const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI); + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); t.rejects( () => agta(res), @@ -67,7 +67,7 @@ t.test("#TraverseKaiAPI", (t) => { t.test("Should throw on invalid response JSON", (t) => { const mockKaiAPI = (() => ({ json: null })) as unknown as NodeFetch; - const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI); + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); t.rejects(() => agta(res)); @@ -78,7 +78,7 @@ t.test("#TraverseKaiAPI", (t) => { const mockKaiAPI = () => { throw new Error("Fake Request timeout..."); }; - const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI); + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); t.rejects(() => agta(res)); @@ -92,7 +92,7 @@ t.test("#TraverseKaiAPI", (t) => { }, }); - const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI); + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); t.rejects(() => agta(res)); @@ -102,7 +102,7 @@ t.test("#TraverseKaiAPI", (t) => { }, }); - const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2); + const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI2); t.rejects(() => agta(res2)); @@ -116,7 +116,7 @@ t.test("#TraverseKaiAPI", (t) => { }, }); - const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI); + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); t.rejects(() => agta(res)); @@ -128,7 +128,7 @@ t.test("#TraverseKaiAPI", (t) => { }, }); - const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI2); + const res2 = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI2); t.rejects(() => agta(res2)); @@ -145,13 +145,60 @@ t.test("#TraverseKaiAPI", (t) => { }, }); - const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, mockKaiAPI); + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); t.rejects(() => agta(res)); t.end(); }); + t.test("Should throw on infinite loop", (t) => { + const mockKaiAPI = MockJSONFetch({ + "http://url.com/sub": { + _links: { + _next: "http://url.com/sub", + }, + _items: [1, 2, 3, 4], + }, + }); + + const res = TraverseKaiAPI("http://url.com", "/sub", fakeAuth, logger, null, mockKaiAPI); + + t.rejects(() => agta(res)); + + t.end(); + }); + + t.test("Should attempt reauthentication if authentication fails.", async (t) => { + const mockKaiAPI = MockBasicFetch({ status: 401 }); + + let hasAttemptedReauth = false; + + const res = TraverseKaiAPI( + "http://url.com", + "/sub", + fakeAuth, + logger, + // eslint-disable-next-line require-await + async () => { + hasAttemptedReauth = true; + return "bar"; + }, + mockKaiAPI + ); + + await t.rejects( + // not redundant here + // eslint-disable-next-line no-return-await + async () => await agta(res), + "Should correctly bail out of an endless reauth loop." + ); + + t.equal(hasAttemptedReauth, true, "Should have called the reauth function."); + + t.end(); + }); + t.end(); }); diff --git a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts index f7cd29876..d363a518d 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts @@ -4,9 +4,32 @@ import nodeFetch from "utils/fetch"; import { VERSION_STR } from "lib/constants/version"; import { ServerTypeInfo } from "lib/setup/config"; +/** + * A Kai Reauth function is an async function that returns a string + * which represents the new token to use, OR throws a score import + * fatal error if reauthentication fails. + */ +export type KaiAPIReauthFunction = () => Promise; + +/** + * there is the (strange) case here where we could end up + * infinitely requesting re-auth and, despite succeeding, + * continually failing on new requests. + * in this case, we check if we've exceeded the maximum amount of + * reauths, and will throw if it is exceeded. + */ +const MAX_REAUTH_ATTEMPTS = 1; + +/** + * Defines the absolute maximum amount of loops this function can do. + * A maliciously crafted API can cause us to enter an infinite loop and + * blow up - we don't want that. + */ +const MAX_ITERATIONS = 500; + /** * Traverses a Kai-like personal_bests api. - * @param baseUrl The base URL to fetch requests from. - https://google.com + * @param baseUrl The base URL to fetch requests from. - like https://google.com * @param subUrl The endpoint for the scores - /api/v1/personal_bests * @param authDoc The users' authentication document. * @param fetch This is so `fetch` can be mocked with something @@ -17,11 +40,15 @@ export async function* TraverseKaiAPI( subUrl: string, token: string, logger: KtLogger, + reauthFunction: KaiAPIReauthFunction | null = null, fetch = nodeFetch ) { let fetchMoreData = true; let url = `${baseUrl}${subUrl}`; + let reauthAttempts = 0; + let currentIteration = 0; + while (fetchMoreData) { // Rough SSRF check - KAI-like apis could potentially get us to perform // SSRF. We check if the origin of the next request is exactly the @@ -35,21 +62,56 @@ export async function* TraverseKaiAPI( } let json; + let res; // wrap all this in a try catch just incase the fetch or the res // json call fails. try { // eslint-disable-next-line no-await-in-loop - const res = await fetch(url, { + res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, "User-Agent": `${ServerTypeInfo.name}/${VERSION_STR}`, }, }); + } catch (err) { + logger.error(`Recieved invalid response from ${url}.`, { err }); + throw new ScoreImportFatalError( + 500, + `Recieved invalid response from ${url}. Are they down?` + ); + } + // if we are unauthorised or forbidden we need to attempt + // reauth + if (res.status === 401 || res.status === 403) { + reauthAttempts++; + if (reauthAttempts > MAX_REAUTH_ATTEMPTS) { + throw new ScoreImportFatalError( + 500, + `Attempted to reauthenticate with ${baseUrl} more than ${MAX_REAUTH_ATTEMPTS} times. The problem is likely not on our end.` + ); + } + + if (!reauthFunction) { + throw new ScoreImportFatalError( + res.status, + `Unable to authenticate with ${baseUrl} and reauthentication was not possible.` + ); + } + + // reassigning this param is fine. + // eslint-disable-next-line no-param-reassign, no-await-in-loop + token = await reauthFunction(); + + // then go to the start of the while loop. + continue; + } + + try { // eslint-disable-next-line no-await-in-loop json = await res.json(); } catch (err) { - logger.error(`Recieved invalid response from ${url}.`, { err }); + logger.error(`Recieved invalid (non-json) response from ${url}.`, { err }); throw new ScoreImportFatalError( 500, `Recieved invalid response from ${url}. Are they down?` @@ -65,6 +127,18 @@ export async function* TraverseKaiAPI( ); } + currentIteration++; + + if (currentIteration > MAX_ITERATIONS) { + logger.error( + `An infinite loop has occured - Terminating at MAX_ITERATIONS (${MAX_ITERATIONS}).` + ); + throw new ScoreImportFatalError( + 508, + "An infinite loop appears to have occured while synchronising with this api." + ); + } + if (typeof json._links._next === "string") { url = json._links._next; } else if (json._links._next === null) { diff --git a/server/src/lib/score-import/import-types/common/api-kai/utils.ts b/server/src/lib/score-import/import-types/common/api-kai/utils.ts new file mode 100644 index 000000000..7eef20a5a --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-kai/utils.ts @@ -0,0 +1,23 @@ +import { ServerConfig } from "lib/setup/config"; + +export function KaiTypeToBaseURL(kaiType: "FLO" | "EAG" | "MIN") { + if (kaiType === "FLO") { + return ServerConfig.FLO_API_URL; + } else if (kaiType === "EAG") { + return ServerConfig.EAG_API_URL; + } else if (kaiType === "MIN") { + return ServerConfig.MIN_API_URL; + } + + throw new Error(`Invalid Kai Type ${kaiType} provided.`); +} + +export function GetKaiTypeClientCredentials(kaiType: "FLO" | "EAG" | "MIN") { + if (kaiType === "FLO") { + return ServerConfig.FLO_OAUTH2_INFO; + } else if (kaiType === "EAG") { + return ServerConfig.EAG_OAUTH2_INFO; + } + + return ServerConfig.MIN_OAUTH2_INFO; +} diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index 3224106e7..09d2924f9 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -37,7 +37,7 @@ function isValidURL(self: unknown) { } } -interface OAuth2Info { +export interface OAuth2Info { CLIENT_ID: string; CLIENT_SECRET: string; } diff --git a/server/src/test-utils/mock-fetch.ts b/server/src/test-utils/mock-fetch.ts index eb8bd658a..e16a8ec78 100644 --- a/server/src/test-utils/mock-fetch.ts +++ b/server/src/test-utils/mock-fetch.ts @@ -18,6 +18,7 @@ export function MockJSONFetch(urlDataMap: Record) { return (async (url: string) => { if (urlDataMap[url]) { return { + status: 200, json: async () => urlDataMap[url], }; } diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 87f5c8e49..7922439d3 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -75,16 +75,4 @@ export function IsString(val: unknown): val is string { export function DedupeArr(arr: T[]): T[] { return [...new Set(arr)]; -} - -export function KaiTypeToBaseURL(kaiType: "FLO" | "EAG" | "MIN") { - if (kaiType === "FLO") { - return ServerConfig.FLO_API_URL; - } else if (kaiType === "EAG") { - return ServerConfig.EAG_API_URL; - } else if (kaiType === "MIN") { - return ServerConfig.MIN_API_URL; - } - - throw new Error(`Invalid Kai Type ${kaiType} provided.`); -} +} \ No newline at end of file