mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 00:18:19 +01:00
refactor(api): 🔥 Remove old forced OpenID auth code
This commit is contained in:
parent
45c3f6ae3f
commit
955a933fe9
8
.github/config.workflow.toml
vendored
8
.github/config.workflow.toml
vendored
|
|
@ -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="
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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()}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue