diff --git a/app/composables/useApiFetch.ts b/app/composables/useApiFetch.ts index b438f04..5f46a03 100644 --- a/app/composables/useApiFetch.ts +++ b/app/composables/useApiFetch.ts @@ -6,7 +6,7 @@ export function useApiFetch( options?: UseFetchOptions, authorize: boolean = true, ): ReturnType> { - const { sessionToken, refreshSessionToken } = useAuth() + const { refreshSession } = useAuth() const defaults: UseFetchOptions = { // The migrated API lives in this Nuxt app under server/api. @@ -16,19 +16,23 @@ export function useApiFetch( retryDelay: 0, retryStatusCodes: [401], - headers: - authorize && sessionToken.value - ? { Authorization: `Bearer ${sessionToken.value}` } - : {}, + headers: {}, onResponseError: async ({ request, response, options }) => { if (response.status == 401 && authorize) { - const newToken = await refreshSessionToken() + const refreshed = await refreshSession() - if (!newToken) return + if (!refreshed.ok) return - const headers = new Headers(options.headers as HeadersInit) - headers.set("Authorization", `Bearer ${newToken}`) - options.headers = headers + if (import.meta.server && refreshed.sessionCookie) { + const headers = new Headers(options.headers as HeadersInit) + const cookies = (headers.get("cookie") ?? "") + .split(";") + .map((cookie) => cookie.trim()) + .filter((cookie) => cookie && !cookie.startsWith("musicSession=")) + cookies.push(refreshed.sessionCookie) + headers.set("cookie", cookies.join("; ")) + options.headers = headers + } } }, } diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 7162c96..f8ac9ff 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -1,17 +1,11 @@ import { appendResponseHeader } from "h3" -type SessionResponse = { - sessionToken: string - expireAt: string -} +type SessionRefresh = { ok: boolean; sessionCookie?: string } +const refreshPromises = new WeakMap>() export const useAuth = () => { - const sessionToken = useCookie("sessionToken", { - sameSite: "lax", - maxAge: 60 * 60, - }) - - let refreshPromise: Promise | null = null + const nuxtApp = useNuxtApp() + const authenticated = useState("auth:authenticated", () => false) const signin = async (email: string, password: string, remember = false) => { try { @@ -20,7 +14,7 @@ export const useAuth = () => { body: { email, password, remember: Boolean(remember) }, }) - if (!(await refreshSessionToken())) throw new Error("Unable to create session") + if (!(await refreshSession()).ok) throw new Error("Unable to create session") await navigateTo("/") } catch (error: any) { throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign in") @@ -34,7 +28,7 @@ export const useAuth = () => { body: { name, email, password }, }) - if (!(await refreshSessionToken())) throw new Error("Unable to create session") + if (!(await refreshSession()).ok) throw new Error("Unable to create session") await navigateTo("/") } catch (error: any) { throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign up") @@ -45,55 +39,64 @@ export const useAuth = () => { try { await $fetch("/api/auth/signout", { method: "POST" }) } finally { - sessionToken.value = null + authenticated.value = false } } - const refreshSessionToken = async (): Promise => { - if (refreshPromise) return refreshPromise + const refreshSession = async (): Promise => { + const inFlight = refreshPromises.get(nuxtApp) + if (inFlight) return inFlight - refreshPromise = (async () => { - const response = await $fetch.raw( + const refreshPromise = (async () => { + const response = await $fetch.raw<{ expireAt: string }>( "/api/auth/session", { method: "POST", headers: import.meta.server - ? useRequestHeaders(["cookie", "authorization"]) + ? useRequestHeaders(["cookie"]) : undefined, }, ) + let sessionCookie: string | undefined + const setCookies = import.meta.server ? response.headers.getSetCookie() : [] + for (const cookie of setCookies) { + if (!cookie.startsWith("musicSession=")) continue + sessionCookie = cookie.split(";", 1)[0] + } + if (import.meta.server) { const event = useRequestEvent() if (event) { - for (const cookie of response.headers.getSetCookie()) { + for (const cookie of setCookies) { appendResponseHeader(event, "set-cookie", cookie) } } } - return response._data! + return { ok: true, sessionCookie } })() - .then((response) => { - sessionToken.value = response.sessionToken - return response.sessionToken + .then((result) => { + authenticated.value = true + return result }) .catch(() => { - sessionToken.value = null - return null + authenticated.value = false + return { ok: false } }) .finally(() => { - refreshPromise = null + refreshPromises.delete(nuxtApp) }) + refreshPromises.set(nuxtApp, refreshPromise) return refreshPromise } return { - sessionToken, + authenticated, signin, signup, signout, - refreshSessionToken, + refreshSession, } } diff --git a/app/middleware/auth.global.ts b/app/middleware/auth.global.ts index 0d4783f..209750f 100644 --- a/app/middleware/auth.global.ts +++ b/app/middleware/auth.global.ts @@ -4,6 +4,8 @@ export default defineNuxtRouteMiddleware(async (to) => { if (to.path.startsWith("/auth")) return const auth = useAuth() + if (import.meta.client && auth.authenticated.value) return + try { // Forward the incoming SSR credentials to this endpoint; the browser sends // same-origin cookies automatically during client navigation. @@ -12,7 +14,6 @@ export default defineNuxtRouteMiddleware(async (to) => { : undefined const response = await $fetch.raw<{ authenticated: boolean - sessionToken?: string }>("/api/auth/session", { headers: requestHeaders }) const session = response._data @@ -27,8 +28,10 @@ export default defineNuxtRouteMiddleware(async (to) => { } } - if (session?.sessionToken) auth.sessionToken.value = session.sessionToken - if (session?.authenticated) return + if (session?.authenticated) { + auth.authenticated.value = true + return + } } catch { // Treat unavailable auth state as unauthenticated. } diff --git a/app/utils/cfetch.ts b/app/utils/cfetch.ts index 19cd085..6342481 100644 --- a/app/utils/cfetch.ts +++ b/app/utils/cfetch.ts @@ -60,23 +60,23 @@ export const cfetch = async ( headers.delete("Content-Type") } - if (authorize) { - const { sessionToken, refreshSessionToken } = useAuth() - let token = sessionToken.value - - if (!token) token = await refreshSessionToken() - if (token) headers.set("Authorization", `Bearer ${token}`) - } - try { return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT } catch (error: any) { if (!authorize || error?.response?.status !== 401) throw error - const token = await useAuth().refreshSessionToken() - if (!token) throw error + const refreshed = await useAuth().refreshSession() + if (!refreshed.ok) throw error + + if (import.meta.server && refreshed.sessionCookie) { + const cookies = (headers.get("cookie") ?? "") + .split(";") + .map((cookie) => cookie.trim()) + .filter((cookie) => cookie && !cookie.startsWith("musicSession=")) + cookies.push(refreshed.sessionCookie) + headers.set("cookie", cookies.join("; ")) + } - headers.set("Authorization", `Bearer ${token}`) return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT } } diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts index ee9ef7d..ec978a9 100644 --- a/server/api/auth/session.get.ts +++ b/server/api/auth/session.get.ts @@ -10,9 +10,7 @@ import { export default defineEventHandler(async (event) => { const activeSession = await findAuthenticatedSession(event) - if (activeSession) { - return { authenticated: true, sessionToken: activeSession.session.token } - } + if (activeSession) return { authenticated: true } const refreshToken = getCookie(event, REFRESH_COOKIE) if (!refreshToken) return { authenticated: false } @@ -31,5 +29,5 @@ export default defineEventHandler(async (event) => { maxAge: Math.floor(ttl / 1000), }) - return { authenticated: true, sessionToken: session.token } + return { authenticated: true } }) diff --git a/server/api/auth/session.post.ts b/server/api/auth/session.post.ts index a17b376..b56e89d 100644 --- a/server/api/auth/session.post.ts +++ b/server/api/auth/session.post.ts @@ -49,7 +49,5 @@ export default defineAuthenticatedEventHandler(async (event) => { }) 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 } + return { expireAt } }) diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 821989f..09e7443 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -79,46 +79,19 @@ export function getSessionTtl(event?: H3Event) { return parseDuration(tokenExpire) || 60 * 60 * 1000 } -function getSessionTokens(event: H3Event) { - const tokens: { token: string; source: "cookie" | "bearer" }[] = [] - 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({ token: cookieToken, source: "cookie" }) - if (bearerToken && bearerToken !== cookieToken) { - tokens.push({ token: bearerToken, source: "bearer" }) - } - - return tokens -} - export async function findSession(event: H3Event) { const Session = getSessionModel(getSessionTtl(event)) as mongoose.Model + const token = getCookie(event, SESSION_COOKIE) + if (!token) return null - for (const credential of getSessionTokens(event)) { - const session = await Session.findOne({ token: credential.token }) - .populate("userId", "+verified") - .exec() + const session = await Session.findOne({ token }) + .populate("userId", "+verified") + .exec() + const user = session?.userId as unknown as + | { _id: unknown; verified?: boolean; role?: string } + | undefined - const user = session?.userId as unknown as - | { _id: unknown; verified?: boolean; role?: string } - | undefined - - if (!session || !user) continue - - const bearerToken = event.node.req.headers.authorization?.startsWith("Bearer ") - ? event.node.req.headers.authorization.slice("Bearer ".length) - : undefined - // If the cookie and bearer header carry the same valid token, the request - // is independently authenticated by the explicit bearer credential. - const source = bearerToken === credential.token ? "bearer" : credential.source - return { session, user, source } - } - - return null + return session && user ? { session, user } : null } export async function findAuthenticatedSession(event: H3Event, adminOnly = false) { @@ -139,9 +112,7 @@ export async function requireAuthenticatedUser(event: H3Event, adminOnly = false const method = event.node.req.method?.toUpperCase() || "GET" const isUnsafeMethod = !["GET", "HEAD", "OPTIONS"].includes(method) - if (isUnsafeMethod && auth.source === "cookie") { - assertCookieRequestOrigin(event) - } + if (isUnsafeMethod) assertCookieRequestOrigin(event) return auth.user } diff --git a/server/utils/openapi.ts b/server/utils/openapi.ts index a184609..80c8fa4 100644 --- a/server/utils/openapi.ts +++ b/server/utils/openapi.ts @@ -17,8 +17,8 @@ const operations: ApiOperation[] = [ { method: "get", path: "/health", tag: "Health", summary: "Health check", response: "Health" }, { method: "post", path: "/auth/register", tag: "Auth", summary: "Register a user", body: "json", response: "EmptyObject", status: 201 }, { method: "post", path: "/auth/signin", tag: "Auth", summary: "Sign in", body: "json", response: "EmptyObject" }, - { method: "post", path: "/auth/session", tag: "Auth", summary: "Create a session", body: "json", response: "Session", status: 201 }, - { method: "get", path: "/auth/session", tag: "Auth", summary: "Get or renew the current session", response: "SessionStatus" }, + { method: "post", path: "/auth/session", tag: "Auth", summary: "Renew the current session", body: "json", response: "Session", status: 201 }, + { method: "get", path: "/auth/session", tag: "Auth", summary: "Check the current session", response: "SessionStatus" }, { method: "post", path: "/auth/signout", tag: "Auth", summary: "Sign out", response: "Signout" }, { method: "get", path: "/artists", tag: "Artist", summary: "Get artists by ID", auth: true, query: ["ids"], response: "ArtistList" }, { method: "get", path: "/artists/{id}", tag: "Artist", summary: "Get an artist", auth: true, response: "ArtistOrNull" }, @@ -140,7 +140,7 @@ function operationSpec(operation: ApiOperation) { return { tags: [operation.tag], summary: operation.summary, - ...(operation.auth || operation.admin ? { security: [{ Bearer: [] }, { SessionCookie: [] }] } : {}), + ...(operation.auth || operation.admin ? { security: [{ SessionCookie: [] }] } : {}), ...(parameters.length ? { parameters } : {}), ...(operation.body ? { @@ -174,7 +174,6 @@ export const openApiDocument = { paths, components: { securitySchemes: { - Bearer: { type: "http", scheme: "bearer" }, SessionCookie: { type: "apiKey", in: "cookie", name: "musicSession" }, }, schemas: { @@ -232,8 +231,8 @@ export const openApiDocument = { }, }, User: { type: "object", properties: { _id: { type: "string" }, name: { type: "string" }, email: { type: "string", format: "email" }, role: { type: "string" }, verified: { type: "boolean" }, savedTracks: { type: "array", items: { type: "string", description: "MongoDB ObjectId" } } } }, - Session: { type: "object", properties: { sessionToken: { type: "string" }, expireAt: { type: "string", format: "date-time" } } }, - SessionStatus: { type: "object", properties: { authenticated: { type: "boolean" }, sessionToken: { type: "string" } } }, + Session: { type: "object", properties: { expireAt: { type: "string", format: "date-time" } } }, + SessionStatus: { type: "object", properties: { authenticated: { type: "boolean" } } }, SearchResults: { type: "object", properties: { tracks: { type: "array", items: ref("Track") }, albums: { type: "array", items: ref("Album") }, artists: { type: "array", items: ref("Artist") } } }, ArtistList: { type: "array", items: ref("Artist") }, GenreList: { type: "array", items: ref("Genre") },