Add and support reauth calls for KAI type APIs

Fixes #271
This commit is contained in:
zkldi
2021-08-11 21:36:13 +01:00
parent bd174ca116
commit 0e9e076ea3
18 changed files with 406 additions and 34 deletions
+5 -1
View File
@@ -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,
}
@@ -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: {},
@@ -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: {},
@@ -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: {},
@@ -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);
@@ -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",
@@ -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: {
@@ -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);
@@ -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;
};
}
@@ -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);
@@ -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",
@@ -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: {
@@ -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();
});
@@ -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<string>;
/**
* 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) {
@@ -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;
}
+1 -1
View File
@@ -37,7 +37,7 @@ function isValidURL(self: unknown) {
}
}
interface OAuth2Info {
export interface OAuth2Info {
CLIENT_ID: string;
CLIENT_SECRET: string;
}
+1
View File
@@ -18,6 +18,7 @@ export function MockJSONFetch(urlDataMap: Record<string, unknown>) {
return (async (url: string) => {
if (urlDataMap[url]) {
return {
status: 200,
json: async () => urlDataMap[url],
};
}
+1 -13
View File
@@ -75,16 +75,4 @@ export function IsString(val: unknown): val is string {
export function DedupeArr<T>(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.`);
}
}