feat: kai

This commit is contained in:
zk
2026-03-26 01:00:15 +00:00
parent f270061f1b
commit 6f33a1d27b
19 changed files with 734 additions and 428 deletions
@@ -0,0 +1,25 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
import { Random20Hex } from "#utils/misc.js";
export const ACTION_CreateOAuth2AuthCode = MakeAction(
"CREATE_OAUTH2_AUTH_CODE",
async (taker) => {
const code = Random20Hex();
const createdOn = Date.now();
await DB.insertInto("priv_oauth2_auth_token")
.values({
token: code,
user_id: taker.acct.id,
created_on: new Date(createdOn).toISOString(),
})
.execute();
return {
code,
userID: taker.acct.id,
createdOn,
};
},
);
@@ -0,0 +1,14 @@
import { MakeAction } from "#lib/actions/actions.js";
import DB from "#services/pg/db.js";
export const ACTION_RevokeKaiAuthToken = MakeAction(
"REVOKE_KAI_AUTH_TOKEN",
async (taker, { service }) => {
await DB.deleteFrom("priv_svc_kai_auth_token")
.where("user_id", "=", taker.acct.id)
.where("service", "=", service)
.execute();
return {};
},
);
@@ -6,15 +6,17 @@ interface SeedApiClientOpts {
clientId: string;
authorId: number;
name?: string;
clientSecret?: string;
submitScore?: boolean;
customiseProfile?: boolean;
redirectUri?: string | null;
}
export async function seedApiClient(opts: SeedApiClientOpts) {
await DB.insertInto("priv_api_client")
.values({
client_id: opts.clientId,
client_secret: "CS_test_secret",
client_secret: opts.clientSecret ?? "CS_test_secret",
name: opts.name ?? "Test Client",
author: opts.authorId,
pm_submit_score: opts.submitScore ?? null,
@@ -28,7 +30,7 @@ export async function seedApiClient(opts: SeedApiClientOpts) {
api_key_template: null,
api_key_filename: null,
webhook_uri: null,
redirect_uri: null,
redirect_uri: opts.redirectUri ?? null,
is_builtin: false,
})
.execute();
@@ -0,0 +1,12 @@
import { MakeAction } from "#lib/actions/actions.js";
import { upsertKaiAuthTokensInDb } from "#lib/kai-auth-token/persist.js";
import DB from "#services/pg/db.js";
export const ACTION_UpsertKaiAuthToken = MakeAction(
"UPSERT_KAI_AUTH_TOKEN",
async (taker, { service, token, refreshToken }) => {
await upsertKaiAuthTokensInDb(DB, taker.acct.id, service, token, refreshToken);
return {};
},
);
@@ -0,0 +1,93 @@
import { MakeAnonAction } from "#lib/actions/actions";
import { SELECT_API_TOKEN, ToAPITokenDocument } from "#lib/db-formats/api-token";
import DB from "#services/pg/db";
import { Random20Hex } from "#utils/misc";
import { ExpectedErr } from "bliss";
const SELECT_OAUTH_CLIENT = [
"priv_api_client.client_id",
"priv_api_client.client_secret",
"priv_api_client.name",
"priv_api_client.redirect_uri",
"priv_api_client.pm_customise_profile",
"priv_api_client.pm_customise_score",
"priv_api_client.pm_customise_session",
"priv_api_client.pm_delete_score",
"priv_api_client.pm_manage_rivals",
"priv_api_client.pm_manage_targets",
"priv_api_client.pm_submit_score",
"priv_api_client.pm_manage_challenges",
] as const;
export const ANON_ACTION_OAuthTokenExchange = MakeAnonAction(
"OAUTH_TOKEN_EXCHANGE",
async (_taker, input) => {
const client = await DB.selectFrom("priv_api_client")
.select(SELECT_OAUTH_CLIENT)
.where("client_id", "=", input.client_id)
.executeTakeFirst();
if (!client) {
throw new ExpectedErr(404, `This client does not exist.`);
}
if (client.client_secret !== input.client_secret) {
throw new ExpectedErr(403, `Invalid secret.`);
}
if (client.redirect_uri !== input.redirect_uri) {
throw new ExpectedErr(
400,
`This redirect_uri does not match with your registered client redirect_uri ${client.redirect_uri}.`,
);
}
const apiToken = Random20Hex();
const identifier = `${client.name} Token`;
await DB.transaction().execute(async (txn) => {
const deleted = await txn
.deleteFrom("priv_oauth2_auth_token")
.where("token", "=", input.code)
.returning("user_id")
.executeTakeFirst();
if (!deleted) {
throw new ExpectedErr(404, `This code does not exist.`);
}
await txn
.insertInto("priv_api_token")
.values({
token: apiToken,
user_id: deleted.user_id,
identifier,
from_oauth2_client: client.client_id,
pm_customise_profile: client.pm_customise_profile,
pm_customise_score: client.pm_customise_score,
pm_customise_session: client.pm_customise_session,
pm_delete_score: client.pm_delete_score,
pm_manage_rivals: client.pm_manage_rivals,
pm_manage_targets: client.pm_manage_targets,
pm_submit_score: client.pm_submit_score,
pm_manage_challenges: client.pm_manage_challenges,
})
.execute();
});
const row = await DB.selectFrom("priv_api_token")
.select(SELECT_API_TOKEN)
.where("token", "=", apiToken)
.executeTakeFirstOrThrow();
const doc = ToAPITokenDocument(row);
return {
userID: Number(doc.userID),
token: doc.token!,
identifier: doc.identifier,
permissions: doc.permissions as Record<string, boolean>,
fromAPIClient: doc.fromAPIClient,
};
},
);
@@ -258,6 +258,28 @@ export const ActionSignatures = {
consumedBy: z.null(),
}),
},
UPSERT_KAI_AUTH_TOKEN: {
input: z.object({
service: z.enum(["EAG", "FLO", "MIN"]),
token: z.string(),
refreshToken: z.string(),
}),
output: z.object({}),
},
REVOKE_KAI_AUTH_TOKEN: {
input: z.object({
service: z.enum(["EAG", "FLO", "MIN"]),
}),
output: z.object({}),
},
CREATE_OAUTH2_AUTH_CODE: {
input: z.object({}),
output: z.object({
code: z.string(),
userID: z.number().int(),
createdOn: z.number().int(),
}),
},
} satisfies Record<string, ActionSignature>;
export const AnonActionSignatures = {
@@ -296,6 +318,22 @@ export const AnonActionSignatures = {
userID: z.number().int(),
}),
},
OAUTH_TOKEN_EXCHANGE: {
input: z.object({
client_id: z.string(),
client_secret: z.string(),
grant_type: z.literal("authorization_code"),
redirect_uri: z.string(),
code: z.string(),
}),
output: z.object({
userID: z.number().int(),
token: z.string(),
identifier: z.string(),
permissions: z.record(z.string(), z.boolean()),
fromAPIClient: z.string().nullable(),
}),
},
} satisfies Record<string, ActionSignature>;
/**
@@ -0,0 +1,21 @@
import { type Selection } from "kysely";
import { type KaiAuthDocument } from "tachi-common";
import { type Database } from "tachi-db";
export const SELECT_KAI_AUTH_TOKEN = [
"priv_svc_kai_auth_token.user_id",
"priv_svc_kai_auth_token.service",
"priv_svc_kai_auth_token.token",
"priv_svc_kai_auth_token.refresh_token",
] as const;
export function ToKaiAuthDocument(
row: Selection<Database, "priv_svc_kai_auth_token", (typeof SELECT_KAI_AUTH_TOKEN)[number]>,
): KaiAuthDocument {
return {
userID: row.user_id,
service: row.service as KaiAuthDocument["service"],
token: row.token,
refreshToken: row.refresh_token,
};
}
@@ -0,0 +1,46 @@
import { type Kysely, type Transaction } from "kysely";
import { type Database } from "tachi-db";
export type KaiService = "EAG" | "FLO" | "MIN";
export async function upsertKaiAuthTokensInDb(
db: Kysely<Database> | Transaction<Database>,
userId: number,
service: KaiService,
token: string,
refreshToken: string,
): Promise<void> {
await db
.insertInto("priv_svc_kai_auth_token")
.values({
user_id: userId,
service,
token,
refresh_token: refreshToken,
})
.onConflict((oc) =>
oc.columns(["user_id", "service"]).doUpdateSet({
token,
refresh_token: refreshToken,
}),
)
.execute();
}
export async function updateKaiAuthTokensInDb(
db: Kysely<Database> | Transaction<Database>,
userId: number,
service: KaiService,
token: string,
refreshToken: string,
): Promise<void> {
await db
.updateTable("priv_svc_kai_auth_token")
.set({
token,
refresh_token: refreshToken,
})
.where("user_id", "=", userId)
.where("service", "=", service)
.execute();
}
@@ -1,122 +0,0 @@
import type { KaiAuthDocument } from "tachi-common";
import { log } from "#lib/log/log.js";
import { ServerConfig } from "#lib/setup/config";
import MONGODB_KILL from "#services/mongo/db";
import { MockJSONFetch } from "#test-utils/mock-fetch";
import ResetDBState from "#test-utils/resets";
import deepmerge from "deepmerge";
import t from "tap";
import { CreateKaiReauthFunction } from "./reauth";
const authDoc: KaiAuthDocument = {
refreshToken: "REFRESH_TOKEN",
service: "FLO",
token: "foobar",
userID: 1,
};
t.test("#CreateKaiReauthFunction", (t) => {
t.beforeEach(ResetDBState);
// eslint-disable-next-line no-return-await
t.beforeEach(async () => await MONGODB_KILL["kai-auth-tokens"].remove({}));
if (!ServerConfig.FLO_OAUTH2_INFO) {
throw new Error(
`Panic in test - No dummy FLO_OAUTH2_INFO configured, and the test depends on some dummy data here.`,
);
}
t.test("Should create a working reauthentication for the service.", async (t) => {
const mockFetch = MockJSONFetch({
[`${ServerConfig.FLO_API_URL}/oauth/token`]: {
access_token: "NEW_ACCESS_TOKEN",
refresh_token: "NEW_REFRESH_TOKEN",
},
});
await MONGODB_KILL["kai-auth-tokens"].insert(deepmerge(authDoc, {}));
const reauthFn = CreateKaiReauthFunction("FLO", authDoc, log, mockFetch);
t.equal(reauthFn.length, 0, "Should return a function with arity 0.");
const data = await reauthFn();
t.equal(data, "NEW_ACCESS_TOKEN");
const dbChange = await MONGODB_KILL["kai-auth-tokens"].findOne({
userID: 1,
service: "FLO",
});
// should also update the db
t.equal(dbChange?.token, "NEW_ACCESS_TOKEN");
t.end();
});
t.test("Should throw on fetch error.", async (t) => {
// will fail
const mockFetch = MockJSONFetch({});
await MONGODB_KILL["kai-auth-tokens"].insert(deepmerge(authDoc, {}));
const reauthFn = CreateKaiReauthFunction("FLO", authDoc, log, mockFetch);
t.rejects(() => reauthFn(), {
message: "An error has occured while attempting reauthentication.",
});
const dbChange = await MONGODB_KILL["kai-auth-tokens"].findOne({
userID: 1,
service: "FLO",
});
t.equal(
dbChange?.refreshToken,
authDoc.refreshToken,
"DB should not be changed for refreshToken.",
);
t.equal(dbChange?.token, authDoc.token, "DB should not be changed for token.");
t.end();
});
t.test("Should throw on invalid JSON response", async (t) => {
const mockFetch = MockJSONFetch({
[`${ServerConfig.FLO_API_URL}/oauth/token?refresh_token=${
authDoc.refreshToken
}&grant_type=refresh_token&client_secret=${
ServerConfig.FLO_OAUTH2_INFO!.CLIENT_SECRET
}&client_id=${ServerConfig.FLO_OAUTH2_INFO!.CLIENT_ID}`]: {
// missing access_token
},
});
await MONGODB_KILL["kai-auth-tokens"].insert(deepmerge(authDoc, {}));
const reauthFn = CreateKaiReauthFunction("FLO", authDoc, log, mockFetch);
t.rejects(() => reauthFn(), {
message: "An error has occured while attempting reauthentication.",
});
const dbChange = await MONGODB_KILL["kai-auth-tokens"].findOne({
userID: 1,
service: "FLO",
});
t.equal(
dbChange?.refreshToken,
authDoc.refreshToken,
"DB should not be changed for refreshToken.",
);
t.equal(dbChange?.token, authDoc.token, "DB should not be changed for token.");
t.end();
});
t.end();
});
@@ -0,0 +1,137 @@
import { log } from "#lib/log/log";
import { ServerConfig } from "#lib/setup/config";
import DB from "#services/pg/db";
import { MockJSONFetch } from "#test-utils/mock-fetch";
import { seedUser } from "#test-utils/pg-fixtures";
import type { KaiAuthDocument } from "tachi-common";
import { beforeEach, describe, expect, it } from "vitest";
import { CreateKaiReauthFunction } from "./reauth";
describe("#CreateKaiReauthFunction", () => {
let userId: number;
beforeEach(async () => {
({ id: userId } = await seedUser({
username: "reauth_user",
withCredential: true,
withSettings: true,
}));
await DB.insertInto("priv_svc_kai_auth_token")
.values({
user_id: userId,
service: "FLO",
token: "foobar",
refresh_token: "REFRESH_TOKEN",
})
.execute();
});
it("creates a working reauthentication for the service", async () => {
if (!ServerConfig.FLO_OAUTH2_INFO) {
throw new Error(
`Panic in test - No dummy FLO_OAUTH2_INFO configured, and the test depends on some dummy data here.`,
);
}
const authDoc: KaiAuthDocument = {
refreshToken: "REFRESH_TOKEN",
service: "FLO",
token: "foobar",
userID: userId,
};
const mockFetch = MockJSONFetch({
[`${ServerConfig.FLO_API_URL}/oauth/token`]: {
access_token: "NEW_ACCESS_TOKEN",
refresh_token: "NEW_REFRESH_TOKEN",
},
});
const reauthFn = CreateKaiReauthFunction("FLO", authDoc, log, mockFetch);
expect(reauthFn.length).toBe(0);
const data = await reauthFn();
expect(data).toBe("NEW_ACCESS_TOKEN");
const dbChange = await DB.selectFrom("priv_svc_kai_auth_token")
.selectAll()
.where("user_id", "=", userId)
.where("service", "=", "FLO")
.executeTakeFirstOrThrow();
expect(dbChange.token).toBe("NEW_ACCESS_TOKEN");
expect(dbChange.refresh_token).toBe("NEW_REFRESH_TOKEN");
});
it("throws on fetch error without changing tokens", async () => {
if (!ServerConfig.FLO_OAUTH2_INFO) {
throw new Error(
`Panic in test - No dummy FLO_OAUTH2_INFO configured, and the test depends on some dummy data here.`,
);
}
const authDoc: KaiAuthDocument = {
refreshToken: "REFRESH_TOKEN",
service: "FLO",
token: "foobar",
userID: userId,
};
const mockFetch = MockJSONFetch({});
const reauthFn = CreateKaiReauthFunction("FLO", authDoc, log, mockFetch);
await expect(reauthFn()).rejects.toMatchObject({
message: "An error has occured while attempting reauthentication.",
});
const dbChange = await DB.selectFrom("priv_svc_kai_auth_token")
.selectAll()
.where("user_id", "=", userId)
.where("service", "=", "FLO")
.executeTakeFirstOrThrow();
expect(dbChange.refresh_token).toBe(authDoc.refreshToken);
expect(dbChange.token).toBe(authDoc.token);
});
it("throws on invalid JSON response without changing tokens", async () => {
if (!ServerConfig.FLO_OAUTH2_INFO) {
throw new Error(
`Panic in test - No dummy FLO_OAUTH2_INFO configured, and the test depends on some dummy data here.`,
);
}
const authDoc: KaiAuthDocument = {
refreshToken: "REFRESH_TOKEN",
service: "FLO",
token: "foobar",
userID: userId,
};
const mockFetch = MockJSONFetch({
[`${ServerConfig.FLO_API_URL}/oauth/token`]: {
/* missing access_token */
},
});
const reauthFn = CreateKaiReauthFunction("FLO", authDoc, log, mockFetch);
await expect(reauthFn()).rejects.toMatchObject({
message: "An error has occured while attempting reauthentication.",
});
const dbChange = await DB.selectFrom("priv_svc_kai_auth_token")
.selectAll()
.where("user_id", "=", userId)
.where("service", "=", "FLO")
.executeTakeFirstOrThrow();
expect(dbChange.refresh_token).toBe(authDoc.refreshToken);
expect(dbChange.token).toBe(authDoc.token);
});
});
@@ -1,9 +1,10 @@
import type { KtLogger } from "#lib/log/log";
import type { KaiAuthDocument } from "tachi-common";
import { updateKaiAuthTokensInDb } from "#lib/kai-auth-token/persist";
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
import { ServerConfig } from "#lib/setup/config";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
import nodeFetch from "#utils/fetch";
import { p } from "prudence";
@@ -109,17 +110,12 @@ export function CreateKaiReauthFunction(
refresh_token: string;
};
await MONGODB_KILL["kai-auth-tokens"].update(
{
userID: authDoc.userID,
service: authDoc.service,
},
{
$set: {
token: validatedContent.access_token,
refreshToken: validatedContent.refresh_token,
},
},
await updateKaiAuthTokensInDb(
DB,
authDoc.userID,
authDoc.service,
validatedContent.access_token,
validatedContent.refresh_token,
);
return validatedContent.access_token;
@@ -1,38 +0,0 @@
import { log } from "#lib/log/log";
import MONGODB_KILL from "#services/mongo/db";
import { WrapScriptPromise } from "#utils/misc";
import { FormatUserDoc, ResolveUser } from "#utils/user";
import { UserAuthLevels } from "tachi-common";
const userID = process.argv[2];
async function MakeUserAdmin(userID: string) {
const user = await ResolveUser(userID);
if (!user) {
log.error(`No such user '${userID}' exists.`);
throw new Error(`No such user '${userID}' exists.`);
}
await MONGODB_KILL.users.update(
{
id: user.id,
},
{
$set: {
authLevel: UserAuthLevels.ADMIN,
},
},
);
log.info(`Made ${FormatUserDoc(user)} an administrator.`);
}
if (!userID) {
log.error(`Usage: pnpm make-user-admin <userID>.`);
throw new Error(`No userID provided.`);
}
if (require.main === module) {
WrapScriptPromise(MakeUserAdmin(userID), log);
}
@@ -1,99 +0,0 @@
import MONGODB_KILL from "#services/mongo/db";
import mockApi from "#test-utils/mock-api";
import ResetDBState from "#test-utils/resets";
import t from "tap";
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 MONGODB_KILL["api-tokens"].findOne({
token: res.body.body.token,
});
t.not(tokenDoc, null);
t.equal(tokenDoc?.userID, 1);
t.equal(tokenDoc?.fromAPIClient, "OAUTH2_CLIENT_ID");
t.strictSame(
tokenDoc?.permissions,
{
customise_profile: true,
},
"Should assign the permissions this client has configured.",
);
const exists = await MONGODB_KILL["oauth2-auth-codes"].findOne({ code: "AUTH_CODE" });
t.equal(exists, null, "Should remove the code from the database after being used.");
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,165 @@
import { seedApiClient } from "#actions/test-utils/api-tokens";
import DB from "#services/pg/db";
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
import { seedUser } from "#test-utils/pg-fixtures";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
async function loginAs(username: string, password = "password123") {
const res = await mockApi.post("/api/v1/auth/login").send({
username,
"!password": password,
captcha: "test",
});
return res.headers["set-cookie"] as unknown as string[];
}
afterAll(() => CloseServerConnection());
describe("POST /api/v1/oauth/token", () => {
let userId: number;
const clientId = "OAUTH2_CLIENT_ID";
const clientSecret = "OAUTH2_CLIENT_SECRET";
const redirectUri = "https://example.com/callback";
const authCode = "AUTH_CODE";
beforeEach(async () => {
({ id: userId } = await seedUser({
username: "oauth_user",
withCredential: true,
withSettings: true,
}));
await seedApiClient({
clientId,
authorId: userId,
name: "Test_Service",
clientSecret,
customiseProfile: true,
redirectUri,
});
await DB.insertInto("priv_oauth2_auth_token")
.values({
token: authCode,
user_id: userId,
created_on: new Date().toISOString(),
})
.execute();
});
it("grants an api token and consumes the auth code", async () => {
const res = await mockApi.post(`/api/v1/oauth/token`).send({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
redirect_uri: redirectUri,
code: authCode,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body.userID).toBe(userId);
expect(res.body.body.fromAPIClient).toBe(clientId);
expect(res.body.body.permissions).toEqual({
customise_profile: true,
});
const tokenRow = await DB.selectFrom("priv_api_token")
.selectAll()
.where("token", "=", res.body.body.token)
.executeTakeFirst();
expect(tokenRow).toBeDefined();
expect(tokenRow?.user_id).toBe(userId);
expect(tokenRow?.from_oauth2_client).toBe(clientId);
const codeRow = await DB.selectFrom("priv_oauth2_auth_token")
.selectAll()
.where("token", "=", authCode)
.executeTakeFirst();
expect(codeRow).toBeUndefined();
});
it("returns 404 for invalid code", async () => {
const res = await mockApi.post(`/api/v1/oauth/token`).send({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
redirect_uri: redirectUri,
code: "invalidcode",
});
expect(res.status).toBe(404);
});
it("returns 404 for invalid client id", async () => {
const res = await mockApi.post(`/api/v1/oauth/token`).send({
client_id: "INVALID_CLIENT_ID",
client_secret: clientSecret,
grant_type: "authorization_code",
redirect_uri: redirectUri,
code: authCode,
});
expect(res.status).toBe(404);
});
it("returns 403 for invalid client secret", async () => {
const res = await mockApi.post(`/api/v1/oauth/token`).send({
client_id: clientId,
client_secret: "INVALID_SECRET",
grant_type: "authorization_code",
redirect_uri: redirectUri,
code: authCode,
});
expect(res.status).toBe(403);
});
it("returns 400 for redirect_uri mismatch", async () => {
const res = await mockApi.post(`/api/v1/oauth/token`).send({
client_id: clientId,
client_secret: clientSecret,
grant_type: "authorization_code",
redirect_uri: "https://invalid.example.com/callback",
code: authCode,
});
expect(res.status).toBe(400);
});
});
describe("POST /api/v1/oauth/create-code", () => {
it("returns 401 when not authenticated", async () => {
const res = await mockApi.post("/api/v1/oauth/create-code");
expect(res.status).toBe(401);
});
it("creates a row in priv_oauth2_auth_token", async () => {
const { id: uid } = await seedUser({
username: "oauth_code_user",
withCredential: true,
withSettings: true,
});
const cookie = await loginAs("oauth_code_user");
const res = await mockApi.post("/api/v1/oauth/create-code").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body.code).toBeTruthy();
expect(typeof res.body.body.createdOn).toBe("number");
expect(res.body.body.userID).toBe(uid);
const row = await DB.selectFrom("priv_oauth2_auth_token")
.selectAll()
.where("token", "=", res.body.body.code)
.executeTakeFirst();
expect(row).toBeDefined();
expect(Number(row?.user_id)).toBe(uid);
});
});
@@ -1,6 +1,6 @@
import { ACTION_CreateOAuth2AuthCode } from "#actions/create-oauth2-auth-code";
import { ANON_ACTION_OAuthTokenExchange } from "#anon-actions/oauth-token-exchange";
import prValidate from "#server/middleware/prudence-validate";
import MONGODB_KILL from "#services/mongo/db";
import { Random20Hex } from "#utils/misc";
import { Router } from "express";
import { p } from "prudence";
@@ -39,57 +39,16 @@ router.post(
redirect_uri: string;
};
const client = await MONGODB_KILL["api-clients"].findOne({
clientID: body.client_id,
});
if (!client) {
return res.status(404).json({
success: false,
description: `This client does not exist.`,
});
}
if (client.clientSecret !== 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 !== 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 MONGODB_KILL["oauth2-auth-codes"].findOne({ code: body.code });
if (!codeDoc) {
return res.status(404).json({
success: false,
description: `This code does not exist.`,
});
}
// don't let people auth with the same code multiple times.
await MONGODB_KILL["oauth2-auth-codes"].remove({ code: body.code });
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])),
fromAPIClient: client.clientID,
};
// Now we can actually register the api key (lol)
await MONGODB_KILL["api-tokens"].insert(apiDoc);
const apiDoc = await ANON_ACTION_OAuthTokenExchange(
{ ip: req.ip },
{
client_id: body.client_id,
client_secret: body.client_secret,
grant_type: body.grant_type,
redirect_uri: body.redirect_uri,
code: body.code,
},
);
return res.status(200).json({
success: true,
@@ -112,11 +71,10 @@ router.post("/create-code", async (req, res) => {
});
}
const code = Random20Hex();
const user = req.session.tachi.user;
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
const doc = { code, userID: req.session.tachi.user.id, createdOn: Date.now() };
await MONGODB_KILL["oauth2-auth-codes"].insert(doc);
const doc = await ACTION_CreateOAuth2AuthCode(taker, {});
return res.status(200).json({
success: true,
@@ -1,65 +0,0 @@
import MONGODB_KILL from "#services/mongo/db";
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
import mockApi from "#test-utils/mock-api";
import ResetDBState from "#test-utils/resets";
import t from "tap";
t.test("GET /api/v1/users/:userID/integrations/kai/:kaiType", async (t) => {
t.beforeEach(ResetDBState);
const cookie = await CreateFakeAuthCookie(mockApi);
t.test("Should return false if unauthed with this kaiType", async (t) => {
const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie);
t.equal(res.statusCode, 200);
t.equal(res.body.body.authStatus, false);
t.end();
});
t.test("Should return true if authed with this kaiType", async (t) => {
await MONGODB_KILL["kai-auth-tokens"].insert({
refreshToken: "refresh",
service: "FLO",
token: "bar",
userID: 1,
});
const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie);
t.equal(res.statusCode, 200);
t.equal(res.body.body.authStatus, true);
t.end();
});
t.test("Should return the auth status of this user specifically.", async (t) => {
await MONGODB_KILL["kai-auth-tokens"].insert([
{
refreshToken: "refresh",
service: "EAG",
token: "bar",
userID: 1,
},
{
refreshToken: "refresh",
service: "FLO",
token: "baz",
userID: 2,
},
]);
const res = await mockApi.get("/api/v1/users/1/integrations/kai/flo").set("Cookie", cookie);
t.equal(res.statusCode, 200);
t.equal(res.body.body.authStatus, false);
t.end();
});
t.end();
});
// test is currently undoable due to issues with mocking out real Fetch calls.
t.todo("PATCH /api/v1/users/:userID/integrations/kai/:kaiType/oauth2callback");
@@ -0,0 +1,130 @@
import DB from "#services/pg/db";
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
import { seedUser } from "#test-utils/pg-fixtures";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
async function loginAs(username: string, password = "password123") {
const res = await mockApi.post("/api/v1/auth/login").send({
username,
"!password": password,
captcha: "test",
});
return res.headers["set-cookie"] as unknown as string[];
}
afterAll(() => CloseServerConnection());
describe("GET /api/v1/users/:userID/integrations/kai/:kaiType", () => {
let cookie: string[];
let userId: number;
beforeEach(async () => {
({ id: userId } = await seedUser({
username: "kai_int_user",
withCredential: true,
withSettings: true,
}));
cookie = await loginAs("kai_int_user");
});
it("returns authStatus false when no token row exists", async () => {
const res = await mockApi
.get(`/api/v1/users/${userId}/integrations/kai/flo`)
.set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.body.authStatus).toBe(false);
});
it("returns authStatus true when a token row exists for this user and service", async () => {
await DB.insertInto("priv_svc_kai_auth_token")
.values({
user_id: userId,
service: "FLO",
token: "access",
refresh_token: "refresh",
})
.execute();
const res = await mockApi
.get(`/api/v1/users/${userId}/integrations/kai/flo`)
.set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.body.authStatus).toBe(true);
});
it("returns authStatus false when another user has FLO auth", async () => {
const { id: otherId } = await seedUser({
username: "kai_other",
email: "kai_other@example.com",
withCredential: true,
withSettings: true,
});
await DB.insertInto("priv_svc_kai_auth_token")
.values({
user_id: otherId,
service: "FLO",
token: "t",
refresh_token: "r",
})
.execute();
const res = await mockApi
.get(`/api/v1/users/${userId}/integrations/kai/flo`)
.set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.body.authStatus).toBe(false);
});
});
describe("DELETE /api/v1/users/:userID/integrations/kai/:kaiType", () => {
let cookie: string[];
let userId: number;
beforeEach(async () => {
({ id: userId } = await seedUser({
username: "kai_del_user",
withCredential: true,
withSettings: true,
}));
cookie = await loginAs("kai_del_user");
});
it("returns 409 when not linked", async () => {
const res = await mockApi
.delete(`/api/v1/users/${userId}/integrations/kai/eag`)
.set("Cookie", cookie);
expect(res.status).toBe(409);
});
it("removes the priv_svc_kai_auth_token row", async () => {
await DB.insertInto("priv_svc_kai_auth_token")
.values({
user_id: userId,
service: "EAG",
token: "a",
refresh_token: "b",
})
.execute();
const res = await mockApi
.delete(`/api/v1/users/${userId}/integrations/kai/eag`)
.set("Cookie", cookie);
expect(res.status).toBe(200);
const row = await DB.selectFrom("priv_svc_kai_auth_token")
.selectAll()
.where("user_id", "=", userId)
.where("service", "=", "EAG")
.executeTakeFirst();
expect(row).toBeUndefined();
});
});
@@ -1,3 +1,5 @@
import { ACTION_RevokeKaiAuthToken } from "#actions/revoke-kai-auth-token";
import { ACTION_UpsertKaiAuthToken } from "#actions/upsert-kai-auth-token";
import { log } from "#lib/log/log";
import {
GetKaiTypeClientCredentials,
@@ -5,10 +7,9 @@ import {
} from "#lib/score-import/import-types/common/api-kai/utils";
import prValidate from "#server/middleware/prudence-validate";
import { RequireKamaitachi } from "#server/middleware/type-require";
import MONGODB_KILL from "#services/mongo/db";
import fetch from "#utils/fetch";
import { NotNullish } from "#utils/misc";
import { GetKaiAuth, RevokeKaiAuth } from "#utils/queries/auth";
import { GetKaiAuth } from "#utils/queries/auth";
import { GetTachiData } from "#utils/req-tachi-data";
import { FormatUserDoc } from "#utils/user";
import { Router } from "express";
@@ -65,7 +66,9 @@ router.delete<any>("/", async (req, res) => {
});
}
await RevokeKaiAuth(user.id, kaiType);
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
await ACTION_RevokeKaiAuthToken(taker, { service: kaiType });
return res.status(200).json({
success: true,
@@ -189,23 +192,13 @@ router.post(
const j = json as { access_token: string; refresh_token: string };
await MONGODB_KILL["kai-auth-tokens"].update(
{
userID: user.id,
service: kaiType,
},
{
$set: {
userID: user.id,
service: kaiType,
refreshToken: j.refresh_token,
token: j.access_token,
},
},
{
upsert: true,
},
);
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
await ACTION_UpsertKaiAuthToken(taker, {
service: kaiType,
token: j.access_token,
refreshToken: j.refresh_token,
});
log.info(`Updated Auth for ${kaiType} for user ${FormatUserDoc(user)}.`);
+13 -13
View File
@@ -1,21 +1,21 @@
import type { KtLogger } from "#lib/log/log";
import type { integer } from "tachi-common";
import type { integer, KaiAuthDocument } from "tachi-common";
import { SELECT_KAI_AUTH_TOKEN, ToKaiAuthDocument } from "#lib/db-formats/kai-auth-token";
import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error";
import MONGODB_KILL from "#services/mongo/db";
import DB from "#services/pg/db";
export function GetKaiAuth(userID: integer, service: "EAG" | "FLO" | "MIN") {
return MONGODB_KILL["kai-auth-tokens"].findOne({
userID,
service,
});
}
export async function GetKaiAuth(
userID: integer,
service: "EAG" | "FLO" | "MIN",
): Promise<KaiAuthDocument | null> {
const row = await DB.selectFrom("priv_svc_kai_auth_token")
.select(SELECT_KAI_AUTH_TOKEN)
.where("user_id", "=", userID)
.where("service", "=", service)
.executeTakeFirst();
export function RevokeKaiAuth(userID: integer, service: "EAG" | "FLO" | "MIN") {
return MONGODB_KILL["kai-auth-tokens"].remove({
userID,
service,
});
return row ? ToKaiAuthDocument(row) : null;
}
export async function GetKaiAuthGuaranteed(