From eb4e9f07001cec176082031e9cd9f6d9a4869877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Tue, 29 Sep 2026 00:23:30 +0200 Subject: [PATCH] Migrated auth to Nitro --- app/composables/useAuth.ts | 175 +++++++++---------------------- app/middleware/auth.global.ts | 39 ++++++- app/pages/auth/signin.vue | 2 +- nuxt.config.ts | 8 ++ server/api/auth/register.post.ts | 51 +++++++++ server/api/auth/session.get.ts | 35 +++++++ server/api/auth/session.post.ts | 52 +++++++++ server/api/auth/signin.post.ts | 47 +++++++++ server/api/auth/signout.post.ts | 8 ++ server/models/session.ts | 19 ++++ server/models/user.ts | 24 +++++ server/plugins/database.ts | 13 +++ server/utils/auth-validation.ts | 42 ++++++++ server/utils/auth.ts | 67 ++++++++++++ server/utils/backend-config.ts | 16 +++ server/utils/database.ts | 48 +++++++++ 16 files changed, 517 insertions(+), 129 deletions(-) create mode 100644 server/api/auth/register.post.ts create mode 100644 server/api/auth/session.get.ts create mode 100644 server/api/auth/session.post.ts create mode 100644 server/api/auth/signin.post.ts create mode 100644 server/api/auth/signout.post.ts create mode 100644 server/models/session.ts create mode 100644 server/models/user.ts create mode 100644 server/plugins/database.ts create mode 100644 server/utils/auth-validation.ts create mode 100644 server/utils/auth.ts create mode 100644 server/utils/backend-config.ts create mode 100644 server/utils/database.ts diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 27c6587..fc102bf 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -1,147 +1,74 @@ -import axios, { AxiosError } from "axios" - -let refreshPromise: Promise | null = null -let waitForPromise: boolean = false - -const setCookie = ( - name: string, - value: string, - options: { - maxAge?: number - path?: string - sameSite?: "lax" | "strict" | "none" - secure?: boolean - } = {}, -) => { - let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}` - - if (options.maxAge !== undefined) { - cookie += `; Max-Age=${options.maxAge}` - } - - cookie += `; Path=${options.path ?? "/"}` - cookie += `; SameSite=${options.sameSite ?? "Lax"}` - - if (options.secure) cookie += "; Secure" - - document.cookie = cookie +type SessionResponse = { + sessionToken: string + expireAt: string } export const useAuth = () => { - const refreshToken = useCookie("refreshToken", { - sameSite: "lax", - }) - const sessionToken = useCookie("sessionToken", { sameSite: "lax", - maxAge: 60 * 60, // 60 minutes + maxAge: 60 * 60, }) - const signin = async ( - email: string, - password: string, - remember: boolean = false, - ) => { + let refreshPromise: Promise | null = null + + const signin = async (email: string, password: string, remember = false) => { try { - const res = await cfetch( - "/auth/signin", - { - method: "POST", - data: { - email, - password, - }, - }, - false, - ) - - // NOTE: Quite hacky... this should be changed. - // TODO: Add httpOnly and update the backend to support that. - if (remember) { - setCookie("refreshToken", res.data.refreshToken, { - maxAge: 60 * 60 * 24 * 30, // 30 days - }) - } else { - setCookie("refreshToken", res.data.refreshToken) - } - //refreshToken.value = res.data.refreshToken - await refreshSessionToken() - - await navigateTo("/") - } catch (e) { - if (axios.isAxiosError(e) && e.response?.data?.message) { - throw new Error(e.response.data.message) - } - - throw new Error("Failed to sign in") - } - } - - const signup = async (name: string, email: string, password: string) => {} - - const signout = () => { - refreshToken.value = null - sessionToken.value = null - } - - const refreshSessionToken = async () => { - if (waitForPromise) { - // Force it to wait up to 50 ms for it to set refreshPromise, hopefully less - for (let i = 0; i < 10; i++) { - if (refreshPromise != null) { - break - } - - await new Promise((r) => setTimeout(r, 5)) - } - } - - waitForPromise = true - - if (refreshPromise != null) { - waitForPromise = false - return refreshPromise - } - - refreshPromise = cfetch( - "/auth/session", - { - data: { - refreshToken: refreshToken.value, - }, + await $fetch("/api/auth/signin", { method: "POST", - }, - false, - ) - .then((res) => { - const token = res.data.sessionToken - sessionToken.value = token - - refreshPromise = null - - return token + body: { email, password, remember: Boolean(remember) }, }) - .catch((e) => { - refreshPromise = null - sessionToken.value = null + if (!(await refreshSessionToken())) throw new Error("Unable to create session") + await navigateTo("/") + } catch (error: any) { + throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign in") + } + } + + const signup = async (name: string, email: string, password: string) => { + try { + await $fetch("/api/auth/register", { + method: "POST", + body: { name, email, password }, + }) + + if (!(await refreshSessionToken())) throw new Error("Unable to create session") + await navigateTo("/") + } catch (error: any) { + throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign up") + } + } + + const signout = async () => { + try { + await $fetch("/api/auth/signout", { method: "POST" }) + } finally { + sessionToken.value = null + } + } + + const refreshSessionToken = async (): Promise => { + if (refreshPromise) return refreshPromise + + refreshPromise = $fetch("/api/auth/session", { + method: "POST", + }) + .then((response) => { + sessionToken.value = response.sessionToken + return response.sessionToken + }) + .catch(() => { + sessionToken.value = null return null }) .finally(() => { - waitForPromise = false + refreshPromise = null }) return refreshPromise } - if (getCurrentInstance()) { - onMounted(() => { - if (refreshToken.value && !sessionToken.value) refreshSessionToken() - }) - } - return { - refreshToken, sessionToken, signin, signup, diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index 05464e1..0d4783f 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -1,6 +1,37 @@ -export default defineNuxtRouteMiddleware((to, from) => { - const auth = useAuth() +import { appendResponseHeader } from "h3" - if (!auth.refreshToken.value && !to.fullPath.includes("/auth")) - return navigateTo("/auth/signin") +export default defineNuxtRouteMiddleware(async (to) => { + if (to.path.startsWith("/auth")) return + + const auth = useAuth() + try { + // Forward the incoming SSR credentials to this endpoint; the browser sends + // same-origin cookies automatically during client navigation. + const requestHeaders = import.meta.server + ? useRequestHeaders(["cookie", "authorization"]) + : undefined + const response = await $fetch.raw<{ + authenticated: boolean + sessionToken?: string + }>("/api/auth/session", { headers: requestHeaders }) + const session = response._data + + // Nuxt forwards incoming request cookies automatically, but internal SSR + // fetch responses do not forward Set-Cookie to the browser automatically. + if (import.meta.server) { + const event = useRequestEvent() + if (event) { + for (const cookie of response.headers.getSetCookie()) { + appendResponseHeader(event, "set-cookie", cookie) + } + } + } + + if (session?.sessionToken) auth.sessionToken.value = session.sessionToken + if (session?.authenticated) return + } catch { + // Treat unavailable auth state as unauthenticated. + } + + return navigateTo("/auth/signin") }) diff --git a/app/pages/auth/signin.vue b/app/pages/auth/signin.vue index 47739bf..a3bab24 100644 --- a/app/pages/auth/signin.vue +++ b/app/pages/auth/signin.vue @@ -10,7 +10,7 @@ const submit = async (e: any) => { } catch {} try { - await auth.signin(data.email, data.password, data.remember) + await auth.signin(data.email, data.password, data.remember === "on") } catch (e: string | any) { toast.show(e, "error") } diff --git a/nuxt.config.ts b/nuxt.config.ts index 7044e13..e27f51b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,6 +4,14 @@ import { fileURLToPath } from "node:url" export default defineNuxtConfig({ compatibilityDate: "2025-07-15", devtools: { enabled: true }, + runtimeConfig: { + // Private server configuration. Matching NUXT_* variables override these + // defaults at runtime in the built Nitro server. Legacy variable names + // are resolved by server utilities at runtime, never during this build. + mongodbUri: "", + uploadDir: "", + tokenExpire: "", + }, modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"], components: [ { diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts new file mode 100644 index 0000000..9eaf46f --- /dev/null +++ b/server/api/auth/register.post.ts @@ -0,0 +1,51 @@ +import { readBody, setCookie, setResponseStatus } from "h3" +import argon2 from "argon2" +import { User } from "../../models/user" +import { REFRESH_COOKIE } from "../../utils/auth" +import { getAuthValidationMessage } from "../../utils/auth-validation" + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const validationMessage = getAuthValidationMessage(body, [ + { name: "name" }, + { name: "email", email: true }, + { name: "password", minimumLength: 8 }, + ]) + if (validationMessage) { + setResponseStatus(event, 400) + return { message: validationMessage } + } + + const email = body.email.toLowerCase() + if (await User.findOne({ email })) { + setResponseStatus(event, 400) + return { message: "The email is already in use" } + } + + try { + const user = await User.create({ + name: body.name, + email, + passwordHash: await argon2.hash(body.password), + }) + const refreshToken = await User.findById(user._id).select("+refreshToken").then((doc) => doc?.refreshToken) + + if (!refreshToken) throw new Error("Unable to create refresh token") + + setCookie(event, REFRESH_COOKIE, refreshToken, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: !import.meta.dev, + }) + setResponseStatus(event, 201) + // Refresh credentials are now delivered only through the HttpOnly cookie. + return {} + } catch (error) { + if ((error as { code?: number }).code === 11000) { + setResponseStatus(event, 400) + return { message: "The email is already in use" } + } + throw error + } +}) diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts new file mode 100644 index 0000000..ee9ef7d --- /dev/null +++ b/server/api/auth/session.get.ts @@ -0,0 +1,35 @@ +import { getCookie, setCookie } from "h3" +import { User } from "../../models/user" +import { getSessionModel } from "../../models/session" +import { + findAuthenticatedSession, + getSessionTtl, + REFRESH_COOKIE, + SESSION_COOKIE, +} from "../../utils/auth" + +export default defineEventHandler(async (event) => { + const activeSession = await findAuthenticatedSession(event) + if (activeSession) { + return { authenticated: true, sessionToken: activeSession.session.token } + } + + const refreshToken = getCookie(event, REFRESH_COOKIE) + if (!refreshToken) return { authenticated: false } + + const user = await User.findOne({ refreshToken }).select("+refreshToken +verified") + if (!user?.verified) return { authenticated: false } + + const ttl = getSessionTtl(event) + const Session = getSessionModel(ttl) + const session = await Session.create({ userId: user._id }) + setCookie(event, SESSION_COOKIE, session.token, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: !import.meta.dev, + maxAge: Math.floor(ttl / 1000), + }) + + return { authenticated: true, sessionToken: session.token } +}) diff --git a/server/api/auth/session.post.ts b/server/api/auth/session.post.ts new file mode 100644 index 0000000..e580f3d --- /dev/null +++ b/server/api/auth/session.post.ts @@ -0,0 +1,52 @@ +import { getCookie, readBody, setCookie, setResponseStatus } from "h3" +import { User } from "../../models/user" +import { getSessionModel } from "../../models/session" +import { + getSessionTtl, + REFRESH_COOKIE, + SESSION_COOKIE, +} from "../../utils/auth" +import { getAuthValidationMessage } from "../../utils/auth-validation" + +export default defineEventHandler(async (event) => { + const body = await readBody(event).catch(() => undefined) + const validationMessage = + body === undefined ? null : getAuthValidationMessage(body, [{ name: "refreshToken" }]) + if (validationMessage) { + setResponseStatus(event, 400) + return { message: validationMessage } + } + + const refreshToken = + (typeof body?.refreshToken === "string" && body.refreshToken) || + getCookie(event, REFRESH_COOKIE) + + if (!refreshToken) { + setResponseStatus(event, 401) + return { message: "Invalid refresh token" } + } + + const user = await User.findOne({ refreshToken }).select("+refreshToken") + if (!user) { + setResponseStatus(event, 401) + return { message: "Invalid refresh token" } + } + + const ttl = getSessionTtl(event) + const Session = getSessionModel(ttl) + const session = await Session.create({ userId: user._id }) + const expireAt = new Date(session.createdAt.getTime() + ttl) + + setCookie(event, SESSION_COOKIE, session.token, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: !import.meta.dev, + maxAge: Math.floor(ttl / 1000), + }) + setResponseStatus(event, 201) + + // The response token remains for unmigrated frontend calls that still send + // Authorization: Bearer. The HttpOnly cookie serves migrated Nitro routes. + return { sessionToken: session.token, expireAt } +}) diff --git a/server/api/auth/signin.post.ts b/server/api/auth/signin.post.ts new file mode 100644 index 0000000..f41b1cd --- /dev/null +++ b/server/api/auth/signin.post.ts @@ -0,0 +1,47 @@ +import { readBody, setCookie, setResponseStatus } from "h3" +import argon2 from "argon2" +import { User } from "../../models/user" +import { REFRESH_COOKIE } from "../../utils/auth" +import { getAuthValidationMessage } from "../../utils/auth-validation" + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const validationMessage = getAuthValidationMessage(body, [ + { name: "email", email: true }, + { name: "password", minimumLength: 8 }, + ]) + if (validationMessage) { + setResponseStatus(event, 400) + return { message: validationMessage } + } + + const user = await User.findOne({ email: body.email.toLowerCase() }).select( + "+refreshToken +passwordHash", + ) + let passwordMatches = false + if (user) { + try { + // Bun.password's default output is PHC Argon2id and argon2 verifies + // its encoded algorithm and parameters directly. + passwordMatches = await argon2.verify(user.passwordHash, body.password) + } catch { + // Malformed or unsupported stored hashes must not reveal account state. + } + } + + if (!user || !passwordMatches) { + setResponseStatus(event, 400) + return { message: "The email or password is wrong" } + } + + setCookie(event, REFRESH_COOKIE, user.refreshToken, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: !import.meta.dev, + ...(body.remember ? { maxAge: 60 * 60 * 24 * 30 } : {}), + }) + setResponseStatus(event, 200) + // The long-lived refresh token is deliberately omitted from the JSON body. + return {} +}) diff --git a/server/api/auth/signout.post.ts b/server/api/auth/signout.post.ts new file mode 100644 index 0000000..2f00685 --- /dev/null +++ b/server/api/auth/signout.post.ts @@ -0,0 +1,8 @@ +import { deleteCookie } from "h3" +import { REFRESH_COOKIE, SESSION_COOKIE } from "../../utils/auth" + +export default defineEventHandler((event) => { + deleteCookie(event, REFRESH_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev }) + deleteCookie(event, SESSION_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev }) + return { success: true } +}) diff --git a/server/models/session.ts b/server/models/session.ts new file mode 100644 index 0000000..294791b --- /dev/null +++ b/server/models/session.ts @@ -0,0 +1,19 @@ +import { randomUUID } from "node:crypto" +import mongoose, { Schema, type Model } from "mongoose" + +export function getSessionModel(sessionTtlMs: number) { + if (mongoose.models.Session) return mongoose.models.Session as Model + + const sessionSchema = new Schema({ + token: { type: String, default: () => randomUUID(), required: true }, + createdAt: { + type: Date, + default: Date.now, + index: { expires: Math.floor(sessionTtlMs / 1000) }, + required: true, + }, + userId: { type: Schema.Types.ObjectId, ref: "User", required: true }, + }) + + return mongoose.model("Session", sessionSchema) +} diff --git a/server/models/user.ts b/server/models/user.ts new file mode 100644 index 0000000..e575b12 --- /dev/null +++ b/server/models/user.ts @@ -0,0 +1,24 @@ +import { randomUUID } from "node:crypto" +import mongoose, { Schema, type Model } from "mongoose" + +const userSchema = new Schema( + { + name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + passwordHash: { type: String, required: true, select: false }, + role: { type: String, enum: ["user", "admin"], default: "user", required: true }, + refreshToken: { + type: String, + default: () => randomUUID(), + required: true, + select: false, + }, + verified: { type: Boolean, default: false, required: true, select: false }, + savedTracks: [{ type: Schema.Types.ObjectId, ref: "Track" }], + }, + { timestamps: true }, +) + +export const User = + (mongoose.models.User as Model | undefined) ?? + (mongoose.model("User", userSchema) as Model) diff --git a/server/plugins/database.ts b/server/plugins/database.ts new file mode 100644 index 0000000..b22a97a --- /dev/null +++ b/server/plugins/database.ts @@ -0,0 +1,13 @@ +import { connectToDatabase } from "../utils/database" +import { getBackendRuntimeConfig } from "../utils/backend-config" + +export default defineNitroPlugin(async () => { + const { mongodbUri } = getBackendRuntimeConfig() + + if (!mongodbUri) { + throw new Error("Missing required MongoDB configuration. Set NUXT_MONGODB_URI or MONGODB_URI.") + } + + await connectToDatabase(mongodbUri) + console.info("Database connected") +}) diff --git a/server/utils/auth-validation.ts b/server/utils/auth-validation.ts new file mode 100644 index 0000000..bced43d --- /dev/null +++ b/server/utils/auth-validation.ts @@ -0,0 +1,42 @@ +type AuthField = { + name: string + email?: boolean + minimumLength?: number +} + +function receivedType(value: unknown) { + if (value === null) return "null" + if (Array.isArray(value)) return "array" + return typeof value +} + +/** Match the first-issue messages returned by the legacy Zod validation hook. */ +export function getAuthValidationMessage(body: unknown, fields: AuthField[]) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return body === undefined ? "Required" : `Expected object, received ${receivedType(body)}` + } + + for (const field of fields) { + const value = (body as Record)[field.name] + if (typeof value !== "string") { + return value === undefined + ? "Required" + : `Expected string, received ${receivedType(value)}` + } + + if ( + field.email && + !/^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i.test( + value, + ) + ) { + return "Invalid email" + } + + if (field.minimumLength && value.length < field.minimumLength) { + return `String must contain at least ${field.minimumLength} character(s)` + } + } + + return null +} diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 0000000..48ffd99 --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,67 @@ +import type { H3Event } from "h3" +import { createError, getCookie } from "h3" +import parseDuration from "parse-duration" +import mongoose from "mongoose" +import { getSessionModel } from "../models/session" +import { getBackendRuntimeConfig } from "./backend-config" + +export const SESSION_COOKIE = "musicSession" +export const REFRESH_COOKIE = "refreshToken" + +export function getSessionTtl(event?: H3Event) { + const { tokenExpire } = getBackendRuntimeConfig(event) + return parseDuration(tokenExpire) || 60 * 60 * 1000 +} + +function getSessionTokens(event: H3Event) { + const tokens: string[] = [] + const cookieToken = getCookie(event, SESSION_COOKIE) + const authorization = event.node.req.headers.authorization + const bearerToken = authorization?.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : undefined + + if (cookieToken) tokens.push(cookieToken) + if (bearerToken && bearerToken !== cookieToken) tokens.push(bearerToken) + + return tokens +} + +export async function findSession(event: H3Event) { + const Session = getSessionModel(getSessionTtl(event)) as mongoose.Model + + for (const token of getSessionTokens(event)) { + const session = await Session.findOne({ token }) + .populate("userId", "+verified") + .exec() + + const user = session?.userId as unknown as + | { _id: unknown; verified?: boolean; role?: string } + | undefined + + if (!session || !user) continue + + return { session, user } + } + + return null +} + +export async function findAuthenticatedSession(event: H3Event, adminOnly = false) { + const auth = await findSession(event) + if (!auth?.user.verified || (adminOnly && auth.user.role !== "admin")) return null + return auth +} + +export async function requireAuthenticatedUser(event: H3Event, adminOnly = false) { + const auth = await findAuthenticatedSession(event, adminOnly) + if (!auth) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + data: { message: "Unauthorized" }, + }) + } + + return auth.user +} diff --git a/server/utils/backend-config.ts b/server/utils/backend-config.ts new file mode 100644 index 0000000..8806571 --- /dev/null +++ b/server/utils/backend-config.ts @@ -0,0 +1,16 @@ +import type { H3Event } from "h3" + +/** + * Read private backend settings at runtime. Nuxt's NUXT_* runtime overrides + * take precedence, while existing standalone-backend variable names remain + * usable during the migration. + */ +export function getBackendRuntimeConfig(event?: H3Event) { + const config = useRuntimeConfig(event) + + return { + mongodbUri: config.mongodbUri || process.env.MONGODB_URI || "", + uploadDir: config.uploadDir || process.env.UPLOAD_DIR || "", + tokenExpire: config.tokenExpire || process.env.TOKEN_EXPIRE || "1h", + } +} diff --git a/server/utils/database.ts b/server/utils/database.ts new file mode 100644 index 0000000..238fea6 --- /dev/null +++ b/server/utils/database.ts @@ -0,0 +1,48 @@ +import mongoose from "mongoose" + +let connectionPromise: Promise | undefined + +/** + * Connect to the application's shared Mongoose instance. + * Concurrent callers share the same connection attempt, and failed attempts + * can be retried after the underlying cause has been corrected. + */ +export function connectToDatabase(uri: string): Promise { + if (!uri) { + return Promise.reject(new Error("MongoDB runtime configuration is missing")) + } + + if (mongoose.connection.readyState === 1) { + return Promise.resolve(mongoose) + } + + if (connectionPromise) { + return connectionPromise + } + + if (mongoose.connection.readyState === 2) { + connectionPromise = mongoose.connection + .asPromise() + .then(() => mongoose) + .catch((error: unknown) => { + throw error + }) + .finally(() => { + connectionPromise = undefined + }) + + return connectionPromise + } + + connectionPromise = mongoose + .connect(uri) + .then(() => mongoose) + .catch((error: unknown) => { + throw new Error("MongoDB connection failed", { cause: error }) + }) + .finally(() => { + connectionPromise = undefined + }) + + return connectionPromise +}