feat: Remove backend cookie and use frontend for auth (#6601)

This commit is contained in:
Michael Genson
2025-11-28 19:29:16 -06:00
committed by GitHub
parent 8f1ce1a1c3
commit 07ecd88685
20 changed files with 72 additions and 172 deletions

View File

@@ -128,11 +128,8 @@ class AppSettings(AppLoggingSettings):
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
# Certain browsers (webkit) have issues with very long-lived cookies, so we limit to 400 days
return min(v, 400 * 24)
SECRET: str
SESSION_SECRET: str

View File

@@ -30,6 +30,7 @@ class AdminAboutController(BaseAdminController):
default_household=settings.DEFAULT_HOUSEHOLD,
allow_signup=settings.ALLOW_SIGNUP,
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
token_time=settings.TOKEN_TIME,
build_id=settings.GIT_COMMIT_HASH,
recipe_scraper_version=recipe_scraper_version.__version__,
enable_oidc=settings.OIDC_AUTH_ENABLED,

View File

@@ -44,6 +44,7 @@ def get_app_info(session: Session = Depends(generate_session)):
enable_openai=settings.OPENAI_ENABLED,
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
token_time=settings.TOKEN_TIME,
)

View File

@@ -1,4 +1,4 @@
from typing import Annotated, Literal
from typing import Annotated
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Header, Request, Response, status
@@ -54,59 +54,13 @@ 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, samesite: str | 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,
samesite=samesite,
)
@classmethod
def respond(cls, token: str, token_type: str = "bearer") -> dict:
return cls(access_token=token, token_type=token_type).model_dump()
def get_samesite(request: Request) -> Literal["lax", "none"]:
"""
Determine the appropriate samesite attribute for cookies.
`samesite="none"` is required for iframe support (i.e. embedding Mealie in another site)
but only works over HTTPS. If `samesite="none"` is set over HTTP, most browsers will reject the cookie.
`samesite="lax"` is the default, which works regardless of HTTP or HTTPS,
but does not support hosting in iframes.
"""
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
is_https = request.url.scheme == "https" or forwarded_proto == "https"
if is_https and settings.PRODUCTION:
return "none"
else:
# TODO: remove this once we resolve pending iframe issues
if settings.PRODUCTION:
logger.debug("Setting samesite to 'lax' because connection is not HTTPS")
logger.debug(f"{request.url.scheme=} | {forwarded_proto=}")
return "lax"
@public_router.post("/token")
def get_token(
request: Request,
response: Response,
data: CredentialsRequestForm = Depends(),
session: Session = Depends(generate_session),
):
def get_token(request: Request, data: CredentialsRequestForm = Depends(), session: Session = Depends(generate_session)):
if "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"]
if "," in ip: # if there are multiple IPs, the first one is canonically the true client
@@ -128,15 +82,7 @@ def get_token(
status_code=status.HTTP_401_UNAUTHORIZED,
)
access_token, duration = auth
expires_in = duration.total_seconds() if duration else None
MealieAuthToken.set_cookie(
response,
access_token,
expires_in=expires_in,
samesite=get_samesite(request),
)
access_token, _ = auth
return MealieAuthToken.respond(access_token)
@@ -160,7 +106,7 @@ async def oauth_login(request: Request):
@public_router.get("/oauth/callback")
async def oauth_callback(request: Request, response: Response, session: Session = Depends(generate_session)):
async def oauth_callback(request: Request, session: Session = Depends(generate_session)):
if not oauth:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -186,15 +132,7 @@ 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
expires_in = duration.total_seconds() if duration else None
MealieAuthToken.set_cookie(
response,
access_token,
expires_in=expires_in,
samesite=get_samesite(request),
)
access_token, _ = auth
return MealieAuthToken.respond(access_token)

View File

@@ -23,6 +23,7 @@ class AppInfo(MealieModel):
oidc_provider_name: str
enable_openai: bool
enable_openai_image_services: bool
token_time: int
class AppTheme(MealieModel):