2025-06-15 04:38:20 +02:00
|
|
|
import { config } from "@versia-server/config";
|
2025-06-15 23:50:34 +02:00
|
|
|
import { ApiError } from "@versia-server/kit";
|
|
|
|
|
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
2025-08-21 01:21:32 +02:00
|
|
|
import { Client, User } from "@versia-server/kit/db";
|
2025-06-15 23:50:34 +02:00
|
|
|
import { Users } from "@versia-server/kit/tables";
|
2025-03-30 23:06:34 +02:00
|
|
|
import { password as bunPassword } from "bun";
|
2024-05-08 10:02:05 +02:00
|
|
|
import { eq, or } from "drizzle-orm";
|
2024-12-18 20:42:40 +01:00
|
|
|
import type { Context } from "hono";
|
2025-04-10 19:15:31 +02:00
|
|
|
import { setCookie } from "hono/cookie";
|
2025-08-21 00:45:58 +02:00
|
|
|
import { sign } from "hono/jwt";
|
2025-07-07 03:42:35 +02:00
|
|
|
import { describeRoute, validator } from "hono-openapi";
|
|
|
|
|
import { z } from "zod/v4";
|
2023-10-16 05:51:29 +02:00
|
|
|
|
2024-11-02 00:43:33 +01:00
|
|
|
const returnError = (
|
|
|
|
|
context: Context,
|
|
|
|
|
error: string,
|
|
|
|
|
description: string,
|
|
|
|
|
): Response => {
|
2024-04-29 01:47:14 +02:00
|
|
|
const searchParams = new URLSearchParams();
|
2024-04-18 10:42:12 +02:00
|
|
|
|
2024-04-25 20:50:30 +02:00
|
|
|
// Add all data that is not undefined except email and password
|
2024-08-28 17:01:56 +02:00
|
|
|
for (const [key, value] of Object.entries(context.req.query())) {
|
2024-06-13 04:26:43 +02:00
|
|
|
if (key !== "email" && key !== "password" && value !== undefined) {
|
2024-04-25 20:50:30 +02:00
|
|
|
searchParams.append(key, value);
|
2024-06-13 04:26:43 +02:00
|
|
|
}
|
2024-04-18 10:42:12 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-29 01:47:14 +02:00
|
|
|
searchParams.append("error", error);
|
|
|
|
|
searchParams.append("error_description", description);
|
|
|
|
|
|
2024-08-28 17:01:56 +02:00
|
|
|
return context.redirect(
|
2024-06-14 10:03:51 +02:00
|
|
|
new URL(
|
2024-05-17 06:05:06 +02:00
|
|
|
`${config.frontend.routes.login}?${searchParams.toString()}`,
|
2024-05-08 10:02:05 +02:00
|
|
|
config.http.base_url,
|
2024-08-28 17:01:56 +02:00
|
|
|
).toString(),
|
2024-06-14 10:03:51 +02:00
|
|
|
);
|
2024-04-29 01:47:14 +02:00
|
|
|
};
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2024-08-19 20:06:38 +02:00
|
|
|
export default apiRoute((app) =>
|
2025-03-29 03:30:06 +01:00
|
|
|
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(),
|
2025-07-07 03:42:35 +02:00
|
|
|
redirect_uri: z.url().optional(),
|
2025-03-29 03:30:06 +01:00
|
|
|
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) => {
|
2025-07-07 05:52:11 +02:00
|
|
|
if (config.authentication.forced_openid) {
|
2025-03-29 03:30:06 +01:00
|
|
|
return returnError(
|
|
|
|
|
context,
|
|
|
|
|
"invalid_request",
|
|
|
|
|
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
|
|
|
|
|
);
|
|
|
|
|
}
|
2024-04-29 01:47:14 +02:00
|
|
|
|
2025-03-29 03:30:06 +01:00
|
|
|
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()),
|
|
|
|
|
),
|
2024-08-27 17:20:36 +02:00
|
|
|
);
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2025-03-29 03:30:06 +01:00
|
|
|
if (
|
|
|
|
|
!(
|
|
|
|
|
user &&
|
2025-03-30 23:06:34 +02:00
|
|
|
(await bunPassword.verify(
|
2025-03-29 03:30:06 +01:00
|
|
|
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
|
2025-08-21 00:45:58 +02:00
|
|
|
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),
|
|
|
|
|
},
|
2025-08-21 01:15:38 +02:00
|
|
|
config.authentication.key,
|
2025-08-21 00:45:58 +02:00
|
|
|
);
|
2025-03-29 03:30:06 +01:00
|
|
|
|
2025-08-21 01:21:32 +02:00
|
|
|
const application = await Client.fromClientId(client_id);
|
2025-03-29 03:30:06 +01:00
|
|
|
|
|
|
|
|
if (!application) {
|
|
|
|
|
throw new ApiError(400, "Invalid application");
|
|
|
|
|
}
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2025-03-29 03:30:06 +01:00
|
|
|
const searchParams = new URLSearchParams({
|
|
|
|
|
application: application.data.name,
|
|
|
|
|
});
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2025-03-29 03:30:06 +01:00
|
|
|
if (application.data.website) {
|
|
|
|
|
searchParams.append("website", application.data.website);
|
|
|
|
|
}
|
2024-05-06 09:16:33 +02:00
|
|
|
|
2025-03-29 03:30:06 +01:00
|
|
|
// 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));
|
|
|
|
|
}
|
2024-05-06 09:16:33 +02:00
|
|
|
}
|
|
|
|
|
|
2025-03-29 03:30:06 +01:00
|
|
|
// 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()}`,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
2024-08-19 20:06:38 +02:00
|
|
|
);
|