diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 6527ffa1d..3dae10323 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -1,6 +1,6 @@ import { defineNuxtConfig } from "nuxt/config"; -const AUTH_TOKEN = "mealie.auth.token"; +const AUTH_TOKEN = "mealie.access_token"; export default defineNuxtConfig({ // Global page headers: https://go.nuxtjs.dev/config-head @@ -142,7 +142,6 @@ export default defineNuxtConfig({ signInResponseTokenPointer: "/access_token", type: "Bearer", cookieName: AUTH_TOKEN, - maxAgeInSeconds: parseInt(process.env.TOKEN_TIME || "48") * 3600, // TOKEN_TIME is in hours }, pages: { login: "/login", diff --git a/frontend/plugins/axios.ts b/frontend/plugins/axios.ts index cc232c833..4214e5356 100644 --- a/frontend/plugins/axios.ts +++ b/frontend/plugins/axios.ts @@ -30,7 +30,23 @@ export default defineNuxtPlugin(() => { return response; }, (error) => { - if (error?.response?.data?.detail?.message) alert.error(error.response.data.detail.message as string); + if (error?.response?.data?.detail?.message) { + alert.error(error.response.data.detail.message as string); + }; + + // If we receive a 401 Unauthorized response, clear the token cookie and redirect to login + if (error?.response?.status === 401) { + // If tokenCookie is not set, we may just be an unauthenticated user using the wrong API, so don't redirect + const tokenCookie = useCookie(tokenName); + if (tokenCookie.value) { + tokenCookie.value = null; + + // Disable beforeunload warnings to prevent "Are you sure you want to leave?" popups + window.onbeforeunload = null; + window.location.href = "/login"; + } + } + return Promise.reject(error); }, ); diff --git a/mealie/core/security/providers/auth_provider.py b/mealie/core/security/providers/auth_provider.py index d6dc84128..81a96c4dc 100644 --- a/mealie/core/security/providers/auth_provider.py +++ b/mealie/core/security/providers/auth_provider.py @@ -30,8 +30,8 @@ class AuthProvider[T](metaclass=abc.ABCMeta): settings = get_app_settings() duration = timedelta(hours=settings.TOKEN_TIME) - if remember_me and remember_me_duration > duration: - duration = remember_me_duration + if remember_me: + duration = max(remember_me_duration, duration) return AuthProvider.create_access_token({"sub": str(user.id)}, duration) diff --git a/mealie/core/settings/settings.py b/mealie/core/settings/settings.py index 10bfb6aeb..91238e8ca 100644 --- a/mealie/core/settings/settings.py +++ b/mealie/core/settings/settings.py @@ -123,6 +123,17 @@ class AppSettings(AppLoggingSettings): TOKEN_TIME: int = 48 """time in hours""" + @field_validator("TOKEN_TIME") + @classmethod + def validate_token_time(cls, v: int) -> int: + if v < 1: + raise ValueError("TOKEN_TIME must be at least 1 hour") + # If TOKEN_TIME is unreasonably high (e.g. hundreds of years), JWT encoding + # can overflow, so we set the max to 10 years (87600 hours). + if v > 87600: + raise ValueError("TOKEN_TIME is too high; maximum is 87600 hours (10 years)") + return v + SECRET: str SESSION_SECRET: str diff --git a/mealie/routes/auth/auth.py b/mealie/routes/auth/auth.py index 73025933e..565088341 100644 --- a/mealie/routes/auth/auth.py +++ b/mealie/routes/auth/auth.py @@ -1,5 +1,3 @@ -from datetime import timedelta - from authlib.integrations.starlette_client import OAuth from fastapi import APIRouter, Depends, Request, Response, status from fastapi.exceptions import HTTPException @@ -25,7 +23,6 @@ public_router = APIRouter(tags=["Users: Authentication"]) user_router = UserAPIRouter(tags=["Users: Authentication"]) logger = root_logger.get_logger("auth") -remember_me_duration = timedelta(days=14) settings = get_app_settings() if settings.OIDC_READY: @@ -54,6 +51,19 @@ class MealieAuthToken(BaseModel): access_token: str token_type: str = "bearer" + @classmethod + def set_cookie(cls, response: Response, token: str, expires_in: int | float | None = None): + expires_in = int(expires_in) if expires_in else None + + # httponly=False to allow JS access for frontend + response.set_cookie( + key="mealie.access_token", + value=token, + httponly=False, + max_age=expires_in, + secure=settings.PRODUCTION, + ) + @classmethod def respond(cls, token: str, token_type: str = "bearer") -> dict: return cls(access_token=token, token_type=token_type).model_dump() @@ -86,17 +96,11 @@ def get_token( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, ) + access_token, duration = auth - expires_in = duration.total_seconds() if duration else None - response.set_cookie( - key="mealie.access_token", - value=access_token, - httponly=True, - max_age=expires_in, - secure=settings.PRODUCTION, - ) + MealieAuthToken.set_cookie(response, access_token, expires_in) return MealieAuthToken.respond(access_token) @@ -145,18 +149,11 @@ async def oauth_callback(request: Request, response: Response, session: Session if not auth: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - access_token, duration = auth + access_token, duration = auth expires_in = duration.total_seconds() if duration else None - response.set_cookie( - key="mealie.access_token", - value=access_token, - httponly=True, - max_age=expires_in, - secure=settings.PRODUCTION, - ) - + MealieAuthToken.set_cookie(response, access_token, expires_in) return MealieAuthToken.respond(access_token)