refactor(api): 🔥 Remove old forced OpenID auth code

This commit is contained in:
Jesse Wierzbinski 2025-10-24 19:12:40 +02:00
parent 45c3f6ae3f
commit 955a933fe9
No known key found for this signature in database
8 changed files with 0 additions and 448 deletions

View file

@ -452,14 +452,6 @@ log_level = "info" # For console output
# environment = "production" # environment = "production"
# log_level = "info" # 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] [authentication.keys]
# Run Versia Server with those values missing to generate a new key # Run Versia Server with those values missing to generate a new key
public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8=" public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8="

View file

@ -459,13 +459,6 @@ log_level = "info" # For console output
# log_level = "info" # log_level = "info"
[authentication] [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 # Run Versia Server with this value missing to generate a new key
# key = "" # key = ""

View file

@ -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();
});
});
});

View file

@ -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()}`,
);
},
),
);

View file

@ -111,7 +111,6 @@ export default apiRoute((app) =>
version: "4.3.0-alpha.3+glitch", version: "4.3.0-alpha.3+glitch",
versia_version: version, versia_version: version,
sso: { sso: {
forced: config.authentication.forced_openid,
providers: config.authentication.openid_providers.map( providers: config.authentication.openid_providers.map(
(p) => ({ (p) => ({
name: p.name, name: p.name,

View file

@ -151,7 +151,6 @@ export default apiRoute((app) =>
hint: r.hint, hint: r.hint,
})), })),
sso: { sso: {
forced: config.authentication.forced_openid,
providers: config.authentication.openid_providers.map( providers: config.authentication.openid_providers.map(
(p) => ({ (p) => ({
name: p.name, name: p.name,

View file

@ -87,11 +87,6 @@ export const NoteReactionWithAccounts = NoteReaction.extend({
/* Versia Server API extension */ /* Versia Server API extension */
export const SSOConfig = z.object({ 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 providers: z
.array( .array(
z.object({ z.object({

View file

@ -800,7 +800,6 @@ export const ConfigSchema = z
}) })
.optional(), .optional(),
authentication: z.strictObject({ authentication: z.strictObject({
forced_openid: z.boolean().default(false),
openid_providers: z openid_providers: z
.array( .array(
z.strictObject({ z.strictObject({