diff --git a/api/api/v1/sso/:id/index.test.ts b/api/api/v1/sso/:id/index.test.ts deleted file mode 100644 index a4cce730..00000000 --- a/api/api/v1/sso/:id/index.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { fakeRequest, getTestUsers } from "~/tests/utils"; -import { meta } from "./index"; - -const { deleteUsers, tokens } = await getTestUsers(1); - -afterAll(async () => { - await deleteUsers(); -}); - -// /api/v1/sso/:id -describe(meta.route, () => { - test("should not find unknown issuer", async () => { - const response = await fakeRequest( - meta.route.replace(":id", "unknown"), - { - method: "GET", - headers: { - Authorization: `Bearer ${tokens[0]?.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: "Issuer not found", - }); - - const response2 = await fakeRequest( - meta.route.replace(":id", "unknown"), - { - method: "DELETE", - headers: { - Authorization: `Bearer ${tokens[0]?.accessToken}`, - "Content-Type": "application/json", - }, - }, - ); - - expect(response2.status).toBe(404); - expect(await response2.json()).toMatchObject({ - error: "Issuer not found", - }); - }); - - /* - Unfortunately, we cannot test actual linking, as it requires a valid OpenID provider - setup in config, which we don't have in tests - */ -}); diff --git a/api/api/v1/sso/:id/index.ts b/api/api/v1/sso/:id/index.ts deleted file mode 100644 index c19d361a..00000000 --- a/api/api/v1/sso/:id/index.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { apiRoute, applyConfig, auth } from "@/api"; -import { proxyUrl } from "@/response"; -import { createRoute } from "@hono/zod-openapi"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { db } from "~/drizzle/db"; -import { OpenIdAccounts, RolePermissions } from "~/drizzle/schema"; -import { config } from "~/packages/config-manager"; -import { ErrorSchema } from "~/types/api"; - -export const meta = applyConfig({ - allowedMethods: ["GET", "DELETE"], - auth: { - required: true, - }, - ratelimits: { - duration: 60, - max: 20, - }, - route: "/api/v1/sso/:id", - permissions: { - required: [RolePermissions.OAuth], - }, -}); - -export const schemas = { - param: z.object({ - id: z.string(), - }), -}; - -const routeGet = createRoute({ - method: "get", - path: "/api/v1/sso/{id}", - summary: "Get linked account", - middleware: [auth(meta.auth, meta.permissions)], - request: { - params: schemas.param, - }, - responses: { - 200: { - description: "Linked account", - content: { - "application/json": { - schema: z.object({ - id: z.string(), - name: z.string(), - icon: z.string().optional(), - }), - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 404: { - description: "Account not found", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - }, -}); - -const routeDelete = createRoute({ - method: "delete", - path: "/api/v1/sso/{id}", - summary: "Unlink account", - middleware: [auth(meta.auth, meta.permissions)], - request: { - params: schemas.param, - }, - responses: { - 204: { - description: "Account unlinked", - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - 404: { - description: "Account not found", - content: { - "application/json": { - schema: ErrorSchema, - }, - }, - }, - }, -}); - -export default apiRoute((app) => { - app.openapi(routeGet, async (context) => { - const { id: issuerId } = context.req.valid("param"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - // Check if issuer exists - const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerId, - ); - - if (!issuer) { - return context.json({ error: "Issuer not found" }, 404); - } - - // Get all linked accounts - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq, and }) => - and( - eq(account.userId, account.id), - eq(account.issuerId, issuerId), - ), - }); - - if (!account) { - return context.json( - { - error: "Account not found or is not linked to this issuer", - }, - 404, - ); - } - - return context.json( - { - id: issuer.id, - name: issuer.name, - icon: proxyUrl(issuer.icon) || undefined, - }, - 200, - ); - }); - - app.openapi(routeDelete, async (context) => { - const { id: issuerId } = context.req.valid("param"); - const { user } = context.get("auth"); - - if (!user) { - return context.json({ error: "Unauthorized" }, 401); - } - - // Check if issuer exists - const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerId, - ); - - if (!issuer) { - return context.json({ error: "Issuer not found" }, 404); - } - - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq, and }) => - and( - eq(account.userId, user.id), - eq(account.issuerId, issuerId), - ), - }); - - if (!account) { - return context.json( - { - error: "Account not found or is not linked to this issuer", - }, - 404, - ); - } - - await db - .delete(OpenIdAccounts) - .where(eq(OpenIdAccounts.id, account.id)); - - return context.newResponse(null, 204); - }); -}); diff --git a/classes/plugin/loader.test.ts b/classes/plugin/loader.test.ts index 08a599fc..70403bf7 100644 --- a/classes/plugin/loader.test.ts +++ b/classes/plugin/loader.test.ts @@ -8,7 +8,7 @@ import { test, } from "bun:test"; import { ZodError, type ZodTypeAny, z } from "zod"; -import { Plugin, PluginConfigManager } from "~/packages/plugin-kit"; +import { Plugin } from "~/packages/plugin-kit"; import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema"; import { PluginLoader } from "./loader"; @@ -154,7 +154,7 @@ describe("PluginLoader", () => { }); test("loadPlugin should load and return a Plugin instance", async () => { - const mockPlugin = new Plugin(new PluginConfigManager(z.object({}))); + const mockPlugin = new Plugin(z.object({})); mock.module("/some/path/index.ts", () => ({ default: mockPlugin, })); @@ -179,7 +179,7 @@ describe("PluginLoader", () => { version: "1.1.0", description: "Doobaee", }; - const mockPlugin = new Plugin(new PluginConfigManager(z.object({}))); + const mockPlugin = new Plugin(z.object({})); mockReaddir .mockResolvedValueOnce([ diff --git a/packages/plugin-kit/example.ts b/packages/plugin-kit/example.ts index 1d3e68e5..2b692157 100644 --- a/packages/plugin-kit/example.ts +++ b/packages/plugin-kit/example.ts @@ -1,15 +1,13 @@ import { z } from "zod"; import { Hooks } from "./hooks"; -import { Plugin, PluginConfigManager } from "./plugin"; +import { Plugin } from "./plugin"; -const configManager = new PluginConfigManager( +const myPlugin = new Plugin( z.object({ apiKey: z.string(), }), ); -const myPlugin = new Plugin(configManager); - myPlugin.registerHandler(Hooks.Response, (req) => { console.info("Request received:", req); return req; diff --git a/packages/plugin-kit/index.ts b/packages/plugin-kit/index.ts index b9ef9b08..9b043240 100644 --- a/packages/plugin-kit/index.ts +++ b/packages/plugin-kit/index.ts @@ -1,6 +1,6 @@ import { Hooks } from "./hooks"; -import { Plugin, PluginConfigManager } from "./plugin"; +import { Plugin } from "./plugin"; import type { Manifest } from "./schema"; export type { Manifest }; -export { Plugin, PluginConfigManager, Hooks }; +export { Plugin, Hooks }; diff --git a/packages/plugin-kit/plugin.ts b/packages/plugin-kit/plugin.ts index 140f2b97..7eee52cd 100644 --- a/packages/plugin-kit/plugin.ts +++ b/packages/plugin-kit/plugin.ts @@ -13,18 +13,19 @@ export type HonoPluginEnv = HonoEnv & { export class Plugin { private handlers: Partial = {}; + private store: z.infer | null = null; private routes: { path: string; fn: (app: OpenAPIHono>) => void; }[] = []; - constructor(private configManager: PluginConfigManager) {} + constructor(private configSchema: ConfigSchema) {} get middleware() { // Middleware that adds the plugin's configuration to the request object return createMiddleware>( async (context, next) => { - context.set("pluginConfig", this.configManager.getConfig()); + context.set("pluginConfig", this.getConfig()); await next(); }, ); @@ -45,9 +46,12 @@ export class Plugin { * This will be called when the plugin is loaded. * @param config Values the user has set in the configuration file. */ - protected _loadConfig(config: z.input): Promise { - // biome-ignore lint/complexity/useLiteralKeys: Private method - return this.configManager["_load"](config); + protected async _loadConfig(config: z.input): Promise { + try { + this.store = await this.configSchema.parseAsync(config); + } catch (error) { + throw fromZodError(error as ZodError).message; + } } protected _addToApp(app: OpenAPIHono) { @@ -73,39 +77,11 @@ export class Plugin { "registerHandler" in instance ); } -} - -/** - * Handles loading, defining, and managing the plugin's configuration. - * Plugins can define their own configuration schema, which is then used to - * load it from the user's configuration file. - * @param schema The Zod schema that defines the configuration. - */ -export class PluginConfigManager { - private store: z.infer | null; - - constructor(private schema: Schema) { - this.store = null; - } - - /** - * Loads the configuration from the Versia Server configuration file. - * This will be called when the plugin is loaded. - * @param config Values the user has set in the configuration file. - */ - protected async _load(config: z.infer) { - // Check if the configuration is valid - try { - this.store = await this.schema.parseAsync(config); - } catch (error) { - throw fromZodError(error as ZodError).message; - } - } /** * Returns the internal configuration object. */ - public getConfig() { + private getConfig() { if (!this.store) { throw new Error("Configuration has not been loaded yet."); } diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts index 6780f35b..96fbbe03 100644 --- a/plugins/openid/index.ts +++ b/plugins/openid/index.ts @@ -1,9 +1,10 @@ -import { Hooks, Plugin, PluginConfigManager } from "@versia/kit"; +import { Hooks, Plugin } from "@versia/kit"; import { z } from "zod"; import authorizeRoute from "./routes/authorize"; import ssoRoute from "./routes/sso"; +import ssoIdRoute from "./routes/sso/:id/index"; -const configManager = new PluginConfigManager( +const plugin = new Plugin( z.object({ forced: z.boolean().default(false), allow_registration: z.boolean().default(true), @@ -60,14 +61,13 @@ const configManager = new PluginConfigManager( }), ); -const plugin = new Plugin(configManager); - plugin.registerHandler(Hooks.Response, (req) => { console.info("Request received:", req); return req; }); authorizeRoute(plugin); ssoRoute(plugin); +ssoIdRoute(plugin); export type PluginType = typeof plugin; export default plugin; diff --git a/plugins/openid/routes/sso/:id/index.test.ts b/plugins/openid/routes/sso/:id/index.test.ts new file mode 100644 index 00000000..1bd00e4c --- /dev/null +++ b/plugins/openid/routes/sso/:id/index.test.ts @@ -0,0 +1,38 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { fakeRequest, getTestUsers } from "~/tests/utils"; + +const { deleteUsers, tokens } = await getTestUsers(1); + +afterAll(async () => { + await deleteUsers(); +}); + +// /api/v1/sso/:id +describe("/api/v1/sso/:id", () => { + test("should not find unknown issuer", async () => { + const response = await fakeRequest("/api/v1/sso/unknown", { + method: "GET", + headers: { + Authorization: `Bearer ${tokens[0]?.accessToken}`, + }, + }); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + error: "Issuer with ID unknown not found in instance's OpenID configuration", + }); + + const response2 = await fakeRequest("/api/v1/sso/unknown", { + method: "DELETE", + headers: { + Authorization: `Bearer ${tokens[0]?.accessToken}`, + "Content-Type": "application/json", + }, + }); + + expect(response2.status).toBe(404); + expect(await response2.json()).toMatchObject({ + error: "Issuer with ID unknown not found in instance's OpenID configuration", + }); + }); +}); diff --git a/plugins/openid/routes/sso/:id/index.ts b/plugins/openid/routes/sso/:id/index.ts new file mode 100644 index 00000000..6d4399f4 --- /dev/null +++ b/plugins/openid/routes/sso/:id/index.ts @@ -0,0 +1,208 @@ +import { auth } from "@/api"; +import { proxyUrl } from "@/response"; +import { createRoute, z } from "@hono/zod-openapi"; +import { eq } from "drizzle-orm"; +import { db } from "~/drizzle/db"; +import { OpenIdAccounts, RolePermissions } from "~/drizzle/schema"; +import type { PluginType } from "~/plugins/openid"; +import { ErrorSchema } from "~/types/api"; + +export default (plugin: PluginType) => { + plugin.registerRoute("/api/v1/sso", (app) => { + app.openapi( + createRoute({ + method: "get", + path: "/api/v1/sso/{id}", + summary: "Get linked account", + middleware: [ + auth( + { + required: true, + }, + { + required: [RolePermissions.OAuth], + }, + ), + plugin.middleware, + ], + request: { + params: z.object({ + id: z.string(), + }), + }, + responses: { + 200: { + description: "Linked account", + content: { + "application/json": { + schema: z.object({ + id: z.string(), + name: z.string(), + icon: z.string().optional(), + }), + }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, + }), + async (context) => { + const { id: issuerId } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json( + { + error: "Unauthorized", + }, + 401, + ); + } + + const issuer = context + .get("pluginConfig") + .providers.find((provider) => provider.id === issuerId); + + if (!issuer) { + return context.json( + { + error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, + }, + 404, + ); + } + + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.userId, user.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + return context.json( + { + error: "Account not found or is not linked to this issuer", + }, + 404, + ); + } + + return context.json( + { + id: issuer.id, + name: issuer.name, + icon: proxyUrl(issuer.icon) ?? undefined, + }, + 200, + ); + }, + ); + + app.openapi( + createRoute({ + method: "delete", + path: "/api/v1/sso/{id}", + summary: "Unlink account", + middleware: [ + auth( + { + required: true, + }, + { + required: [RolePermissions.OAuth], + }, + ), + plugin.middleware, + ], + request: { + params: z.object({ + id: z.string(), + }), + }, + responses: { + 204: { + description: "Account unlinked", + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: ErrorSchema, + }, + }, + }, + }, + }), + async (context) => { + const { id: issuerId } = context.req.valid("param"); + const { user } = context.get("auth"); + + if (!user) { + return context.json({ error: "Unauthorized" }, 401); + } + + // Check if issuer exists + const issuer = context + .get("pluginConfig") + .providers.find((provider) => provider.id === issuerId); + + if (!issuer) { + return context.json( + { + error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, + }, + 404, + ); + } + + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.userId, user.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + return context.json( + { + error: "Account not found or is not linked to this issuer", + }, + 404, + ); + } + + await db + .delete(OpenIdAccounts) + .where(eq(OpenIdAccounts.id, account.id)); + + return context.newResponse(null, 204); + }, + ); + }); +};