mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-13 01:38:29 +03:00
OAuth2 Support.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<OAuth2ApplicationDocument, "clientSecret">;
|
||||
|
||||
// @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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<APIPermissions>(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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[{
|
||||
"code": "AUTH_CODE",
|
||||
"userID": 1,
|
||||
"createdOn": 0
|
||||
}]
|
||||
@@ -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"
|
||||
}]
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -103,3 +103,12 @@ export function DeleteUndefinedProps(record: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function IsValidURL(string: string) {
|
||||
try {
|
||||
const url = new URL(string);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user