diff --git a/server/package.json b/server/package.json index df2fe8c0c..ca06e5b50 100644 --- a/server/package.json +++ b/server/package.json @@ -70,7 +70,7 @@ "redis": "3.1.2", "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", - "tachi-common": "^0.1.9", + "tachi-common": "^0.1.10", "typescript": "4.3.4", "winston": "3.3.3" }, diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index c3439b82d..17c9443c2 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -51,7 +51,7 @@ specifiers: rimraf: 3.0.2 safe-json-stringify: 1.2.0 supertest: 6.1.3 - tachi-common: ^0.1.9 + tachi-common: ^0.1.10 tap: 15.0.9 ts-node: 10.0.0 tsconfig-paths: 3.10.1 @@ -84,7 +84,7 @@ dependencies: redis: 3.1.2 rimraf: 3.0.2 safe-json-stringify: 1.2.0 - tachi-common: 0.1.9_ts-node@10.0.0+typescript@4.3.4 + tachi-common: 0.1.10_ts-node@10.0.0+typescript@4.3.4 typescript: 4.3.4 winston: 3.3.3 @@ -3774,8 +3774,8 @@ packages: strip-ansi: 6.0.0 dev: true - /tachi-common/0.1.9_ts-node@10.0.0+typescript@4.3.4: - resolution: {integrity: sha512-nNChWe41kwizagY9tS5PCHTTSbT7VP87lejXn8sX19x+StNax/KJDyGhwXEuYyqo0pCoPYttHGZc4Aar6SgVyQ==} + /tachi-common/0.1.10_ts-node@10.0.0+typescript@4.3.4: + resolution: {integrity: sha512-yiiaQqgCGt8E9HWL9k4PFWAQ6b6WEK1LIIhJlPmS0zQf/qDx8aM9gugFhBogX9ilLFC0ZVj8i3X8YozydAILNQ==} dependencies: monk: 7.3.4 tap: 15.0.9_ts-node@10.0.0+typescript@4.3.4 diff --git a/server/src/server/router/api/v1/users/_userID/integrations/arc/router.test.ts b/server/src/server/router/api/v1/users/_userID/integrations/arc/router.test.ts new file mode 100644 index 000000000..47a2e1c24 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/arc/router.test.ts @@ -0,0 +1,180 @@ +import db from "external/mongo/db"; +import t from "tap"; +import { CloseAllConnections } from "test-utils/close-connections"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; + +t.test("GET /api/v1/users/:userID/integrations/arc", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return the current authentication state.", async (t) => { + await db["arc-saved-profiles"].insert({ + userID: 1, + accountID: "foobar", + forImportType: "api/arc-iidx", + }); + + await db["arc-saved-profiles"].insert({ + userID: 2, + accountID: "barfoo", + forImportType: "api/arc-sdvx", + }); + + const res = await mockApi.get("/api/v1/users/1/integrations/arc").set("Cookie", cookie); + + t.hasStrict(res.body.body, { + iidx: { userID: 1, accountID: "foobar", forImportType: "api/arc-iidx" }, + sdvx: null, + }); + + t.end(); + }); + + t.test("Should reject APIKey authentication.", async (t) => { + await db["api-tokens"].insert({ + identifier: "foobar", + permissions: {}, + token: "foobar", + userID: 1, + }); + + const res = await mockApi + .get("/api/v1/users/1/integrations/arc") + .set("Authorization", "Bearer foobar"); + + t.equal(res.statusCode, 403); + t.match(res.body.description, /this request cannot be performed by an api key/iu); + + t.end(); + }); + + t.end(); +}); + +t.test("PATCH /api/v1/users/:userID/integrations/arc", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should update a users configured accountIDs.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1/integrations/arc") + .set("Cookie", cookie) + .send({ + iidx: "newAccountID", + }); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + iidx: { + accountID: "newAccountID", + userID: 1, + forImportType: "api/arc-iidx", + }, + sdvx: null, + }); + + const dbRes = await db["arc-saved-profiles"].findOne({ + userID: 1, + forImportType: "api/arc-iidx", + }); + + t.strictSame(dbRes, { + accountID: "newAccountID", + userID: 1, + forImportType: "api/arc-iidx", + }); + + t.end(); + }); + + t.test("Should update accountIDs if one already exists", async (t) => { + await db["arc-saved-profiles"].insert({ + userID: 1, + accountID: "OLD_ACCOUNT_ID", + forImportType: "api/arc-iidx", + }); + + const res = await mockApi + .patch("/api/v1/users/1/integrations/arc") + .set("Cookie", cookie) + .send({ + iidx: "newAccountID", + }); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + iidx: { + accountID: "newAccountID", + userID: 1, + forImportType: "api/arc-iidx", + }, + sdvx: null, + }); + + const dbRes = await db["arc-saved-profiles"].findOne({ + userID: 1, + forImportType: "api/arc-iidx", + }); + + t.strictSame(dbRes, { + accountID: "newAccountID", + userID: 1, + forImportType: "api/arc-iidx", + }); + + t.end(); + }); + + t.test("Should remove accountIDs if nulled", async (t) => { + await db["arc-saved-profiles"].insert({ + userID: 1, + accountID: "OLD_ACCOUNT_ID", + forImportType: "api/arc-iidx", + }); + + const res = await mockApi + .patch("/api/v1/users/1/integrations/arc") + .set("Cookie", cookie) + .send({ + iidx: null, + }); + + t.equal(res.statusCode, 200); + + t.hasStrict(res.body.body, { + iidx: null, + sdvx: null, + }); + + const dbRes = await db["arc-saved-profiles"].findOne({ + userID: 1, + forImportType: "api/arc-iidx", + }); + + t.equal(dbRes, null); + + t.end(); + }); + + t.test("Should reject requests with no modifications", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1/integrations/arc") + .set("Cookie", cookie) + .send({}); + + t.equal(res.statusCode, 400); + t.match(res.body.description, "Invalid request to modify nothing."); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/users/_userID/integrations/arc/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/arc/router.ts new file mode 100644 index 000000000..10024c0e5 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/arc/router.ts @@ -0,0 +1,128 @@ +/* eslint-disable no-await-in-loop */ +import { Router } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import CreateLogCtx from "lib/logger/logger"; +import prValidate from "server/middleware/prudence-validate"; +import { RequireKamaitachi } from "server/middleware/type-require"; +import { GetArcAuth } from "utils/queries/auth"; +import { FormatUserDoc } from "utils/user"; +import { RequireSelfRequestFromUser } from "../../middleware"; + +const router: Router = Router({ mergeParams: true }); + +const logger = CreateLogCtx(__filename); + +router.use(RequireKamaitachi); +router.use(RequireSelfRequestFromUser); + +/** + * Return this users integration status for ARC. + * @name GET /api/v1/users/:userID/integrations/arc + */ +router.get("/", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const [iidx, ddr, sdvx] = await Promise.all([ + GetArcAuth(user.id, "api/arc-iidx"), + GetArcAuth(user.id, "api/arc-ddr"), + GetArcAuth(user.id, "api/arc-sdvx"), + ]); + + return res.status(200).json({ + success: true, + description: `Retrieved integration information for ARC.`, + body: { + iidx, + ddr, + sdvx, + }, + }); +}); + +/** + * Modify this users integrations for ARC. + * @name PATCH /api/v1/users/:userID/integrations/arc + */ +router.patch( + "/", + prValidate({ iidx: "*?string", ddr: "*?string", sdvx: "*?string" }), + async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + if (Object.keys(req.body).length === 0) { + return res.status(400).json({ + success: false, + description: `Invalid request to modify nothing.`, + }); + } + + const [iidx, ddr, sdvx] = await Promise.all([ + GetArcAuth(user.id, "api/arc-iidx"), + GetArcAuth(user.id, "api/arc-ddr"), + GetArcAuth(user.id, "api/arc-sdvx"), + ]); + + const existingData = { iidx, ddr, sdvx }; + + for (const key of ["iidx", "ddr", "sdvx"] as const) { + const importType = `api/arc-${key}` as const; + + if (req.body[key] === null) { + logger.info( + `User ${FormatUserDoc(user)} removed ARC integration for ${importType}.` + ); + + await db["arc-saved-profiles"].remove( + { + userID: user.id, + forImportType: importType, + }, + { + single: true, + } + ); + } else if (req.body[key]) { + if (existingData[key]) { + logger.info(`User updated ARC integration for ${importType}.`); + await db["arc-saved-profiles"].update( + { + userID: user.id, + forImportType: importType, + }, + { + $set: { + accountID: req.body[key], + }, + } + ); + } else { + logger.info(`User created ARC integration for ${importType}.`); + await db["arc-saved-profiles"].insert({ + userID: user.id, + forImportType: importType, + accountID: req.body[key], + }); + } + } + } + + const [iidx2, ddr2, sdvx2] = await Promise.all([ + GetArcAuth(user.id, "api/arc-iidx"), + GetArcAuth(user.id, "api/arc-ddr"), + GetArcAuth(user.id, "api/arc-sdvx"), + ]); + + return res.status(200).json({ + success: true, + description: `Updated ARC integrations.`, + body: { + iidx: iidx2, + ddr: ddr2, + sdvx: sdvx2, + }, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.test.ts b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.test.ts new file mode 100644 index 000000000..6ab998f0f --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.test.ts @@ -0,0 +1,32 @@ +import t from "tap"; +import expMiddlewareMock from "express-request-mock"; +import { ValidateKaiType } from "./middleware"; + +t.test("#ValidateKaiType", (t) => { + const k = async (k: string) => { + const { res } = await expMiddlewareMock(ValidateKaiType, { params: { kaiType: k } }); + + return res.statusCode; + }; + + t.test("Should allow flo, eag or min, case insensitively.", async (t) => { + t.equal(await k("flo"), 200); + t.equal(await k("eag"), 200); + t.equal(await k("min"), 200); + t.equal(await k("FLO"), 200); + t.equal(await k("EAG"), 200); + t.equal(await k("MIN"), 200); + t.equal(await k("FlO"), 200); + t.equal(await k("EaG"), 200); + t.equal(await k("MiN"), 200); + t.equal(await k("nonsense"), 400); + t.equal(await k("bad"), 400); + t.equal(await k(""), 400); + t.equal(await k("FLO2"), 400); + t.equal(await k("2FLO"), 400); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.ts b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.ts new file mode 100644 index 000000000..34f1b9253 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/middleware.ts @@ -0,0 +1,12 @@ +import { RequestHandler } from "express"; + +export const ValidateKaiType: RequestHandler = (req, res, next) => { + if (!["min", "flo", "eag"].includes(req.params.kaiType.toLowerCase())) { + return res.status(400).json({ + success: false, + description: `Invalid kaiType - Expected min, flo or eag.`, + }); + } + + return next(); +}; diff --git a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.test.ts b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.test.ts new file mode 100644 index 000000000..83fae9216 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.test.ts @@ -0,0 +1,68 @@ +import db from "external/mongo/db"; +import t from "tap"; +import { CloseAllConnections } from "test-utils/close-connections"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; + +t.test("GET /api/v1/users/:userID/integrations/kai/:kaiType", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return false if unauthed with this kaiType", async (t) => { + const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie); + + t.equal(res.statusCode, 200); + t.equal(res.body.body.authStatus, false); + + t.end(); + }); + + t.test("Should return true if authed with this kaiType", async (t) => { + await db["kai-auth-tokens"].insert({ + refreshToken: "refresh", + service: "FLO", + token: "bar", + userID: 1, + }); + + const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie); + + t.equal(res.statusCode, 200); + t.equal(res.body.body.authStatus, true); + + t.end(); + }); + + t.test("Should return the auth status of this user specifically.", async (t) => { + await db["kai-auth-tokens"].insert([ + { + refreshToken: "refresh", + service: "EAG", + token: "bar", + userID: 1, + }, + { + refreshToken: "refresh", + service: "FLO", + token: "baz", + userID: 2, + }, + ]); + + const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie); + + t.equal(res.statusCode, 200); + t.equal(res.body.body.authStatus, false); + + t.end(); + }); + + t.end(); +}); + +// test is currently undoable due to issues with mocking out real Fetch calls. +t.todo("PATCH /api/v1/userse/:userID/integrations/kai/:kaiType/oauth2callback"); + +t.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts new file mode 100644 index 000000000..57dd62d51 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts @@ -0,0 +1,163 @@ +import { Router } from "express"; +import CreateLogCtx from "lib/logger/logger"; +import { + GetKaiTypeClientCredentials, + KaiTypeToBaseURL, +} from "lib/score-import/import-types/common/api-kai/utils"; +import prValidate from "server/middleware/prudence-validate"; +import { ValidateKaiType } from "./middleware"; +import p from "prudence"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { FormatUserDoc } from "utils/user"; +import { GetKaiAuth } from "utils/queries/auth"; +import { RequireSelfRequestFromUser } from "../../../middleware"; +import { RequireKamaitachi } from "server/middleware/type-require"; + +const router: Router = Router({ mergeParams: true }); + +const logger = CreateLogCtx(__filename); + +router.use(RequireKamaitachi, RequireSelfRequestFromUser, ValidateKaiType); + +/** + * Return the authentication status for this kaiType. + * @note - Express's types infer arg0 of "/" to mean no params, for some reason. + * the generic overrides this behaviour. + * + * @name GET /api/v1/users/:userID/integrations/kai/:kaiType + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +router.get("/", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const kaiType = req.params.kaiType.toUpperCase() as "FLO" | "EAG" | "MIN"; + + const authDoc = await GetKaiAuth(user.id, kaiType); + + return res.status(200).json({ + success: true, + description: authDoc ? `User is authenticated.` : `User is unauthenticated.`, + body: { + authStatus: !!authDoc, + }, + }); +}); + +const KAI_OAUTH2_RETURN_SCHEMA = { + access_token: "string", + refresh_token: "string", +}; + +/** + * The OAuth2 callback used by Kai to send an intermediate token to. + * @note The way this is implemented is *really* weird due to the fact that + * the tachi-server code cannot have any knowledge of the tachi-client code, + * and the two must be agnostic. + * + * This means the tachi-client will handle the redirecting, and will check + * query params for ?code=12345 to know when to POST us with the code + * to perform an update. + * + * @param code - An intermediate code to use to get the real auth token. + * + * @name POST /api/v1/users/:userID/integrations/kai/:kaiType/oauth2callback + */ +router.post( + "/oauth2callback", + prValidate({ code: "string" }, {}, { allowExcessKeys: true }), + async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const kaiType = req.params.kaiType.toUpperCase() as "FLO" | "EAG" | "MIN"; + + const baseUrl = KaiTypeToBaseURL(kaiType); + + const maybeCredentials = GetKaiTypeClientCredentials(kaiType); + + if (!maybeCredentials) { + logger.severe( + `Attempted to /callback ${kaiType}, but this server has no oauth2 credentials configured for that type.` + ); + return res.status(500).json({ + success: false, + description: `A fatal error has occured, This has been reported.`, + }); + } + + const { CLIENT_SECRET, CLIENT_ID } = maybeCredentials; + + const url = new URL(`${baseUrl}/oauth/token`); + + url.searchParams.append("code", req.body.code); + url.searchParams.append("grant_type", "authorization_code"); + url.searchParams.append("client_secret", CLIENT_SECRET); + url.searchParams.append("client_id", CLIENT_ID); + url.searchParams.append("redirect_uri", "somewhere?"); + + // this also isn't a POST?? + const getTokenRes = await fetch(url.href); + + if (getTokenRes.status !== 200) { + logger.error( + `Unexpected status of ${getTokenRes.status} from ${url.href} oauth2 flow.` + ); + + return res.status(500).json({ + success: false, + description: `A fatal error has occured, This has been reported.`, + }); + } + + let json; + try { + json = await getTokenRes.json(); + } catch (err) { + logger.error(`Error parsing JSON in response body from getTokenRes.`, { + res: getTokenRes, + err, + }); + + return res.status(500).json({ + success: false, + description: `A fatal error has occured, This has been reported.`, + }); + } + + const err = p(json, KAI_OAUTH2_RETURN_SCHEMA, {}, { allowExcessKeys: true }); + + if (err) { + logger.error(`Validation error in JSON return from ${url.href}.`, { err }); + return res.status(500).json({ + success: false, + description: `A fatal error has occured, This has been reported.`, + }); + } + + await db["kai-auth-tokens"].update( + { + userID: user.id, + service: kaiType, + }, + { + $set: { + userID: user.id, + service: kaiType, + refreshToken: json.refresh_token, + token: json.access_token, + }, + }, + { + upsert: true, + } + ); + + logger.info(`Updated Auth for ${kaiType} for user ${FormatUserDoc(user)}.`); + + return res.status(200).json({ + success: true, + description: `Successfully updated auth for ${kaiType}`, + body: {}, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/integrations/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/router.ts new file mode 100644 index 000000000..918ea9484 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/router.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import arcRouter from "./arc/router"; +import kaiKaiTypeRouter from "./kai/_kaiType/router"; + +const router: Router = Router({ mergeParams: true }); + +router.use("/arc", arcRouter); +router.use("/kai/:kaiType", kaiKaiTypeRouter); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/middleware.ts b/server/src/server/router/api/v1/users/_userID/middleware.ts index 98a666735..672a17dab 100644 --- a/server/src/server/router/api/v1/users/_userID/middleware.ts +++ b/server/src/server/router/api/v1/users/_userID/middleware.ts @@ -38,6 +38,9 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => { return next(); }; +/** + * Require the user making this request to also be the user in the :userID param. + */ export const RequireAuthedAsUser: RequestHandler = (req, res, next) => { const user = req[SYMBOL_TachiData]!.requestedUser!; @@ -50,3 +53,21 @@ export const RequireAuthedAsUser: RequestHandler = (req, res, next) => { return next(); }; + +/** + * Require that this request is made with a Cookie, instead of any + * API key. This is for things that services should not be allowed to + * alter/access, like integration information. + */ +export const RequireSelfRequestFromUser: RequestHandler = (req, res, next) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + if (!req.session.tachi?.userID || req[SYMBOL_TachiAPIAuth].userID !== user.id) { + return res.status(403).json({ + success: false, + description: `This request cannot be performed by an API key, and requires authentication.`, + }); + } + + return next(); +}; diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index 4da037d93..3396fb733 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -5,6 +5,7 @@ import { GetUserFromParam } from "./middleware"; import gamePTRouter from "./games/_game/_playtype/router"; import bannerRouter from "./banner/router"; import pfpRouter from "./pfp/router"; +import integrationsRouter from "./integrations/router"; const router: Router = Router({ mergeParams: true }); @@ -46,5 +47,6 @@ router.get("/game-stats", async (req, res) => { router.use("/games/:game/:playtype", gamePTRouter); router.use("/pfp", pfpRouter); router.use("/banner", bannerRouter); +router.use("/integrations", integrationsRouter); export default router; diff --git a/server/src/test-utils/mock-db/kai-auth-tokens.json b/server/src/test-utils/mock-db/kai-auth-tokens.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/server/src/test-utils/mock-db/kai-auth-tokens.json @@ -0,0 +1 @@ +[] \ No newline at end of file