diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index bfaf13289..99450c10f 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -65,6 +65,7 @@ export interface TachiConfig { CLIENT_DEV_SERVER?: string | null; SERVER_TYPE_INFO: StaticConfig.ServerConfig; RATE_LIMIT: integer; + OAUTH_CLIENT_CAP: integer; OPTIONS_ALWAYS_SUCCEEDS?: boolean; NO_CONSOLE?: boolean; } @@ -95,6 +96,7 @@ const err = p(config, { CLIENT_DEV_SERVER: "*?string", TYPE: p.isIn("ktchi", "btchi", "omni"), RATE_LIMIT: p.optional(p.isPositiveInteger), + OAUTH_CLIENT_CAP: p.optional(p.isPositiveInteger), OPTIONS_ALWAYS_SUCCEEDS: "*boolean", NO_CONSOLE: "*boolean", }); @@ -115,6 +117,7 @@ const tachiConfig = config as TachiConfig; // default rate limit 500 tachiConfig.RATE_LIMIT ??= 500; +tachiConfig.OAUTH_CLIENT_CAP ??= 15; export const ServerTypeInfo = tachiConfig.SERVER_TYPE_INFO; export const ServerConfig = tachiConfig; diff --git a/server/src/server/router/api/v1/oauth/clients/middleware.test.ts b/server/src/server/router/api/v1/oauth/clients/middleware.test.ts new file mode 100644 index 000000000..fafb9c94c --- /dev/null +++ b/server/src/server/router/api/v1/oauth/clients/middleware.test.ts @@ -0,0 +1,114 @@ +import t from "tap"; +import { GetClientFromID, RequireOwnershipOfClient } from "./middleware"; +import expMiddlewareMock from "express-request-mock"; +import ResetDBState from "test-utils/resets"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; + +t.test("#GetClientFromID", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should assign the client to req[@@TachiData] if exists.", async (t) => { + const { req } = await expMiddlewareMock(GetClientFromID, { + params: { + clientID: "OAUTH2_CLIENT_ID", + }, + }); + + t.strictSame( + req[SYMBOL_TachiData]?.oauth2ClientDoc, + { + clientID: "OAUTH2_CLIENT_ID", + // clientSecret: "OAUTH2_CLIENT_SECRET", + name: "Test_Service", + author: 1, + requestedPermissions: ["customise_profile"], + redirectUri: "https://example.com/callback", + }, + "Should assign clientDoc with secret ommitted." + ); + + t.end(); + }); + + t.test("Should return 404 if client does not exist.", async (t) => { + const { res } = await expMiddlewareMock(GetClientFromID, { + params: { + clientID: "NONSENSE", + }, + }); + + t.equal(res.statusCode, 404); + + t.hasStrict(res._getJSONData(), { + success: false, + description: "This client does not exist.", + }); + + t.end(); + }); + + t.end(); +}); + +t.test("#RequireOwnershipOfClient", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return 401 if the user has no authentication.", async (t) => { + const { res } = await expMiddlewareMock(RequireOwnershipOfClient, { + body: { + __terribleHackOauth2ClientDoc: { + author: 1, + }, + }, + session: {}, + }); + + t.equal(res.statusCode, 401); + + t.end(); + }); + + t.test("Should return 403 if the user does not own this client.", async (t) => { + const { res } = await expMiddlewareMock(RequireOwnershipOfClient, { + body: { + __terribleHackOauth2ClientDoc: { + author: 1, + }, + }, + session: { + tachi: { + user: { + id: 2, + }, + }, + }, + }); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Should continue if this is their session.", async (t) => { + const { res } = await expMiddlewareMock(RequireOwnershipOfClient, { + body: { + __terribleHackOauth2ClientDoc: { + author: 1, + }, + }, + session: { + tachi: { + user: { + id: 1, + }, + }, + }, + }); + + t.equal(res.statusCode, 200); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/oauth/clients/middleware.ts b/server/src/server/router/api/v1/oauth/clients/middleware.ts new file mode 100644 index 000000000..050ccdcae --- /dev/null +++ b/server/src/server/router/api/v1/oauth/clients/middleware.ts @@ -0,0 +1,63 @@ +import { RequestHandler } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { OAuth2ApplicationDocument } from "tachi-common"; +import { AssignToReqTachiData } from "utils/req-tachi-data"; + +export const GetClientFromID: RequestHandler = async (req, res, next) => { + const client = await db["oauth2-clients"].findOne( + { + clientID: req.params.clientID, + }, + { + projection: { + clientSecret: 0, + }, + } + ); + + if (!client) { + return res.status(404).json({ + success: false, + description: `This client does not exist.`, + }); + } + + AssignToReqTachiData(req, { oauth2ClientDoc: client }); + + return next(); +}; + +export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => { + let client: Omit; + + // @hack + // Sadly, expMiddlewareMock doesn't support mounting symbol props on + // request. To hack around this for testing, we perform this hack. + // There's an open issue for this here: https://github.com/i-like-robots/express-request-mock/issues/19 + /* istanbul ignore next */ + if (process.env.NODE_ENV === "test" && req.body.__terribleHackOauth2ClientDoc) { + // obviously a glaring hack and security flaw - this only applies + // in testing. + client = req.body.__terribleHackOauth2ClientDoc; + } else { + client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + } + + const user = req.session.tachi?.user; + + if (!user) { + return res.status(401).json({ + success: false, + description: `You are not authenticated (for a session-level request, atleast).`, + }); + } + + if (user.id !== client.author) { + return res.status(403).json({ + success: false, + description: `You are not authorized to perform this action.`, + }); + } + return next(); +}; diff --git a/server/src/server/router/api/v1/oauth/clients/router.test.ts b/server/src/server/router/api/v1/oauth/clients/router.test.ts new file mode 100644 index 000000000..d0c91f6f7 --- /dev/null +++ b/server/src/server/router/api/v1/oauth/clients/router.test.ts @@ -0,0 +1,409 @@ +import db from "external/mongo/db"; +import { ServerConfig } from "lib/setup/config"; +import { APITokenDocument, OAuth2ApplicationDocument } from "tachi-common"; +import t from "tap"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; + +const clientDataset: OAuth2ApplicationDocument[] = [ + { + author: 1, + clientID: "CLIENT_1", + clientSecret: "SECRET_1", + name: "foo", + redirectUri: "example.com", + requestedPermissions: ["customise_profile"], + }, + { + author: 1, + clientID: "CLIENT_2", + clientSecret: "SECRET_2", + name: "bar", + redirectUri: "example.com", + requestedPermissions: ["customise_profile"], + }, + { + author: 2, + clientID: "CLIENT_3", + clientSecret: "SECRET_3", + name: "baz", + redirectUri: "example.com", + requestedPermissions: ["customise_profile"], + }, +]; + +t.test("GET /api/v1/oauth/clients", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db["oauth2-clients"].remove({}); + await db["oauth2-clients"].insert(clientDataset); + }); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should retrieve your clients.", async (t) => { + const res = await mockApi.get("/api/v1/oauth/clients").set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + // note: force sort alphabetically so this isn't dependent on + // mongodb natural order. + t.strictSame( + res.body.body.sort((a: any, b: any) => a.name - b.name), + [ + { + author: 1, + clientID: "CLIENT_1", + clientSecret: "SECRET_1", + name: "foo", + redirectUri: "example.com", + requestedPermissions: ["customise_profile"], + }, + { + author: 1, + clientID: "CLIENT_2", + clientSecret: "SECRET_2", + name: "bar", + redirectUri: "example.com", + requestedPermissions: ["customise_profile"], + }, + ] + ); + + t.end(); + }); + + t.test("Requires self-key level authentication.", async (t) => { + const res = await mockApi.get("/api/v1/oauth/clients"); + + t.equal(res.statusCode, 401); + + const res2 = await mockApi + .get("/api/v1/oauth/clients") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res2.statusCode, 401); + + t.end(); + }); + + t.end(); +}); + +t.test("POST /api/v1/oauth/clients/create", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should create a new client.", async (t) => { + const res = await mockApi + .post("/api/v1/oauth/clients/create") + .send({ + name: "Hello World", + redirectUri: "https://example.com/callback", + permissions: ["customise_profile"], + }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + const dbRes = await db["oauth2-clients"].findOne({ clientID: res.body.body.clientID }); + + t.not(dbRes, null, "Should be saved in the database."); + + t.strictSame(dbRes?.requestedPermissions, ["customise_profile"]); + + t.end(); + }); + + t.test("Should validate names to be between 3 and 80 characters.", async (t) => { + const res = await mockApi + .post("/api/v1/oauth/clients/create") + .send({ + name: "2", + redirectUri: "https://example.com/callback", + permissions: ["customise_profile"], + }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + + const res2 = await mockApi + .post("/api/v1/oauth/clients/create") + .send({ + name: "2".repeat(100), + redirectUri: "https://example.com/callback", + permissions: ["customise_profile"], + }) + .set("Cookie", cookie); + + t.equal(res2.statusCode, 400); + + t.end(); + }); + + t.test("Should validate urls to be between 3 and 80 characters.", async (t) => { + const res = await mockApi + .post("/api/v1/oauth/clients/create") + .send({ + name: "Hello World", + redirectUri: "ftp://example.com/callback", + permissions: ["customise_profile"], + }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.test("Should validate permissions.", async (t) => { + const res = await mockApi + .post("/api/v1/oauth/clients/create") + .send({ + name: "Hello World", + redirectUri: "http://example.com/callback", + permissions: ["permission_that_doesnt_exist"], + }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.test("Should cap a user at OAUTH_CLIENT_CAP.", async (t) => { + for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) { + // eslint-disable-next-line no-await-in-loop + await mockApi + .post("/api/v1/oauth/clients/create") + .send({ + name: "Hello World", + redirectUri: "https://example.com/callback", + permissions: ["customise_profile"], + }) + .set("Cookie", cookie); + } + + const res = await mockApi + .post("/api/v1/oauth/clients/create") + .send({ + name: "Hello World", + redirectUri: "https://example.com/callback", + permissions: ["customise_profile"], + }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + + const dbCount = await db["oauth2-clients"].count({ author: 1 }); + + t.equal(dbCount, ServerConfig.OAUTH_CLIENT_CAP); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/oauth/clients/:clientID", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return information about the client at that ID.", async (t) => { + const res = await mockApi.get("/api/v1/oauth/clients/OAUTH2_CLIENT_ID"); + + t.strictSame(res.body.body, { + clientID: "OAUTH2_CLIENT_ID", + // clientSecret: "OAUTH2_CLIENT_SECRET", MUST NOT have secret! + name: "Test_Service", + author: 1, + requestedPermissions: ["customise_profile"], + redirectUri: "https://example.com/callback", + }); + + t.end(); + }); + + t.test("Should return 404 if client doesn't exist.", async (t) => { + const res = await mockApi.get("/api/v1/oauth/clients/BAD_CLIENT"); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.end(); +}); + +t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db["oauth2-clients"].remove({}); + await db["oauth2-clients"].insert(clientDataset); + }); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should be able to modify a clients name.", async (t) => { + const res = await mockApi + .patch("/api/v1/oauth/clients/CLIENT_1") + .send({ name: "NEW NAME" }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + t.equal(res.body.body.name, "NEW NAME"); + + const dbRes = await db["oauth2-clients"].findOne({ + clientID: "CLIENT_1", + }); + + t.equal(dbRes?.name, "NEW NAME"); + + t.end(); + }); + + t.test("Must validate name to be between 3 and 80 characters.", async (t) => { + const res = await mockApi + .patch("/api/v1/oauth/clients/CLIENT_1") + .send({ name: "2" }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + + const res2 = await mockApi + .patch("/api/v1/oauth/clients/CLIENT_1") + .send({ name: "2".repeat(100) }) + .set("Cookie", cookie); + + t.equal(res2.statusCode, 400); + + t.end(); + }); + + t.test("Must provide name to modify.", async (t) => { + const res = await mockApi + .patch("/api/v1/oauth/clients/CLIENT_1") + .send({}) + .set("Cookie", cookie); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.test("Must be owner of client.", async (t) => { + const res = await mockApi + .patch("/api/v1/oauth/clients/CLIENT_3") + .send({ name: "foo" }) + .set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + const res2 = await mockApi.patch("/api/v1/oauth/clients/CLIENT_3").send({ name: "foo" }); + + t.equal(res2.statusCode, 401); + + t.end(); + }); + + t.end(); +}); + +t.test("POST /api/v1/oauth/clients/:clientID/reset-secret", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db["oauth2-clients"].remove({}); + await db["oauth2-clients"].insert(clientDataset); + }); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should reset the client's secret.", async (t) => { + const res = await mockApi + .post("/api/v1/oauth/clients/CLIENT_1/reset-secret") + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + t.not(res.body.body.clientSecret, "SECRET_1", "Should return the new secret."); + + const dbRes = await db["oauth2-clients"].findOne({ + clientID: "CLIENT_1", + }); + + t.not(dbRes?.clientSecret, "SECRET_1", "Should change secret to anything else."); + + t.end(); + }); + + t.test("Must be owner of client.", async (t) => { + const res = await mockApi + .post("/api/v1/oauth/clients/CLIENT_3/reset-secret") + .set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + const res2 = await mockApi.post("/api/v1/oauth/clients/CLIENT_3/reset-secret"); + + t.equal(res2.statusCode, 401); + + t.end(); + }); + + t.end(); +}); + +t.test("DELETE /api/v1/oauth/clients/:clientID", async (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db["oauth2-clients"].remove({}); + await db["oauth2-clients"].insert(clientDataset); + }); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should destroy the client and all associated api keys.", async (t) => { + await db["api-tokens"].insert([ + { + fromOAuth2Client: "CLIENT_1", + token: "foo", + userID: 1, + }, + { + fromOAuth2Client: "CLIENT_1", + token: "bar", + userID: 1, + }, + ] as APITokenDocument[]); + + const res = await mockApi.delete("/api/v1/oauth/clients/CLIENT_1").set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + const dbRes = await db["oauth2-clients"].findOne({ clientID: "CLIENT_1" }); + + t.equal(dbRes, null, "Should no longer exist."); + + const dbCount = await db["api-tokens"].count({ fromOAuth2Client: "CLIENT_1" }); + + t.equal(dbCount, 0, "Should have destroyed all related api tokens."); + + t.end(); + }); + + t.test("Must be owner of client.", async (t) => { + const res = await mockApi.delete("/api/v1/oauth/clients/CLIENT_3").set("Cookie", cookie); + + t.equal(res.statusCode, 403); + + const res2 = await mockApi.delete("/api/v1/oauth/clients/CLIENT_3"); + + t.equal(res2.statusCode, 401); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/oauth/clients/router.ts b/server/src/server/router/api/v1/oauth/clients/router.ts new file mode 100644 index 000000000..2b7168a99 --- /dev/null +++ b/server/src/server/router/api/v1/oauth/clients/router.ts @@ -0,0 +1,241 @@ +import { Router } from "express"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import prValidate from "server/middleware/prudence-validate"; +import p from "prudence"; +import db from "external/mongo/db"; +import { GetClientFromID, RequireOwnershipOfClient } from "./middleware"; +import { DedupeArr, IsValidURL, Random20Hex } from "utils/misc"; +import CreateLogCtx from "lib/logger/logger"; +import { APIPermissions } from "tachi-common"; +import { AllPermissions } from "server/middleware/auth"; +import { ServerConfig } from "lib/setup/config"; +import { FormatUserDoc } from "utils/user"; + +const logger = CreateLogCtx(__filename); + +const router: Router = Router({ mergeParams: true }); + +/** + * Retrieve the clients you created. Must be performed with a session-level request. + * + * @warn This also returns the client_secrets! Those *have* to be kept secret. + * + * @name GET /api/v1/oauth/clients + */ +router.get("/", async (req, res) => { + const user = req.session.tachi?.user; + + if (!user) { + return res.status(401).json({ + success: false, + description: `You are not authenticated (for a session-level request, atleast).`, + }); + } + + const clients = await db["oauth2-clients"].find({ + author: user.id, + }); + + return res.status(200).json({ + success: true, + description: `Returned ${clients.length} clients.`, + body: clients, + }); +}); + +/** + * Create a new OAuth2 Client. Requires session-level auth. + * + * @param name - A string that identifies this client. + * @param redirectUri - The redirectUri this client uses. + * @param permissions - An array of APIPermissions this client is expected to use. + * + * @name POST /api/v1/oauth/clients/create + */ +router.post( + "/create", + prValidate({ + name: p.isBoundedString(3, 80), + redirectUri: "string", + permissions: [p.isIn(Object.keys(AllPermissions))], + }), + async (req, res) => { + if (!req.session.tachi?.user) { + return res.status(401).json({ + success: false, + description: `You are not authenticated.`, + }); + } + + const existingClients = await db["oauth2-clients"].find({ + author: req.session.tachi.user.id, + }); + + if (existingClients.length >= ServerConfig.OAUTH_CLIENT_CAP) { + return res.status(400).json({ + success: false, + description: `You have created too many OAuth2 clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`, + }); + } + + const permissions = DedupeArr(req.body.permissions); + + if (!IsValidURL(req.body.redirectUri)) { + return res.status(400).json({ + success: false, + description: `Invalid URL for ${req.body.redirectUri}.`, + }); + } + + const clientID = Random20Hex(); + const clientSecret = Random20Hex(); + + const clientDoc = { + clientID, + clientSecret, + requestedPermissions: permissions, + name: req.body.name, + author: req.session.tachi.user.id, + redirectUri: req.body.redirectUri, + }; + + await db["oauth2-clients"].insert(clientDoc); + + logger.info( + `User ${FormatUserDoc(req.session.tachi.user)} created a new OAuth2 Client ${ + req.body.name + } (${clientID}).` + ); + + return res.status(200).json({ + success: true, + description: `Created a new OAuth2 client.`, + body: clientDoc, + }); + } +); + +/** + * Retrieves information about the client at this ID. + * + * @name GET /api/v1/oauth/clients/:clientID + */ +router.get("/:clientID", GetClientFromID, (req, res) => { + const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + + return res.status(200).json({ + success: true, + description: `Retrieved client ${client.name}.`, + body: client, + }); +}); + +/** + * Update an existing client. The requester must be the owner of this + * client, and must also be making a session-level request. + * + * @param name - Change the name of this client. This is the only option at + * the moment, and I do not imagine that will change. + * + * @name PATCH /api/v1/oauth/clients/:clientID + */ +router.patch( + "/:clientID", + GetClientFromID, + RequireOwnershipOfClient, + prValidate({ name: p.isBoundedString(3, 80) }), + async (req, res) => { + const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + + const newClient = await db["oauth2-clients"].findOneAndUpdate( + { + clientID: client.clientID, + }, + { + $set: { + name: req.body.name, + }, + } + ); + + logger.info( + `OAuth2 Client ${client.name} (${client.clientID}) has been renamed to ${req.body.name}.` + ); + + return res.status(200).json({ + success: true, + description: `Updated client.`, + body: newClient, + }); + } +); + +/** + * Resets the clientSecret for this client. + * This will NOT invalidate any existing tokens, as per oauth2 spec. + * + * @name POST /api/v1/oauth/clients/:clientID/reset-secret + */ +router.post( + "/:clientID/reset-secret", + GetClientFromID, + RequireOwnershipOfClient, + async (req, res) => { + const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + const clientName = `${client.name} (${client.clientID})`; + + logger.info(`Recieved request to reset client secret for ${clientName}`); + + const newSecret = Random20Hex(); + + const newClient = await db["oauth2-clients"].findOneAndUpdate( + { + clientID: client.clientID, + }, + { + $set: { clientSecret: newSecret }, + } + ); + + logger.info(`Reset secret for ${clientName}.`); + + return res.status(200).json({ + success: true, + description: `Reset secret.`, + body: newClient, + }); + } +); + +/** + * Delete this client. Must be authorized at a session-request level. + * + * @name DELETE /api/v1/oauth/clients/:clientID + */ +router.delete("/:clientID", GetClientFromID, RequireOwnershipOfClient, async (req, res) => { + const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + + const clientName = `${client.name} (${client.clientID})`; + + logger.info(`Recieved request to destroy OAuth2 Client ${client.name} (${client.clientID})`); + + logger.verbose(`Removing OAuth2 Client ${clientName}.`); + await db["oauth2-clients"].remove({ + clientID: client.clientID, + }); + logger.info(`Removed OAuth2 Client ${clientName}.`); + + logger.verbose(`Removing all associated api tokens.`); + const result = await db["api-tokens"].remove({ + fromOAuth2Client: client.clientID, + }); + logger.info(`Removed ${result.deletedCount} api tokens from ${clientName}.`); + + return res.status(200).json({ + success: true, + description: `Deleted ${clientName}.`, + body: {}, + }); +}); + +export default router; diff --git a/server/src/server/router/api/v1/oauth/router.test.ts b/server/src/server/router/api/v1/oauth/router.test.ts new file mode 100644 index 000000000..df5cd5e30 --- /dev/null +++ b/server/src/server/router/api/v1/oauth/router.test.ts @@ -0,0 +1,95 @@ +import db from "external/mongo/db"; +import t from "tap"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; + +t.test("POST /api/v1/oauth/token", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should grant and create an api token.", async (t) => { + const res = await mockApi.post(`/api/v1/oauth/token`).send({ + client_id: "OAUTH2_CLIENT_ID", + client_secret: "OAUTH2_CLIENT_SECRET", + grant_type: "authorization_code", + redirect_uri: "https://example.com/callback", + code: "AUTH_CODE", + }); + + t.equal(res.statusCode, 200); + + const tokenDoc = await db["api-tokens"].findOne({ + token: res.body.body.token, + }); + + t.not(tokenDoc, null); + t.equal(tokenDoc?.userID, 1); + t.equal(tokenDoc?.fromOAuth2Client, "OAUTH2_CLIENT_ID"); + t.strictSame( + tokenDoc?.permissions, + { + customise_profile: true, + }, + "Should assign the permissions this client has configured." + ); + + t.end(); + }); + + t.test("Requires a valid code.", async (t) => { + const res = await mockApi.post(`/api/v1/oauth/token`).send({ + client_id: "OAUTH2_CLIENT_ID", + client_secret: "OAUTH2_CLIENT_SECRET", + grant_type: "authorization_code", + redirect_uri: "https://example.com/callback", + code: "invalidcode", + }); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Requires a valid clientID.", async (t) => { + const res = await mockApi.post(`/api/v1/oauth/token`).send({ + client_id: "INVALID_CLIENT_ID", + client_secret: "OAUTH2_CLIENT_SECRET", + grant_type: "authorization_code", + redirect_uri: "https://example.com/callback", + code: "AUTH_CODE", + }); + + t.equal(res.statusCode, 404); + + t.end(); + }); + + t.test("Requires a valid client secret.", async (t) => { + const res = await mockApi.post(`/api/v1/oauth/token`).send({ + client_id: "OAUTH2_CLIENT_ID", + client_secret: "INVALID_SECRET", + grant_type: "authorization_code", + redirect_uri: "https://example.com/callback", + code: "AUTH_CODE", + }); + + t.equal(res.statusCode, 403); + + t.end(); + }); + + t.test("Requires an identical redirect_uri.", async (t) => { + const res = await mockApi.post(`/api/v1/oauth/token`).send({ + client_id: "OAUTH2_CLIENT_ID", + client_secret: "OAUTH2_CLIENT_SECRET", + grant_type: "authorization_code", + redirect_uri: "https://invalid.example.com/callback", + code: "AUTH_CODE", + }); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/oauth/router.ts b/server/src/server/router/api/v1/oauth/router.ts new file mode 100644 index 000000000..4cb9b9eb1 --- /dev/null +++ b/server/src/server/router/api/v1/oauth/router.ts @@ -0,0 +1,119 @@ +import { Router } from "express"; +import db from "external/mongo/db"; +import p from "prudence"; +import prValidate from "server/middleware/prudence-validate"; +import { Random20Hex } from "utils/misc"; +import clientsRouter from "./clients/router"; + +const router: Router = Router({ mergeParams: true }); + +/** + * Converts an auth code into a valid API key that is returned. + * + * @note The params here are deliberately snake cased as that's what + * the digitalocean examples for oauth2 do. I have no idea whether that's + * part of the spec or not, but it probably is. + * + * @param client_id - The id for the client requesting a token. + * @param client_secret - The secret for the client. + * @param grant_type - Only exactly "authorization_code" is supported at the moment. + * @param redirect_uri - Must be the exact redirectUri registered with this client. + * @param code - The code to convert into an API token. + * + * @name POST /api/v1/oauth/token + */ +router.post( + "/token", + prValidate({ + client_id: "string", + client_secret: "string", + grant_type: p.is("authorization_code"), + redirect_uri: "string", + code: "string", + }), + async (req, res) => { + const client = await db["oauth2-clients"].findOne({ + clientID: req.body.client_id, + }); + + if (!client) { + return res.status(404).json({ + success: false, + description: `This client does not exist.`, + }); + } + + if (client.clientSecret !== req.body.client_secret) { + return res.status(403).json({ + success: false, + description: `Invalid secret.`, + }); + } + + // I honest to god have no idea what the point of this check is + // but it's part of the oauth spec. + if (client.redirectUri !== req.body.redirect_uri) { + return res.status(400).json({ + success: false, + description: `This redirect_uri does not match with your registered client redirect_uri ${client.redirectUri}.`, + }); + } + + const codeDoc = await db["oauth2-auth-codes"].findOne({ code: req.body.code }); + + if (!codeDoc) { + return res.status(404).json({ + success: false, + description: `This code does not exist.`, + }); + } + + const apiDoc = { + userID: codeDoc.userID, + token: Random20Hex(), + identifier: `${client.name} Token`, + // converts ["a","b"] to {a: true, b: true}. + permissions: Object.fromEntries(client.requestedPermissions.map((e) => [e, true])), + fromOAuth2Client: client.clientID, + }; + + // Now we can actually register the api key (lol) + await db["api-tokens"].insert(apiDoc); + + return res.status(200).json({ + success: true, + description: `Successfully authenticated.`, + body: apiDoc, + }); + } +); + +/** + * Creates an authorization code for this user (inferred from session). + * + * @name POST /api/v1/oauth/create-code + */ +router.post("/create-code", async (req, res) => { + if (!req.session.tachi?.user) { + return res.status(401).json({ + success: false, + description: `You are not authenticated.`, + }); + } + + const code = Random20Hex(); + + const doc = { code, userID: req.session.tachi.user.id, createdOn: Date.now() }; + + await db["oauth2-auth-codes"].insert(doc); + + return res.status(200).json({ + success: true, + description: `Successfully created code.`, + body: doc, + }); +}); + +router.use("/clients", clientsRouter); + +export default router; diff --git a/server/src/server/router/api/v1/router.ts b/server/src/server/router/api/v1/router.ts index 7b19c5a42..850b51a00 100644 --- a/server/src/server/router/api/v1/router.ts +++ b/server/src/server/router/api/v1/router.ts @@ -8,6 +8,7 @@ import gamesRouter from "./games/router"; import searchRouter from "./search/router"; import scoresRouter from "./scores/router"; import sessionsRouter from "./sessions/router"; +import oauthRouter from "./oauth/router"; const router: Router = Router({ mergeParams: true }); @@ -20,7 +21,13 @@ router.use("/games", gamesRouter); router.use("/search", searchRouter); router.use("/scores", scoresRouter); router.use("/sessions", sessionsRouter); +router.use("/oauth", oauthRouter); +/** + * Return a JSON 404 response if an endpoint is hit that does not exist. + * + * @name ALL /api/v1/* + */ router.all("*", (req, res) => res.status(404).json({ success: false, diff --git a/server/src/test-utils/mock-db/oauth2-auth-codes.json b/server/src/test-utils/mock-db/oauth2-auth-codes.json new file mode 100644 index 000000000..bf72e17b4 --- /dev/null +++ b/server/src/test-utils/mock-db/oauth2-auth-codes.json @@ -0,0 +1,5 @@ +[{ + "code": "AUTH_CODE", + "userID": 1, + "createdOn": 0 +}] \ No newline at end of file diff --git a/server/src/test-utils/mock-db/oauth2-clients.json b/server/src/test-utils/mock-db/oauth2-clients.json new file mode 100644 index 000000000..bbe580883 --- /dev/null +++ b/server/src/test-utils/mock-db/oauth2-clients.json @@ -0,0 +1,8 @@ +[{ + "clientID": "OAUTH2_CLIENT_ID", + "clientSecret": "OAUTH2_CLIENT_SECRET", + "name": "Test_Service", + "author": 1, + "requestedPermissions": ["customise_profile"], + "redirectUri": "https://example.com/callback" +}] \ No newline at end of file diff --git a/server/src/utils/misc.test.ts b/server/src/utils/misc.test.ts index 0c1caa531..5ac99b695 100644 --- a/server/src/utils/misc.test.ts +++ b/server/src/utils/misc.test.ts @@ -1,5 +1,5 @@ import t from "tap"; -import { GetMilisecondsSince } from "./misc"; +import { GetMilisecondsSince, IsValidURL } from "./misc"; t.test("#GetMilisecondsSince", (t) => { const time = GetMilisecondsSince(10n); @@ -7,3 +7,17 @@ t.test("#GetMilisecondsSince", (t) => { t.end(); }); + +t.test("#IsValidURL", (t) => { + t.ok(IsValidURL("https://example.com")); + t.ok(IsValidURL("http://example.com")); + t.ok(IsValidURL("http://example.com/suburl")); + t.ok(IsValidURL("http://example.com#href")); + t.ok(IsValidURL("http://example.com?querystring")); + + t.notOk(IsValidURL("ftp://example.com")); + // t.notOk(IsValidURL("http://example")); lol this is valid???? insane. + // t.notOk(IsValidURL("http:/example.com")); this is also valid, the JS URL parser is ridiculously lenient. Whatever. + + t.end(); +}); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 75f5ad566..ea5ff19be 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -103,3 +103,12 @@ export function DeleteUndefinedProps(record: Record) { } } } + +export function IsValidURL(string: string) { + try { + const url = new URL(string); + return url.protocol === "http:" || url.protocol === "https:"; + } catch (err) { + return false; + } +}