mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-01 19:38:13 +03:00
Merge pull request #394 from TNG-dev/zkldi/issue-393-Change-OAuth2-Clients-to-just-API-Clients
This commit is contained in:
+1
-1
@@ -78,7 +78,7 @@
|
||||
"redis": "3.1.2",
|
||||
"rimraf": "3.0.2",
|
||||
"safe-json-stringify": "1.2.0",
|
||||
"tachi-common": "0.2.38",
|
||||
"tachi-common": "0.2.42",
|
||||
"ts-node": "10.0.0",
|
||||
"typescript": "4.3.4",
|
||||
"winston": "3.3.3",
|
||||
|
||||
Generated
+4
-4
@@ -60,7 +60,7 @@ specifiers:
|
||||
rimraf: 3.0.2
|
||||
safe-json-stringify: 1.2.0
|
||||
supertest: 6.1.3
|
||||
tachi-common: 0.2.38
|
||||
tachi-common: 0.2.42
|
||||
tap: 15.0.9
|
||||
ts-node: 10.0.0
|
||||
tsconfig-paths: 3.10.1
|
||||
@@ -102,7 +102,7 @@ dependencies:
|
||||
redis: 3.1.2
|
||||
rimraf: 3.0.2
|
||||
safe-json-stringify: 1.2.0
|
||||
tachi-common: 0.2.38
|
||||
tachi-common: 0.2.42
|
||||
ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b
|
||||
typescript: 4.3.4
|
||||
winston: 3.3.3
|
||||
@@ -4161,8 +4161,8 @@ packages:
|
||||
strip-ansi: 6.0.0
|
||||
dev: true
|
||||
|
||||
/tachi-common/0.2.38:
|
||||
resolution: {integrity: sha512-hsiutwz/TKjYRcV6TsFDhpPHUalKDF2IVJGX5K9UuAAynU2K/wJ5qHG3xWH4pEjYz0B0Vx263HGY2johCEDAAA==}
|
||||
/tachi-common/0.2.42:
|
||||
resolution: {integrity: sha512-X+oS08NXgVeiPAFiNUzN7DCmiEJor35NCQkh6ia/kcpTAGkRUwst8NFQnHIca2eXDQmndEJg0KGkpYosMs4KYg==}
|
||||
dependencies:
|
||||
monk: 7.3.4
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -74,7 +74,7 @@ async function AnonymiseDB(nsTo: string) {
|
||||
logger.info(`Stripped username info.`, { r2 });
|
||||
|
||||
for (const collection of [
|
||||
"oauth2-clients",
|
||||
"api-clients",
|
||||
"oauth2-auth-codes",
|
||||
"password-reset-codes",
|
||||
"api-tokens",
|
||||
|
||||
Vendored
+3
-3
@@ -23,7 +23,7 @@ import {
|
||||
InviteCodeDocument,
|
||||
KaiAuthDocument,
|
||||
MilestoneDocument,
|
||||
OAuth2ApplicationDocument,
|
||||
TachiAPIClientDocument,
|
||||
OrphanChart,
|
||||
PBScoreDocument,
|
||||
PrivateUserInfoDocument,
|
||||
@@ -170,7 +170,7 @@ const db = {
|
||||
"arc-saved-profiles": monkDB.get<ARCSavedProfileDocument>("arc-saved-profiles"),
|
||||
"user-settings": monkDB.get<UserSettings>("user-settings"),
|
||||
"user-private-information": monkDB.get<PrivateUserInfoDocument>("user-private-information"),
|
||||
"oauth2-clients": monkDB.get<OAuth2ApplicationDocument>("oauth2-clients"),
|
||||
"api-clients": monkDB.get<TachiAPIClientDocument>("api-clients"),
|
||||
"oauth2-auth-codes":
|
||||
// i've inlined this one because i don't see it appearing anywhere else.
|
||||
monkDB.get<{ code: string; userID: integer; createdOn: number }>("oauth2-auth-codes"),
|
||||
@@ -212,7 +212,7 @@ export type StaticDatabases =
|
||||
| "game-stats-snapshots"
|
||||
| "arc-saved-profiles"
|
||||
| "user-private-information"
|
||||
| "oauth2-clients"
|
||||
| "api-clients"
|
||||
| "oauth2-auth-codes"
|
||||
| "fer-settings"
|
||||
| "orphan-chart-queue"
|
||||
|
||||
Vendored
+25
-1
@@ -4,7 +4,7 @@ import { IndexOptions } from "mongodb";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { ONE_DAY } from "lib/constants/time";
|
||||
import { Databases } from "./db";
|
||||
import db, { Databases, monkDB } from "./db";
|
||||
import { Random20Hex } from "utils/misc";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
@@ -84,6 +84,7 @@ const staticIndexes: Partial<Record<Databases, Index[]>> = {
|
||||
"fer-settings": [index({ userID: 1 }, UNIQUE)],
|
||||
counters: [index({ counterName: 1 }, UNIQUE)],
|
||||
"class-achievements": [index({ game: 1, playtype: 1, timeAchieved: 1 })],
|
||||
"api-clients": [index({ clientID: 1 }, UNIQUE)],
|
||||
};
|
||||
|
||||
const indexes: Partial<Record<Databases, Index[]>> = staticIndexes;
|
||||
@@ -157,3 +158,26 @@ export async function SetIndexes(mongoUrl: string, reset: boolean) {
|
||||
|
||||
await monkDb.close();
|
||||
}
|
||||
|
||||
export function SetIndexesIfNoneSet() {
|
||||
// If no indexes are set, then we need to load mongo indexes.
|
||||
return db.users
|
||||
.indexes()
|
||||
.then((r) => {
|
||||
// If there's only one index on users
|
||||
// that means that only _id has indexes.
|
||||
// This means that there are likely to be no indexes
|
||||
// configured in the database.
|
||||
if (Object.keys(r).length === 1) {
|
||||
logger.info(`First-time Mongo startup detected. Running SetIndexes.`);
|
||||
SetIndexesWithDB(monkDB, true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.info(
|
||||
`Error in finding users collection. First time startup likely. Running SetIndexes.`,
|
||||
err
|
||||
);
|
||||
SetIndexesWithDB(monkDB, true);
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+14
-2
@@ -429,14 +429,26 @@ export const DatabaseSchemas: Record<Databases, ValidatorFunction> = {
|
||||
userID: p.isPositiveNonZeroInteger,
|
||||
createdOn: p.isPositive,
|
||||
}),
|
||||
"oauth2-clients": prSchemaify({
|
||||
"api-clients": prSchemaify({
|
||||
clientID: "string",
|
||||
clientSecret: "string",
|
||||
name: "string",
|
||||
author: p.isPositiveNonZeroInteger,
|
||||
requestedPermissions: [p.isIn(Object.keys(AllPermissions))],
|
||||
redirectUri: "string",
|
||||
redirectUri: "?string",
|
||||
webhookUri: "?string",
|
||||
apiKeyTemplate: p.nullable((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
if (!self.includes("%%TACHI_KEY%%")) {
|
||||
return "Template must include %%TACHI_KEY%%.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
apiKeyFilename: "?string",
|
||||
}),
|
||||
"orphan-chart-queue": prSchemaify({
|
||||
idString: p.isIn(allIDStrings),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import db from "external/mongo/db";
|
||||
import { ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
import { TachiAPIClientDocument, UserAuthLevels } from "tachi-common";
|
||||
import { Random20Hex } from "utils/misc";
|
||||
import fjsh from "fast-json-stable-hash";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { DatabaseSchemas } from "external/mongo/schemas";
|
||||
import { FormatPrError } from "utils/prudence";
|
||||
import p from "prudence";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
type DefaultClients = Omit<TachiAPIClientDocument, "clientSecret" | "author">[];
|
||||
|
||||
// Defines some Tachi API Clients that should come default with a Tachi
|
||||
// environment.
|
||||
// These use the special Client ID prefix "CX" instead of "CI", which
|
||||
// means they cannot possibly be collided.1
|
||||
const KtchiDefaultClients: DefaultClients = [
|
||||
{
|
||||
name: "Fervidex",
|
||||
webhookUri: null,
|
||||
redirectUri: null,
|
||||
requestedPermissions: ["submit_score"],
|
||||
clientID: "CXFervidex",
|
||||
apiKeyFilename: "kamaitachi.fervidex.json",
|
||||
apiKeyTemplate: JSON.stringify(
|
||||
{
|
||||
url: `${ServerConfig.OUR_URL}/ir/fervidex`,
|
||||
token: "%%TACHI_KEY%%",
|
||||
},
|
||||
null,
|
||||
"\t"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Barbatos",
|
||||
webhookUri: null,
|
||||
redirectUri: null,
|
||||
requestedPermissions: ["submit_score"],
|
||||
clientID: "CXBarbatos",
|
||||
apiKeyFilename: "barbatos.json",
|
||||
apiKeyTemplate: JSON.stringify(
|
||||
{
|
||||
api_key: "%%TACHI_KEY%%",
|
||||
},
|
||||
null,
|
||||
"\t"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Konaste Hook",
|
||||
webhookUri: null,
|
||||
redirectUri: null,
|
||||
requestedPermissions: ["submit_score"],
|
||||
clientID: "CXKsHook",
|
||||
apiKeyFilename: "kamaitachi.kshook.json",
|
||||
apiKeyTemplate: JSON.stringify(
|
||||
{
|
||||
url: `${ServerConfig.OUR_URL}/ir/kshook`,
|
||||
token: "%%TACHI_KEY%%",
|
||||
games: ["sv3c"],
|
||||
},
|
||||
null,
|
||||
"\t"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const BtchiDefaultClients: DefaultClients = [
|
||||
{
|
||||
name: "Beatoraja IR",
|
||||
webhookUri: null,
|
||||
redirectUri: null,
|
||||
requestedPermissions: ["submit_score"],
|
||||
clientID: "CXBeatorajaIR",
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
},
|
||||
{
|
||||
name: "USC IR",
|
||||
webhookUri: null,
|
||||
redirectUri: null,
|
||||
requestedPermissions: ["submit_score"],
|
||||
clientID: "CXUSCIR",
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
},
|
||||
];
|
||||
|
||||
export async function LoadDefaultClients() {
|
||||
if (TachiConfig.TYPE === "ktchi") {
|
||||
await LoadClients(KtchiDefaultClients);
|
||||
} else if (TachiConfig.TYPE === "btchi") {
|
||||
await LoadClients(BtchiDefaultClients);
|
||||
} else {
|
||||
await LoadClients(KtchiDefaultClients);
|
||||
await LoadClients(BtchiDefaultClients);
|
||||
}
|
||||
}
|
||||
|
||||
async function LoadClients(clients: DefaultClients) {
|
||||
const firstAdmin = await db.users.findOne({
|
||||
authLevel: UserAuthLevels.ADMIN,
|
||||
});
|
||||
|
||||
if (!firstAdmin) {
|
||||
logger.error(
|
||||
`There are no admins on this instance of tachi-server. We cannot create default API Clients!`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
const exists = await db["api-clients"].findOne(
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
clientSecret: 0,
|
||||
author: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Skip if nothing has changed.
|
||||
if (fjsh.hash(exists, "sha256") === fjsh.hash(client, "sha256")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const realClient: TachiAPIClientDocument = {
|
||||
...client,
|
||||
clientSecret: `CS${Random20Hex()}`,
|
||||
author: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
DatabaseSchemas["api-clients"](realClient);
|
||||
} catch (err) {
|
||||
logger.error(`Invalid API Client ${client.name}: ${FormatPrError(err)}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No replaceOne support in monk -- have to do this.
|
||||
await db["api-clients"].remove({
|
||||
clientID: client.clientID,
|
||||
});
|
||||
|
||||
await db["api-clients"].insert(realClient);
|
||||
|
||||
logger.info(`Loaded/Modified new built-in client ${client.name}.`);
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,8 @@ export async function ProcessGoal(
|
||||
goalID: goal.goalID,
|
||||
old: oldData,
|
||||
new: newData,
|
||||
game: goal.game,
|
||||
playtype: goal.playtype,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +136,8 @@ export async function UpdateUsersMilestones(
|
||||
content: {
|
||||
userID,
|
||||
...milestoneInfo,
|
||||
game,
|
||||
playtype: milestone.playtype,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const logger = CreateLogCtx(__filename);
|
||||
|
||||
// @todo make use of aggressive caching here?
|
||||
export async function GetWebhookUrlInfo() {
|
||||
const urls = await db["oauth2-clients"].find(
|
||||
const urls = await db["api-clients"].find(
|
||||
{ webhookUri: { $ne: null } },
|
||||
{ projection: { webhookUri: 1, clientSecret: 1 } }
|
||||
);
|
||||
|
||||
+16
-28
@@ -1,11 +1,12 @@
|
||||
import { spawn } from "child_process";
|
||||
import db, { monkDB } from "external/mongo/db";
|
||||
import { SetIndexesWithDB } from "external/mongo/indexes";
|
||||
import db from "external/mongo/db";
|
||||
import { SetIndexesIfNoneSet } from "external/mongo/indexes";
|
||||
import { InitSequenceDocs } from "external/mongo/sequence-docs";
|
||||
import fs from "fs";
|
||||
import https from "https";
|
||||
import { LoadDefaultClients } from "lib/builtin-clients/builtin-clients";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { Environment, ServerConfig, TachiConfig, TachiServerConfig } from "lib/setup/config";
|
||||
import { Environment, ServerConfig, TachiConfig } from "lib/setup/config";
|
||||
import path from "path";
|
||||
import server from "server/server";
|
||||
import { InitaliseFolderChartLookup } from "utils/folder";
|
||||
@@ -17,35 +18,22 @@ logger.info(`Booting ${TachiConfig.NAME} - ${FormatVersion()} [ENV: ${Environmen
|
||||
logger.info(`Log level is set to ${ServerConfig.LOG_LEVEL}.`);
|
||||
|
||||
logger.info(`Loading sequence documents...`);
|
||||
InitSequenceDocs();
|
||||
|
||||
// If no indexes are set, then we need to load mongo indexes.
|
||||
db.users
|
||||
.indexes()
|
||||
.then((r) => {
|
||||
// If there's only one index on users
|
||||
// that means that only _id has indexes.
|
||||
// This means that there are likely to be no indexes
|
||||
// configured in the database.
|
||||
if (Object.keys(r).length === 1) {
|
||||
logger.info(`First-time Mongo startup detected. Running SetIndexes.`);
|
||||
SetIndexesWithDB(monkDB, true);
|
||||
async function RunOnInit() {
|
||||
await InitSequenceDocs();
|
||||
await SetIndexesIfNoneSet();
|
||||
|
||||
await db["folder-chart-lookup"].findOne().then((r) => {
|
||||
// If there are no folder chart lookups, initialise them.
|
||||
if (!r) {
|
||||
InitaliseFolderChartLookup();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.info(
|
||||
`Error in finding users collection. First time startup likely. Running SetIndexes.`,
|
||||
err
|
||||
);
|
||||
SetIndexesWithDB(monkDB, true);
|
||||
});
|
||||
|
||||
db["folder-chart-lookup"].findOne().then((r) => {
|
||||
// If there are no folder chart lookups, initialise them.
|
||||
if (!r) {
|
||||
InitaliseFolderChartLookup();
|
||||
}
|
||||
});
|
||||
await LoadDefaultClients();
|
||||
}
|
||||
|
||||
RunOnInit();
|
||||
|
||||
if (ServerConfig.ENABLE_SERVER_HTTPS) {
|
||||
logger.warn(
|
||||
|
||||
+3
-1
@@ -15,7 +15,7 @@ t.test("#GetClientFromID", (t) => {
|
||||
});
|
||||
|
||||
t.strictSame(
|
||||
req[SYMBOL_TachiData]?.oauth2ClientDoc,
|
||||
req[SYMBOL_TachiData]?.apiClientDoc,
|
||||
{
|
||||
clientID: "OAUTH2_CLIENT_ID",
|
||||
// clientSecret: "OAUTH2_CLIENT_SECRET",
|
||||
@@ -24,6 +24,8 @@ t.test("#GetClientFromID", (t) => {
|
||||
requestedPermissions: ["customise_profile"],
|
||||
redirectUri: "https://example.com/callback",
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
},
|
||||
"Should assign clientDoc with secret ommitted."
|
||||
);
|
||||
+5
-5
@@ -2,11 +2,11 @@ import { RequestHandler } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { SYMBOL_TachiData } from "lib/constants/tachi";
|
||||
import { Environment } from "lib/setup/config";
|
||||
import { OAuth2ApplicationDocument } from "tachi-common";
|
||||
import { TachiAPIClientDocument } 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(
|
||||
const client = await db["api-clients"].findOne(
|
||||
{
|
||||
clientID: req.params.clientID,
|
||||
},
|
||||
@@ -24,13 +24,13 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { oauth2ClientDoc: client });
|
||||
AssignToReqTachiData(req, { apiClientDoc: client });
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => {
|
||||
let client: Omit<OAuth2ApplicationDocument, "clientSecret">;
|
||||
let client: Omit<TachiAPIClientDocument, "clientSecret">;
|
||||
|
||||
// @hack
|
||||
// Sadly, expMiddlewareMock doesn't support mounting symbol props on
|
||||
@@ -42,7 +42,7 @@ export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => {
|
||||
// in testing.
|
||||
client = req.body.__terribleHackOauth2ClientDoc;
|
||||
} else {
|
||||
client = req[SYMBOL_TachiData]!.oauth2ClientDoc!;
|
||||
client = req[SYMBOL_TachiData]!.apiClientDoc!;
|
||||
}
|
||||
|
||||
const user = req.session.tachi?.user;
|
||||
+85
-53
@@ -1,12 +1,13 @@
|
||||
import db from "external/mongo/db";
|
||||
import { UserAuthLevels, APITokenDocument, TachiAPIClientDocument } from "tachi-common";
|
||||
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[] = [
|
||||
const clientDataset: TachiAPIClientDocument[] = [
|
||||
{
|
||||
author: 1,
|
||||
clientID: "CLIENT_1",
|
||||
@@ -15,6 +16,8 @@ const clientDataset: OAuth2ApplicationDocument[] = [
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
{
|
||||
author: 1,
|
||||
@@ -24,6 +27,8 @@ const clientDataset: OAuth2ApplicationDocument[] = [
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
{
|
||||
author: 2,
|
||||
@@ -33,20 +38,22 @@ const clientDataset: OAuth2ApplicationDocument[] = [
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
];
|
||||
|
||||
t.test("GET /api/v1/oauth/clients", async (t) => {
|
||||
t.test("GET /api/v1/clients", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["oauth2-clients"].remove({});
|
||||
await db["oauth2-clients"].insert(clientDataset);
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-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);
|
||||
const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
@@ -63,6 +70,8 @@ t.test("GET /api/v1/oauth/clients", async (t) => {
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
{
|
||||
author: 1,
|
||||
@@ -72,6 +81,8 @@ t.test("GET /api/v1/oauth/clients", async (t) => {
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -80,12 +91,12 @@ t.test("GET /api/v1/oauth/clients", async (t) => {
|
||||
});
|
||||
|
||||
t.test("Requires self-key level authentication.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/oauth/clients");
|
||||
const res = await mockApi.get("/api/v1/clients");
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
const res2 = await mockApi
|
||||
.get("/api/v1/oauth/clients")
|
||||
.get("/api/v1/clients")
|
||||
.set("Authorization", "Bearer fake_api_token");
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
@@ -96,24 +107,27 @@ t.test("GET /api/v1/oauth/clients", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/oauth/clients/create", async (t) => {
|
||||
t.test("POST /api/v1/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")
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbRes = await db["oauth2-clients"].findOne({ clientID: res.body.body.clientID });
|
||||
const dbRes = await db["api-clients"].findOne({ clientID: res.body.body.clientID });
|
||||
|
||||
t.not(dbRes, null, "Should be saved in the database.");
|
||||
|
||||
@@ -124,22 +138,28 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => {
|
||||
|
||||
t.test("Should validate names to be between 3 and 80 characters.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/oauth/clients/create")
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "2",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
const res2 = await mockApi
|
||||
.post("/api/v1/oauth/clients/create")
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "2".repeat(100),
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
@@ -150,11 +170,14 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => {
|
||||
|
||||
t.test("Should validate urls to be between 3 and 80 characters.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/oauth/clients/create")
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "ftp://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
@@ -165,11 +188,14 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => {
|
||||
|
||||
t.test("Should validate permissions.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/oauth/clients/create")
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "http://example.com/callback",
|
||||
permissions: ["permission_that_doesnt_exist"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
@@ -178,31 +204,38 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should cap a user at OAUTH_CLIENT_CAP.", async (t) => {
|
||||
// Currently skipped as its difficult to mock user auth level.
|
||||
t.skip("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")
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
}
|
||||
|
||||
const res = await mockApi
|
||||
.post("/api/v1/oauth/clients/create")
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
const dbCount = await db["oauth2-clients"].count({ author: 1 });
|
||||
const dbCount = await db["api-clients"].count({ author: 1 });
|
||||
|
||||
t.equal(dbCount, ServerConfig.OAUTH_CLIENT_CAP);
|
||||
|
||||
@@ -212,11 +245,11 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/oauth/clients/:clientID", (t) => {
|
||||
t.test("GET /api/v1/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");
|
||||
const res = await mockApi.get("/api/v1/clients/OAUTH2_CLIENT_ID");
|
||||
|
||||
t.strictSame(res.body.body, {
|
||||
clientID: "OAUTH2_CLIENT_ID",
|
||||
@@ -226,13 +259,15 @@ t.test("GET /api/v1/oauth/clients/:clientID", (t) => {
|
||||
requestedPermissions: ["customise_profile"],
|
||||
redirectUri: "https://example.com/callback",
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
});
|
||||
|
||||
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");
|
||||
const res = await mockApi.get("/api/v1/clients/BAD_CLIENT");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
@@ -242,18 +277,18 @@ t.test("GET /api/v1/oauth/clients/:clientID", (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
t.test("PATCH /api/v1/clients/:clientID", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["oauth2-clients"].remove({});
|
||||
await db["oauth2-clients"].insert(clientDataset);
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-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")
|
||||
.patch("/api/v1/clients/CLIENT_1")
|
||||
.send({ name: "NEW NAME" })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
@@ -261,7 +296,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
|
||||
t.equal(res.body.body.name, "NEW NAME");
|
||||
|
||||
const dbRes = await db["oauth2-clients"].findOne({
|
||||
const dbRes = await db["api-clients"].findOne({
|
||||
clientID: "CLIENT_1",
|
||||
});
|
||||
|
||||
@@ -272,7 +307,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
|
||||
t.test("Should be able to modify a clients webhookUri.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/oauth/clients/CLIENT_1")
|
||||
.patch("/api/v1/clients/CLIENT_1")
|
||||
.send({ webhookUri: "https://example.com" })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
@@ -280,7 +315,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
|
||||
t.equal(res.body.body.webhookUri, "https://example.com");
|
||||
|
||||
const dbRes = await db["oauth2-clients"].findOne({
|
||||
const dbRes = await db["api-clients"].findOne({
|
||||
clientID: "CLIENT_1",
|
||||
});
|
||||
|
||||
@@ -291,14 +326,14 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
|
||||
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")
|
||||
.patch("/api/v1/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")
|
||||
.patch("/api/v1/clients/CLIENT_1")
|
||||
.send({ name: "2".repeat(100) })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
@@ -308,10 +343,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
});
|
||||
|
||||
t.test("Must provide name to modify.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/oauth/clients/CLIENT_1")
|
||||
.send({})
|
||||
.set("Cookie", cookie);
|
||||
const res = await mockApi.patch("/api/v1/clients/CLIENT_1").send({}).set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
@@ -320,13 +352,13 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
|
||||
t.test("Must be owner of client.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/oauth/clients/CLIENT_3")
|
||||
.patch("/api/v1/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" });
|
||||
const res2 = await mockApi.patch("/api/v1/clients/CLIENT_3").send({ name: "foo" });
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
|
||||
@@ -336,25 +368,25 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/oauth/clients/:clientID/reset-secret", async (t) => {
|
||||
t.test("POST /api/v1/clients/:clientID/reset-secret", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["oauth2-clients"].remove({});
|
||||
await db["oauth2-clients"].insert(clientDataset);
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-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")
|
||||
.post("/api/v1/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({
|
||||
const dbRes = await db["api-clients"].findOne({
|
||||
clientID: "CLIENT_1",
|
||||
});
|
||||
|
||||
@@ -365,12 +397,12 @@ t.test("POST /api/v1/oauth/clients/:clientID/reset-secret", async (t) => {
|
||||
|
||||
t.test("Must be owner of client.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/oauth/clients/CLIENT_3/reset-secret")
|
||||
.post("/api/v1/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");
|
||||
const res2 = await mockApi.post("/api/v1/clients/CLIENT_3/reset-secret");
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
|
||||
@@ -380,11 +412,11 @@ t.test("POST /api/v1/oauth/clients/:clientID/reset-secret", async (t) => {
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("DELETE /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
t.test("DELETE /api/v1/clients/:clientID", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["oauth2-clients"].remove({});
|
||||
await db["oauth2-clients"].insert(clientDataset);
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-clients"].insert(clientDataset);
|
||||
});
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
@@ -392,22 +424,22 @@ t.test("DELETE /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
t.test("Should destroy the client and all associated api keys.", async (t) => {
|
||||
await db["api-tokens"].insert([
|
||||
{
|
||||
fromOAuth2Client: "CLIENT_1",
|
||||
fromAPIClient: "CLIENT_1",
|
||||
token: "foo",
|
||||
userID: 1,
|
||||
},
|
||||
{
|
||||
fromOAuth2Client: "CLIENT_1",
|
||||
fromAPIClient: "CLIENT_1",
|
||||
token: "bar",
|
||||
userID: 1,
|
||||
},
|
||||
] as APITokenDocument[]);
|
||||
|
||||
const res = await mockApi.delete("/api/v1/oauth/clients/CLIENT_1").set("Cookie", cookie);
|
||||
const res = await mockApi.delete("/api/v1/clients/CLIENT_1").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbRes = await db["oauth2-clients"].findOne({ clientID: "CLIENT_1" });
|
||||
const dbRes = await db["api-clients"].findOne({ clientID: "CLIENT_1" });
|
||||
|
||||
t.equal(dbRes, null, "Should no longer exist.");
|
||||
|
||||
@@ -419,11 +451,11 @@ t.test("DELETE /api/v1/oauth/clients/:clientID", async (t) => {
|
||||
});
|
||||
|
||||
t.test("Must be owner of client.", async (t) => {
|
||||
const res = await mockApi.delete("/api/v1/oauth/clients/CLIENT_3").set("Cookie", cookie);
|
||||
const res = await mockApi.delete("/api/v1/clients/CLIENT_3").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
const res2 = await mockApi.delete("/api/v1/oauth/clients/CLIENT_3");
|
||||
const res2 = await mockApi.delete("/api/v1/clients/CLIENT_3");
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
|
||||
+88
-32
@@ -6,7 +6,7 @@ import db from "external/mongo/db";
|
||||
import { GetClientFromID, RequireOwnershipOfClient } from "./middleware";
|
||||
import { DedupeArr, DeleteUndefinedProps, IsValidURL, Random20Hex } from "utils/misc";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { APIPermissions } from "tachi-common";
|
||||
import { APIPermissions, TachiAPIClientDocument, UserAuthLevels } from "tachi-common";
|
||||
import { AllPermissions } from "server/middleware/auth";
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import { FormatUserDoc } from "utils/user";
|
||||
@@ -21,7 +21,7 @@ const router: Router = Router({ mergeParams: true });
|
||||
*
|
||||
* @warn This also returns the client_secrets! Those *have* to be kept secret.
|
||||
*
|
||||
* @name GET /api/v1/oauth/clients
|
||||
* @name GET /api/v1/clients
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const user = req.session.tachi?.user;
|
||||
@@ -33,7 +33,7 @@ router.get("/", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const clients = await db["oauth2-clients"].find({
|
||||
const clients = await db["api-clients"].find({
|
||||
author: user.id,
|
||||
});
|
||||
|
||||
@@ -45,19 +45,40 @@ router.get("/", async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new OAuth2 Client. Requires session-level auth.
|
||||
* Create a new API Client. Requires session-level auth.
|
||||
*
|
||||
* @param name - A string that identifies this client.
|
||||
* @param redirectUri - The redirectUri this client uses.
|
||||
* @param webhookUri - Optionally, a webhookUri to call with webhook events.
|
||||
* @param apiKeyTemplate - Optionally, a static format to apply when doing static auth.
|
||||
* @param apiKeyFilename - Optionally, a filename to automatically download the template to, when doing
|
||||
* static flow.
|
||||
* @param permissions - An array of APIPermissions this client is expected to use.
|
||||
*
|
||||
* @name POST /api/v1/oauth/clients/create
|
||||
* @name POST /api/v1/clients/create
|
||||
*/
|
||||
router.post(
|
||||
"/create",
|
||||
prValidate({
|
||||
name: p.isBoundedString(3, 80),
|
||||
redirectUri: "string",
|
||||
redirectUri: "?string",
|
||||
webhookUri: "?string",
|
||||
apiKeyTemplate: (self) => {
|
||||
if (self === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
if (!self.includes("%%TACHI_KEY%%")) {
|
||||
return "Must contain %%TACHI_KEY%% as part of the template.";
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
apiKeyFilename: "?string",
|
||||
permissions: [p.isIn(Object.keys(AllPermissions))],
|
||||
}),
|
||||
async (req, res) => {
|
||||
@@ -68,50 +89,70 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
const existingClients = await db["oauth2-clients"].find({
|
||||
const existingClients = await db["api-clients"].find({
|
||||
author: req.session.tachi.user.id,
|
||||
});
|
||||
|
||||
if (existingClients.length >= ServerConfig.OAUTH_CLIENT_CAP) {
|
||||
// Note: Admins are excluded from the API client cap.
|
||||
if (
|
||||
req.session.tachi.user.authLevel !== UserAuthLevels.ADMIN &&
|
||||
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}.`,
|
||||
description: `You have created too many API clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const permissions = DedupeArr<APIPermissions>(req.body.permissions);
|
||||
|
||||
if (!IsValidURL(req.body.redirectUri)) {
|
||||
if (permissions.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid URL for ${req.body.redirectUri}.`,
|
||||
description: `Invalid permissions -- Need to require atleast one.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.redirectUri !== null && !IsValidURL(req.body.redirectUri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid Redirect URL.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.webhookUri !== null && !IsValidURL(req.body.webhookUri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid Webhook URL.`,
|
||||
});
|
||||
}
|
||||
|
||||
const clientID = `CI${Random20Hex()}`;
|
||||
const clientSecret = `CS${Random20Hex()}`;
|
||||
|
||||
const clientDoc = {
|
||||
const clientDoc: TachiAPIClientDocument = {
|
||||
clientID,
|
||||
clientSecret,
|
||||
requestedPermissions: permissions,
|
||||
name: req.body.name,
|
||||
author: req.session.tachi.user.id,
|
||||
redirectUri: req.body.redirectUri,
|
||||
webhookUri: null,
|
||||
webhookUri: req.body.webhookUri ?? null,
|
||||
apiKeyFilename: req.body.apiKeyFilename ?? null,
|
||||
apiKeyTemplate: req.body.apiKeyTemplate ?? null,
|
||||
};
|
||||
|
||||
await db["oauth2-clients"].insert(clientDoc);
|
||||
await db["api-clients"].insert(clientDoc);
|
||||
|
||||
logger.info(
|
||||
`User ${FormatUserDoc(req.session.tachi.user)} created a new OAuth2 Client ${
|
||||
`User ${FormatUserDoc(req.session.tachi.user)} created a new API Client ${
|
||||
req.body.name
|
||||
} (${clientID}).`
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Created a new OAuth2 client.`,
|
||||
description: `Created a new API client.`,
|
||||
body: clientDoc,
|
||||
});
|
||||
}
|
||||
@@ -120,10 +161,10 @@ router.post(
|
||||
/**
|
||||
* Retrieves information about the client at this ID.
|
||||
*
|
||||
* @name GET /api/v1/oauth/clients/:clientID
|
||||
* @name GET /api/v1/clients/:clientID
|
||||
*/
|
||||
router.get("/:clientID", GetClientFromID, (req, res) => {
|
||||
const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!;
|
||||
const client = req[SYMBOL_TachiData]!.apiClientDoc!;
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@@ -138,8 +179,11 @@ router.get("/:clientID", GetClientFromID, (req, res) => {
|
||||
*
|
||||
* @param name - Change the name of this client.
|
||||
* @param webhookUri - Change a bound webhookUri for this client.
|
||||
* @param redirectUri - Change a bound redirectUri for this client.
|
||||
* @param apiKeyFormat - Change the APIKeyFormat for this client.
|
||||
* @param apiKeyFilename - Change the APIKeyFilename for this client.
|
||||
*
|
||||
* @name PATCH /api/v1/oauth/clients/:clientID
|
||||
* @name PATCH /api/v1/clients/:clientID
|
||||
*/
|
||||
router.patch(
|
||||
"/:clientID",
|
||||
@@ -147,6 +191,18 @@ router.patch(
|
||||
RequireOwnershipOfClient,
|
||||
prValidate({
|
||||
name: p.optional(p.isBoundedString(3, 80)),
|
||||
apiKeyFormat: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
if (!self.includes("%%TACHI_KEY%%")) {
|
||||
return "Must contain a %%TACHI_KEY%% placeholder.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
apiKeyFilename: p.optional(p.isBoundedString(3, 80)),
|
||||
webhookUri: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
@@ -159,7 +215,7 @@ router.patch(
|
||||
|
||||
return true;
|
||||
}),
|
||||
redirectUri: optNull((self) => {
|
||||
redirectUri: p.optional((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
@@ -173,7 +229,7 @@ router.patch(
|
||||
}),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!;
|
||||
const client = req[SYMBOL_TachiData]!.apiClientDoc!;
|
||||
|
||||
DeleteUndefinedProps(req.body);
|
||||
|
||||
@@ -184,7 +240,7 @@ router.patch(
|
||||
});
|
||||
}
|
||||
|
||||
const newClient = await db["oauth2-clients"].findOneAndUpdate(
|
||||
const newClient = await db["api-clients"].findOneAndUpdate(
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
@@ -194,7 +250,7 @@ router.patch(
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`OAuth2 Client ${client.name} (${client.clientID}) has been renamed to ${req.body.name}.`
|
||||
`API Client ${client.name} (${client.clientID}) has been renamed to ${req.body.name}.`
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -209,21 +265,21 @@ router.patch(
|
||||
* 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
|
||||
* @name POST /api/v1/clients/:clientID/reset-secret
|
||||
*/
|
||||
router.post(
|
||||
"/:clientID/reset-secret",
|
||||
GetClientFromID,
|
||||
RequireOwnershipOfClient,
|
||||
async (req, res) => {
|
||||
const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!;
|
||||
const client = req[SYMBOL_TachiData]!.apiClientDoc!;
|
||||
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(
|
||||
const newClient = await db["api-clients"].findOneAndUpdate(
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
@@ -245,20 +301,20 @@ router.post(
|
||||
/**
|
||||
* Delete this client. Must be authorized at a session-request level.
|
||||
*
|
||||
* @name DELETE /api/v1/oauth/clients/:clientID
|
||||
* @name DELETE /api/v1/clients/:clientID
|
||||
*/
|
||||
router.delete("/:clientID", GetClientFromID, RequireOwnershipOfClient, async (req, res) => {
|
||||
const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!;
|
||||
const client = req[SYMBOL_TachiData]!.apiClientDoc!;
|
||||
|
||||
const clientName = `${client.name} (${client.clientID})`;
|
||||
|
||||
logger.info(`Recieved request to destroy OAuth2 Client ${client.name} (${client.clientID})`);
|
||||
logger.info(`Recieved request to destroy API Client ${client.name} (${client.clientID})`);
|
||||
|
||||
logger.verbose(`Removing OAuth2 Client ${clientName}.`);
|
||||
await db["oauth2-clients"].remove({
|
||||
logger.verbose(`Removing API Client ${clientName}.`);
|
||||
await db["api-clients"].remove({
|
||||
clientID: client.clientID,
|
||||
});
|
||||
logger.info(`Removed OAuth2 Client ${clientName}.`);
|
||||
logger.info(`Removed API Client ${clientName}.`);
|
||||
|
||||
logger.verbose(`Removing all associated api tokens.`);
|
||||
const result = await db["api-tokens"].remove({
|
||||
@@ -23,7 +23,7 @@ t.test("POST /api/v1/oauth/token", (t) => {
|
||||
|
||||
t.not(tokenDoc, null);
|
||||
t.equal(tokenDoc?.userID, 1);
|
||||
t.equal(tokenDoc?.fromOAuth2Client, "OAUTH2_CLIENT_ID");
|
||||
t.equal(tokenDoc?.fromAPIClient, "OAUTH2_CLIENT_ID");
|
||||
t.strictSame(
|
||||
tokenDoc?.permissions,
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 });
|
||||
|
||||
@@ -32,7 +31,7 @@ router.post(
|
||||
code: "string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const client = await db["oauth2-clients"].findOne({
|
||||
const client = await db["api-clients"].findOne({
|
||||
clientID: req.body.client_id,
|
||||
});
|
||||
|
||||
@@ -74,7 +73,7 @@ router.post(
|
||||
identifier: `${client.name} Token`,
|
||||
// converts ["a","b"] to {a: true, b: true}.
|
||||
permissions: Object.fromEntries(client.requestedPermissions.map((e) => [e, true])),
|
||||
fromOAuth2Client: client.clientID,
|
||||
fromAPIClient: client.clientID,
|
||||
};
|
||||
|
||||
// Now we can actually register the api key (lol)
|
||||
@@ -114,6 +113,4 @@ router.post("/create-code", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.use("/clients", clientsRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Router } from "express";
|
||||
import adminRouter from "./admin/router";
|
||||
import authRouter from "./auth/router";
|
||||
import clientsRouter from "./clients/router";
|
||||
import gamesRouter from "./games/router";
|
||||
import importRouter from "./import/router";
|
||||
import importsRouter from "./imports/router";
|
||||
import oauthRouter from "./oauth/router";
|
||||
import scoresRouter from "./scores/router";
|
||||
import searchRouter from "./search/router";
|
||||
import sessionsRouter from "./sessions/router";
|
||||
import statusRouter from "./status/router";
|
||||
import usersRouter from "./users/router";
|
||||
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";
|
||||
import importsRouter from "./imports/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
@@ -24,6 +25,7 @@ router.use("/search", searchRouter);
|
||||
router.use("/scores", scoresRouter);
|
||||
router.use("/sessions", sessionsRouter);
|
||||
router.use("/oauth", oauthRouter);
|
||||
router.use("/clients", clientsRouter);
|
||||
|
||||
/**
|
||||
* Return a JSON 404 response if an endpoint is hit that does not exist.
|
||||
|
||||
@@ -65,10 +65,10 @@ router.post(
|
||||
const user = req[SYMBOL_TachiData]!.requestedUser!;
|
||||
|
||||
let identifier: string;
|
||||
let fromOAuth2Client;
|
||||
let fromAPIClient;
|
||||
|
||||
if (req.body.clientID) {
|
||||
const client = await db["oauth2-clients"].findOne(
|
||||
const client = await db["api-clients"].findOne(
|
||||
{
|
||||
clientID: req.body.clientID,
|
||||
},
|
||||
@@ -101,7 +101,7 @@ router.post(
|
||||
|
||||
permissions = client.requestedPermissions;
|
||||
identifier = client.name;
|
||||
fromOAuth2Client = client.clientID;
|
||||
fromAPIClient = client.clientID;
|
||||
|
||||
logger.info(
|
||||
`Creating API Key for ${FormatUserDoc(user)} from ${client.name} specification.`
|
||||
@@ -127,7 +127,7 @@ router.post(
|
||||
permissions: permissionsObject,
|
||||
token: Random20Hex(),
|
||||
userID: user.id,
|
||||
fromOAuth2Client,
|
||||
fromAPIClient,
|
||||
};
|
||||
|
||||
await db["api-tokens"].insert(apiTokenDocument);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { SYMBOL_TachiData } from "lib/constants/tachi";
|
||||
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 prValidate from "server/middleware/prudence-validate";
|
||||
import { RequireKamaitachi } from "server/middleware/type-require";
|
||||
import fetch from "utils/fetch";
|
||||
import { Random20Hex } from "utils/misc";
|
||||
import { Environment } from "lib/setup/config";
|
||||
import { GetKaiAuth } from "utils/queries/auth";
|
||||
import { FormatUserDoc } from "utils/user";
|
||||
import { RequireSelfRequestFromUser } from "../../../middleware";
|
||||
import { ValidateKaiType } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"clientID": "OAUTH2_CLIENT_ID",
|
||||
"clientSecret": "OAUTH2_CLIENT_SECRET",
|
||||
"name": "Test_Service",
|
||||
"author": 1,
|
||||
"requestedPermissions": [
|
||||
"customise_profile"
|
||||
],
|
||||
"redirectUri": "https://example.com/callback",
|
||||
"webhookUri": null,
|
||||
"apiKeyTemplate": null,
|
||||
"apiKeyFilename": null
|
||||
}
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
[{
|
||||
"clientID": "OAUTH2_CLIENT_ID",
|
||||
"clientSecret": "OAUTH2_CLIENT_SECRET",
|
||||
"name": "Test_Service",
|
||||
"author": 1,
|
||||
"requestedPermissions": ["customise_profile"],
|
||||
"redirectUri": "https://example.com/callback",
|
||||
"webhookUri": null
|
||||
}]
|
||||
@@ -72,7 +72,7 @@ export async function UpdateClassIfGreater(
|
||||
if (isGreater === null) {
|
||||
EmitWebhookEvent({
|
||||
type: "class-update/v1",
|
||||
content: { userID, new: classVal, old: null, set: classSet },
|
||||
content: { userID, new: classVal, old: null, set: classSet, game, playtype },
|
||||
});
|
||||
|
||||
return null;
|
||||
@@ -84,6 +84,8 @@ export async function UpdateClassIfGreater(
|
||||
new: classVal,
|
||||
old: userGameStats!.classes[classSet]!,
|
||||
set: classSet,
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Playtypes,
|
||||
SongDocument,
|
||||
UserSettings,
|
||||
OAuth2ApplicationDocument,
|
||||
TachiAPIClientDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
declare module "express-session" {
|
||||
@@ -63,5 +63,5 @@ export interface TachiRequestData {
|
||||
tableDoc?: TableDocument;
|
||||
folderDoc?: FolderDocument;
|
||||
|
||||
oauth2ClientDoc: Omit<OAuth2ApplicationDocument, "clientSecret">;
|
||||
apiClientDoc: Omit<TachiAPIClientDocument, "clientSecret">;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user