From 6f33a1d27b3519f76c0eeae9fcd51d7095eaaf69 Mon Sep 17 00:00:00 2001 From: zk Date: Thu, 26 Mar 2026 01:00:15 +0000 Subject: [PATCH] feat: kai --- .../src/actions/create-oauth2-auth-code.ts | 25 +++ .../src/actions/revoke-kai-auth-token.ts | 14 ++ .../src/actions/test-utils/api-tokens.ts | 6 +- .../src/actions/upsert-kai-auth-token.ts | 12 ++ .../src/anon-actions/oauth-token-exchange.ts | 93 ++++++++++ typescript/server/src/lib/actions/actions.ts | 38 ++++ .../src/lib/db-formats/kai-auth-token.ts | 21 +++ .../server/src/lib/kai-auth-token/persist.ts | 46 +++++ .../common/api-kai/reauth.oldtest.ts | 122 ------------- .../common/api-kai/reauth.test.ts | 137 +++++++++++++++ .../import-types/common/api-kai/reauth.ts | 20 +-- .../server/src/scripts/make-user-admin.ts | 38 ---- .../router/api/v1/oauth/router.oldtest.ts | 99 ----------- .../server/router/api/v1/oauth/router.test.ts | 165 ++++++++++++++++++ .../src/server/router/api/v1/oauth/router.ts | 72 ++------ .../kai/_kaiType/router.oldtest.ts | 65 ------- .../integrations/kai/_kaiType/router.test.ts | 130 ++++++++++++++ .../integrations/kai/_kaiType/router.ts | 33 ++-- typescript/server/src/utils/queries/auth.ts | 26 +-- 19 files changed, 734 insertions(+), 428 deletions(-) create mode 100644 typescript/server/src/actions/create-oauth2-auth-code.ts create mode 100644 typescript/server/src/actions/revoke-kai-auth-token.ts create mode 100644 typescript/server/src/actions/upsert-kai-auth-token.ts create mode 100644 typescript/server/src/anon-actions/oauth-token-exchange.ts create mode 100644 typescript/server/src/lib/db-formats/kai-auth-token.ts create mode 100644 typescript/server/src/lib/kai-auth-token/persist.ts delete mode 100644 typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.oldtest.ts create mode 100644 typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts delete mode 100644 typescript/server/src/scripts/make-user-admin.ts delete mode 100644 typescript/server/src/server/router/api/v1/oauth/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/oauth/router.test.ts delete mode 100644 typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.oldtest.ts create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.test.ts diff --git a/typescript/server/src/actions/create-oauth2-auth-code.ts b/typescript/server/src/actions/create-oauth2-auth-code.ts new file mode 100644 index 000000000..f9c76628a --- /dev/null +++ b/typescript/server/src/actions/create-oauth2-auth-code.ts @@ -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, + }; + }, +); diff --git a/typescript/server/src/actions/revoke-kai-auth-token.ts b/typescript/server/src/actions/revoke-kai-auth-token.ts new file mode 100644 index 000000000..0b2554bfa --- /dev/null +++ b/typescript/server/src/actions/revoke-kai-auth-token.ts @@ -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 {}; + }, +); diff --git a/typescript/server/src/actions/test-utils/api-tokens.ts b/typescript/server/src/actions/test-utils/api-tokens.ts index 7b22fb3d2..70c21de71 100644 --- a/typescript/server/src/actions/test-utils/api-tokens.ts +++ b/typescript/server/src/actions/test-utils/api-tokens.ts @@ -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(); diff --git a/typescript/server/src/actions/upsert-kai-auth-token.ts b/typescript/server/src/actions/upsert-kai-auth-token.ts new file mode 100644 index 000000000..d40f8cf6c --- /dev/null +++ b/typescript/server/src/actions/upsert-kai-auth-token.ts @@ -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 {}; + }, +); diff --git a/typescript/server/src/anon-actions/oauth-token-exchange.ts b/typescript/server/src/anon-actions/oauth-token-exchange.ts new file mode 100644 index 000000000..09b55afea --- /dev/null +++ b/typescript/server/src/anon-actions/oauth-token-exchange.ts @@ -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, + fromAPIClient: doc.fromAPIClient, + }; + }, +); diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index 966339929..737693c30 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -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; 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; /** diff --git a/typescript/server/src/lib/db-formats/kai-auth-token.ts b/typescript/server/src/lib/db-formats/kai-auth-token.ts new file mode 100644 index 000000000..541f4ad45 --- /dev/null +++ b/typescript/server/src/lib/db-formats/kai-auth-token.ts @@ -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, +): KaiAuthDocument { + return { + userID: row.user_id, + service: row.service as KaiAuthDocument["service"], + token: row.token, + refreshToken: row.refresh_token, + }; +} diff --git a/typescript/server/src/lib/kai-auth-token/persist.ts b/typescript/server/src/lib/kai-auth-token/persist.ts new file mode 100644 index 000000000..e86916092 --- /dev/null +++ b/typescript/server/src/lib/kai-auth-token/persist.ts @@ -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 | Transaction, + userId: number, + service: KaiService, + token: string, + refreshToken: string, +): Promise { + 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 | Transaction, + userId: number, + service: KaiService, + token: string, + refreshToken: string, +): Promise { + await db + .updateTable("priv_svc_kai_auth_token") + .set({ + token, + refresh_token: refreshToken, + }) + .where("user_id", "=", userId) + .where("service", "=", service) + .execute(); +} diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.oldtest.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.oldtest.ts deleted file mode 100644 index 43940848c..000000000 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.oldtest.ts +++ /dev/null @@ -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(); -}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts new file mode 100644 index 000000000..345d59364 --- /dev/null +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.test.ts @@ -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); + }); +}); diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts index f7cfe98b1..3c3d90095 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts @@ -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; diff --git a/typescript/server/src/scripts/make-user-admin.ts b/typescript/server/src/scripts/make-user-admin.ts deleted file mode 100644 index 2b03b791c..000000000 --- a/typescript/server/src/scripts/make-user-admin.ts +++ /dev/null @@ -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 .`); - throw new Error(`No userID provided.`); -} - -if (require.main === module) { - WrapScriptPromise(MakeUserAdmin(userID), log); -} diff --git a/typescript/server/src/server/router/api/v1/oauth/router.oldtest.ts b/typescript/server/src/server/router/api/v1/oauth/router.oldtest.ts deleted file mode 100644 index d7dc5a4e9..000000000 --- a/typescript/server/src/server/router/api/v1/oauth/router.oldtest.ts +++ /dev/null @@ -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(); -}); diff --git a/typescript/server/src/server/router/api/v1/oauth/router.test.ts b/typescript/server/src/server/router/api/v1/oauth/router.test.ts new file mode 100644 index 000000000..ff54c4a3f --- /dev/null +++ b/typescript/server/src/server/router/api/v1/oauth/router.test.ts @@ -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); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/oauth/router.ts b/typescript/server/src/server/router/api/v1/oauth/router.ts index 3ead2c326..db110089d 100644 --- a/typescript/server/src/server/router/api/v1/oauth/router.ts +++ b/typescript/server/src/server/router/api/v1/oauth/router.ts @@ -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, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.oldtest.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.oldtest.ts deleted file mode 100644 index e2a74ef26..000000000 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.oldtest.ts +++ /dev/null @@ -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"); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.test.ts new file mode 100644 index 000000000..9fd339792 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.test.ts @@ -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(); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts index 9d63104fd..ce7baa3c2 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts @@ -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("/", 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)}.`); diff --git a/typescript/server/src/utils/queries/auth.ts b/typescript/server/src/utils/queries/auth.ts index a2315068b..a2922c2fc 100644 --- a/typescript/server/src/utils/queries/auth.ts +++ b/typescript/server/src/utils/queries/auth.ts @@ -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 { + 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(