From 955a933fe98b95e4b5b0250e220462920b69cfe9 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 24 Oct 2025 19:12:40 +0200 Subject: [PATCH] refactor(api): :fire: Remove old forced OpenID auth code --- .github/config.workflow.toml | 8 - config/config.example.toml | 7 - .../api/routes/api/auth/login/index.test.ts | 227 ------------------ packages/api/routes/api/auth/login/index.ts | 198 --------------- packages/api/routes/api/v1/instance/index.ts | 1 - packages/api/routes/api/v2/instance/index.ts | 1 - packages/client/schemas/versia.ts | 5 - packages/config/index.ts | 1 - 8 files changed, 448 deletions(-) delete mode 100644 packages/api/routes/api/auth/login/index.test.ts delete mode 100644 packages/api/routes/api/auth/login/index.ts diff --git a/.github/config.workflow.toml b/.github/config.workflow.toml index 22a15529..42eff479 100644 --- a/.github/config.workflow.toml +++ b/.github/config.workflow.toml @@ -452,14 +452,6 @@ log_level = "info" # For console output # environment = "production" # log_level = "info" -[authentication] -# If enabled, Versia will require users to log in with an OpenID provider -forced_openid = false - -# Allow registration with OpenID providers -# If signups.registration is false, it will only be possible to register with OpenID -openid_registration = true - [authentication.keys] # Run Versia Server with those values missing to generate a new key public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8=" diff --git a/config/config.example.toml b/config/config.example.toml index f7b231a7..1712b1a3 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -459,13 +459,6 @@ log_level = "info" # For console output # log_level = "info" [authentication] -# If enabled, Versia will require users to log in with an OpenID provider -forced_openid = false - -# Allow registration with OpenID providers -# If signups.registration is false, it will only be possible to register with OpenID -openid_registration = true - # Run Versia Server with this value missing to generate a new key # key = "" diff --git a/packages/api/routes/api/auth/login/index.test.ts b/packages/api/routes/api/auth/login/index.test.ts deleted file mode 100644 index dc0f08c0..00000000 --- a/packages/api/routes/api/auth/login/index.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { config } from "@versia-server/config"; -import { Client } from "@versia-server/kit/db"; -import { fakeRequest, getTestUsers } from "@versia-server/tests"; -import { randomString } from "@/math"; - -const { users, deleteUsers, passwords } = await getTestUsers(1); - -// Create application -const application = await Client.insert({ - id: randomString(32, "hex"), - name: "Test Client", - secret: "test", - redirectUris: ["https://example.com"], - scopes: ["read", "write"], -}); - -afterAll(async () => { - await deleteUsers(); - await application.delete(); -}); - -// /api/auth/login -describe("/api/auth/login", () => { - test("should get a JWT with email", async () => { - const formData = new FormData(); - - formData.append("identifier", users[0]?.data.email ?? ""); - formData.append("password", passwords[0]); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - - expect(locationHeader.pathname).toBe("/oauth/consent"); - expect(locationHeader.searchParams.get("client_id")).toBe( - application.data.id, - ); - expect(locationHeader.searchParams.get("redirect_uri")).toBe( - "https://example.com", - ); - expect(locationHeader.searchParams.get("response_type")).toBe("code"); - expect(locationHeader.searchParams.get("scope")).toBe("read write"); - - expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/); - }); - - test("should get a JWT with username", async () => { - const formData = new FormData(); - - formData.append("identifier", users[0]?.data.username ?? ""); - formData.append("password", passwords[0]); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - - expect(locationHeader.pathname).toBe("/oauth/consent"); - expect(locationHeader.searchParams.get("client_id")).toBe( - application.data.id, - ); - expect(locationHeader.searchParams.get("redirect_uri")).toBe( - "https://example.com", - ); - expect(locationHeader.searchParams.get("response_type")).toBe("code"); - expect(locationHeader.searchParams.get("scope")).toBe("read write"); - - expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/); - }); - - test("should have state in the URL", async () => { - const formData = new FormData(); - - formData.append("identifier", users[0]?.data.email ?? ""); - formData.append("password", passwords[0]); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write&state=abc`, - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - - expect(locationHeader.pathname).toBe("/oauth/consent"); - expect(locationHeader.searchParams.get("client_id")).toBe( - application.data.id, - ); - expect(locationHeader.searchParams.get("redirect_uri")).toBe( - "https://example.com", - ); - expect(locationHeader.searchParams.get("response_type")).toBe("code"); - expect(locationHeader.searchParams.get("scope")).toBe("read write"); - expect(locationHeader.searchParams.get("state")).toBe("abc"); - - expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/); - }); - - describe("should reject invalid credentials", () => { - // Redirects to /oauth/authorize on invalid - test("invalid email", async () => { - const formData = new FormData(); - - formData.append("identifier", "ababa@gmail.com"); - formData.append("password", "password"); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - "", - ); - - expect(locationHeader.pathname).toBe("/oauth/authorize"); - expect(locationHeader.searchParams.get("error")).toBe( - "invalid_grant", - ); - expect(locationHeader.searchParams.get("error_description")).toBe( - "Invalid identifier or password", - ); - - expect(response.headers.get("Set-Cookie")).toBeNull(); - }); - - test("invalid username", async () => { - const formData = new FormData(); - - formData.append("identifier", "ababa"); - formData.append("password", "password"); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - "", - ); - - expect(locationHeader.pathname).toBe("/oauth/authorize"); - expect(locationHeader.searchParams.get("error")).toBe( - "invalid_grant", - ); - expect(locationHeader.searchParams.get("error_description")).toBe( - "Invalid identifier or password", - ); - - expect(response.headers.get("Set-Cookie")).toBeNull(); - }); - - test("invalid password", async () => { - const formData = new FormData(); - - formData.append("identifier", users[0]?.data.email ?? ""); - formData.append("password", "password"); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - "", - ); - - expect(locationHeader.pathname).toBe("/oauth/authorize"); - expect(locationHeader.searchParams.get("error")).toBe( - "invalid_grant", - ); - expect(locationHeader.searchParams.get("error_description")).toBe( - "Invalid identifier or password", - ); - - expect(response.headers.get("Set-Cookie")).toBeNull(); - }); - }); -}); diff --git a/packages/api/routes/api/auth/login/index.ts b/packages/api/routes/api/auth/login/index.ts deleted file mode 100644 index f953fc0d..00000000 --- a/packages/api/routes/api/auth/login/index.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { config } from "@versia-server/config"; -import { ApiError } from "@versia-server/kit"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { Client, User } from "@versia-server/kit/db"; -import { Users } from "@versia-server/kit/tables"; -import { password as bunPassword } from "bun"; -import { eq, or } from "drizzle-orm"; -import type { Context } from "hono"; -import { setCookie } from "hono/cookie"; -import { sign } from "hono/jwt"; -import { describeRoute, validator } from "hono-openapi"; -import { z } from "zod/v4"; - -const returnError = ( - context: Context, - error: string, - description: string, -): Response => { - const searchParams = new URLSearchParams(); - - // Add all data that is not undefined except email and password - for (const [key, value] of Object.entries(context.req.query())) { - if (key !== "email" && key !== "password" && value !== undefined) { - searchParams.append(key, value); - } - } - - searchParams.append("error", error); - searchParams.append("error_description", description); - - return context.redirect( - new URL( - `${config.frontend.routes.login}?${searchParams.toString()}`, - config.http.base_url, - ).toString(), - ); -}; - -export default apiRoute((app) => - app.post( - "/api/auth/login", - describeRoute({ - summary: "Login", - description: "Login to the application", - responses: { - 302: { - description: "Redirect to OAuth authorize, or error", - headers: { - "Set-Cookie": { - description: "JWT cookie", - required: false, - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - scope: z.string().optional(), - redirect_uri: z.url().optional(), - response_type: z.enum([ - "code", - "token", - "none", - "id_token", - "code id_token", - "code token", - "token id_token", - "code token id_token", - ]), - client_id: z.string(), - state: z.string().optional(), - code_challenge: z.string().optional(), - code_challenge_method: z.enum(["plain", "S256"]).optional(), - prompt: z - .enum(["none", "login", "consent", "select_account"]) - .optional() - .default("none"), - max_age: z - .number() - .int() - .optional() - .default(60 * 60 * 24 * 7), - }), - handleZodError, - ), - validator( - "form", - z.object({ - identifier: z - .email() - .toLowerCase() - .or(z.string().toLowerCase()), - password: z.string().min(2).max(100), - }), - handleZodError, - ), - async (context) => { - if (config.authentication.forced_openid) { - return returnError( - context, - "invalid_request", - "Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.", - ); - } - - const { identifier, password } = context.req.valid("form"); - const { client_id } = context.req.valid("query"); - - // Find user - const user = await User.fromSql( - or( - eq(Users.email, identifier.toLowerCase()), - eq(Users.username, identifier.toLowerCase()), - ), - ); - - if ( - !( - user && - (await bunPassword.verify( - password, - user.data.password || "", - )) - ) - ) { - return returnError( - context, - "invalid_grant", - "Invalid identifier or password", - ); - } - - if (user.data.passwordResetToken) { - return context.redirect( - `${config.frontend.routes.password_reset}?${new URLSearchParams( - { - token: user.data.passwordResetToken ?? "", - login_reset: "true", - }, - ).toString()}`, - ); - } - - // Generate JWT - const jwt = await sign( - { - sub: user.id, - iss: config.http.base_url.origin, - aud: client_id, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }, - config.authentication.key, - ); - - const application = await Client.fromClientId(client_id); - - if (!application) { - throw new ApiError(400, "Invalid application"); - } - - const searchParams = new URLSearchParams({ - application: application.data.name, - }); - - if (application.data.website) { - searchParams.append("website", application.data.website); - } - - // Add all data that is not undefined except email and password - for (const [key, value] of Object.entries(context.req.query())) { - if ( - key !== "email" && - key !== "password" && - value !== undefined - ) { - searchParams.append(key, String(value)); - } - } - - // Redirect to OAuth authorize with JWT - setCookie(context, "jwt", jwt, { - httpOnly: true, - secure: true, - sameSite: "Strict", - path: "/", - // 2 weeks - maxAge: 60 * 60 * 24 * 14, - }); - return context.redirect( - `${config.frontend.routes.consent}?${searchParams.toString()}`, - ); - }, - ), -); diff --git a/packages/api/routes/api/v1/instance/index.ts b/packages/api/routes/api/v1/instance/index.ts index 73b89d29..94056ae6 100644 --- a/packages/api/routes/api/v1/instance/index.ts +++ b/packages/api/routes/api/v1/instance/index.ts @@ -111,7 +111,6 @@ export default apiRoute((app) => version: "4.3.0-alpha.3+glitch", versia_version: version, sso: { - forced: config.authentication.forced_openid, providers: config.authentication.openid_providers.map( (p) => ({ name: p.name, diff --git a/packages/api/routes/api/v2/instance/index.ts b/packages/api/routes/api/v2/instance/index.ts index 18150104..b44a24e2 100644 --- a/packages/api/routes/api/v2/instance/index.ts +++ b/packages/api/routes/api/v2/instance/index.ts @@ -151,7 +151,6 @@ export default apiRoute((app) => hint: r.hint, })), sso: { - forced: config.authentication.forced_openid, providers: config.authentication.openid_providers.map( (p) => ({ name: p.name, diff --git a/packages/client/schemas/versia.ts b/packages/client/schemas/versia.ts index 120d8c32..739d1fb3 100644 --- a/packages/client/schemas/versia.ts +++ b/packages/client/schemas/versia.ts @@ -87,11 +87,6 @@ export const NoteReactionWithAccounts = NoteReaction.extend({ /* Versia Server API extension */ export const SSOConfig = z.object({ - forced: z.boolean().meta({ - description: - "If this is enabled, normal identifier/password login is disabled and login must be done through SSO.", - example: false, - }), providers: z .array( z.object({ diff --git a/packages/config/index.ts b/packages/config/index.ts index 664f1e02..161b3113 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -800,7 +800,6 @@ export const ConfigSchema = z }) .optional(), authentication: z.strictObject({ - forced_openid: z.boolean().default(false), openid_providers: z .array( z.strictObject({