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

@@ -4,22 +4,22 @@
### General ### General
| Variables | Default | Description | | Variables | Default | Description |
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- | | ----------------------------- | :-------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PUID | 911 | UserID permissions between host OS and container | | PUID | 911 | UserID permissions between host OS and container |
| PGID | 911 | GroupID permissions between host OS and container | | PGID | 911 | GroupID permissions between host OS and container |
| DEFAULT_GROUP | Home | The default group for users | | DEFAULT_GROUP | Home | The default group for users |
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group | | DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
| BASE_URL | http://localhost:8080 | Used for Notifications | | BASE_URL | http://localhost:8080 | Used for Notifications |
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 87600 (10 years, in hours). | | TOKEN_TIME | 48 | The time in hours that a login/auth token is valid. Must be <= 9600 (400 days, in hours). |
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** | | API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
| API_DOCS | True | Turns on/off access to the API documentation locally | | API_DOCS | True | Turns on/off access to the API documentation locally |
| TZ | UTC | Must be set to get correct date/time on the server | | TZ | UTC | Must be set to get correct date/time on the server |
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token | | ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
| ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication | | ALLOW_PASSWORD_LOGIN | true | Whether or not to display the username+password input fields. Keep set to true unless you use OIDC authentication |
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path | | LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) | | LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC | | DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application. <super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.

View File

@@ -196,7 +196,7 @@ import { VueDraggable } from "vue-draggable-plus";
import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe"; import type { IngredientFood, IngredientUnit, ParsedIngredient, RecipeIngredient } from "~/lib/api/types/recipe";
import type { Parser } from "~/lib/api/user/recipes/recipe"; import type { Parser } from "~/lib/api/user/recipes/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useAppInfo, useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { parseIngredientText } from "~/composables/recipes"; import { parseIngredientText } from "~/composables/recipes";
import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store"; import { useFoodData, useFoodStore, useUnitData, useUnitStore } from "~/composables/store";
import { useGlobalI18n } from "~/composables/use-global-i18n"; import { useGlobalI18n } from "~/composables/use-global-i18n";
@@ -213,9 +213,9 @@ const emit = defineEmits<{
(e: "save", value: NoUndefinedField<RecipeIngredient[]>): void; (e: "save", value: NoUndefinedField<RecipeIngredient[]>): void;
}>(); }>();
const { $appInfo } = useNuxtApp();
const i18n = useGlobalI18n(); const i18n = useGlobalI18n();
const api = useUserApi(); const api = useUserApi();
const appInfo = useAppInfo();
const drag = ref(false); const drag = ref(false);
const unitStore = useUnitStore(); const unitStore = useUnitStore();
@@ -238,7 +238,7 @@ const availableParsers = computed(() => {
{ {
text: i18n.t("recipe.parser.openai-parser"), text: i18n.t("recipe.parser.openai-parser"),
value: "openai", value: "openai",
hide: !appInfo.value?.enableOpenai, hide: !$appInfo.enableOpenai,
}, },
]; ];
}); });

View File

@@ -97,7 +97,6 @@
<script lang="ts"> <script lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SideBarLink } from "~/types/application-types"; import type { SideBarLink } from "~/types/application-types";
import { useAppInfo } from "~/composables/api";
import { useCookbookPreferences } from "~/composables/use-users/preferences"; import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store"; import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
import type { ReadCookBook } from "~/lib/api/types/cookbook"; import type { ReadCookBook } from "~/lib/api/types/cookbook";
@@ -105,7 +104,7 @@ import type { ReadCookBook } from "~/lib/api/types/cookbook";
export default defineNuxtComponent({ export default defineNuxtComponent({
setup() { setup() {
const i18n = useI18n(); const i18n = useI18n();
const { $globals } = useNuxtApp(); const { $appInfo, $globals } = useNuxtApp();
const display = useDisplay(); const display = useDisplay();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
@@ -135,9 +134,7 @@ export default defineNuxtComponent({
return []; return [];
}); });
const appInfo = useAppInfo(); const showImageImport = computed(() => $appInfo.enableOpenaiImageServices);
const showImageImport = computed(() => appInfo.value?.enableOpenaiImageServices);
const languageDialog = ref<boolean>(false); const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean>(false); const sidebar = ref<boolean>(false);

View File

@@ -1,3 +1,2 @@
export { useAppInfo } from "./use-app-info";
export { useStaticRoutes } from "./static-routes"; export { useStaticRoutes } from "./static-routes";
export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client"; export { useAdminApi, usePublicApi, usePublicExploreApi, useUserApi } from "./api-client";

View File

@@ -1,14 +0,0 @@
import type { AppInfo } from "~/lib/api/types/admin";
export function useAppInfo(): Ref<AppInfo | null> {
const i18n = useI18n();
const { $axios } = useNuxtApp();
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
const { data: appInfo } = useAsyncData("app-info", async () => {
const data = await $axios.get<AppInfo>("/api/app/about");
return data.data;
});
return appInfo;
}

View File

@@ -5,9 +5,9 @@ const userRatings = ref<UserRatingSummary[]>([]);
const loading = ref(false); const loading = ref(false);
const ready = ref(false); const ready = ref(false);
const $auth = useMealieAuth();
export const useUserSelfRatings = function () { export const useUserSelfRatings = function () {
const $auth = useMealieAuth();
async function refreshUserRatings() { async function refreshUserRatings() {
if (!$auth.user.value || loading.value) { if (!$auth.user.value || loading.value) {
return; return;

View File

@@ -23,13 +23,15 @@ const authUser = ref<UserOut | null>(null);
const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading"); const authStatus = ref<"loading" | "authenticated" | "unauthenticated">("loading");
export const useAuthBackend = function (): AuthState { export const useAuthBackend = function (): AuthState {
const { $axios } = useNuxtApp(); const { $appInfo, $axios } = useNuxtApp();
const router = useRouter(); const router = useRouter();
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const tokenTimeHours = Number(runtimeConfig.public.TOKEN_TIME) || 48;
const tokenName = runtimeConfig.public.AUTH_TOKEN; const tokenName = runtimeConfig.public.AUTH_TOKEN;
const tokenCookie = useCookie(tokenName, { maxAge: tokenTimeHours * 60 * 60 }); const tokenCookie = useCookie(tokenName, {
maxAge: $appInfo.tokenTime * 60 * 60,
secure: $appInfo.production && window?.location?.protocol === "https:",
});
function setToken(token: string | null) { function setToken(token: string | null) {
tokenCookie.value = token; tokenCookie.value = token;

View File

@@ -3,11 +3,11 @@
<TheSnackbar /> <TheSnackbar />
<v-banner <v-banner
v-if="isDemo" v-if="$appInfo.demoStatus"
sticky sticky
> >
<div class="text-center"> <div class="text-center">
<b> {{ $t("demo.info_message_with_version", { version: version }) }} </b> <b> {{ $t("demo.info_message_with_version", { version: $appInfo.version }) }} </b>
</div> </div>
</v-banner> </v-banner>
@@ -23,24 +23,8 @@
<script lang="ts"> <script lang="ts">
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue"; import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import { useAppInfo } from "~/composables/api";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({ export default defineNuxtComponent({
components: { TheSnackbar }, components: { TheSnackbar },
setup() {
const appInfo = useAppInfo();
const isDemo = computed(() => appInfo?.value?.demoStatus || false);
const i18n = useGlobalI18n();
const version = computed(() => appInfo?.value?.version || i18n.t("about.unknown-version"));
return {
appInfo,
isDemo,
version,
};
},
}); });
</script> </script>

View File

@@ -18,6 +18,7 @@ export interface AdminAboutInfo {
oidcProviderName: string; oidcProviderName: string;
enableOpenai: boolean; enableOpenai: boolean;
enableOpenaiImageServices: boolean; enableOpenaiImageServices: boolean;
tokenTime: number;
versionLatest: string; versionLatest: string;
apiPort: number; apiPort: number;
apiDocs: boolean; apiDocs: boolean;
@@ -50,6 +51,7 @@ export interface AppInfo {
oidcProviderName: string; oidcProviderName: string;
enableOpenai: boolean; enableOpenai: boolean;
enableOpenaiImageServices: boolean; enableOpenaiImageServices: boolean;
tokenTime: number;
} }
export interface AppStartupInfo { export interface AppStartupInfo {
isFirstLogin: boolean; isFirstLogin: boolean;

View File

@@ -72,7 +72,6 @@ export default defineNuxtConfig({
apiUrl: process.env.API_URL || "http://localhost:9000", apiUrl: process.env.API_URL || "http://localhost:9000",
public: { public: {
AUTH_TOKEN, AUTH_TOKEN,
TOKEN_TIME: process.env.TOKEN_TIME || "48",
GLOBAL_MIDDLEWARE: process.env.GLOBAL_MIDDLEWARE || undefined, GLOBAL_MIDDLEWARE: process.env.GLOBAL_MIDDLEWARE || undefined,
SUB_PATH: process.env.SUB_PATH || "", SUB_PATH: process.env.SUB_PATH || "",
// ============================================== // ==============================================

View File

@@ -43,7 +43,6 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { useAppInfo } from "~/composables/api";
import type { MenuItem } from "~/components/global/BaseOverflowButton.vue"; import type { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import AdvancedOnly from "~/components/global/AdvancedOnly.vue"; import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
@@ -53,15 +52,12 @@ export default defineNuxtComponent({
setup() { setup() {
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const $globals = useNuxtApp().$globals; const { $appInfo, $globals } = useNuxtApp();
useSeoMeta({ useSeoMeta({
title: i18n.t("general.create"), title: i18n.t("general.create"),
}); });
const appInfo = useAppInfo();
const enableOpenAIImages = computed(() => appInfo.value?.enableOpenaiImageServices);
const subpages = computed<MenuItem[]>(() => [ const subpages = computed<MenuItem[]>(() => [
{ {
icon: $globals.icons.link, icon: $globals.icons.link,
@@ -82,7 +78,7 @@ export default defineNuxtComponent({
icon: $globals.icons.fileImage, icon: $globals.icons.fileImage,
text: i18n.t("recipe.create-from-images"), text: i18n.t("recipe.create-from-images"),
value: "image", value: "image",
hide: !enableOpenAIImages.value, hide: !$appInfo.enableOpenaiImageServices,
}, },
{ {
icon: $globals.icons.edit, icon: $globals.icons.edit,

View File

@@ -25,7 +25,7 @@
persistent-hint persistent-hint
/> />
</v-card-text> </v-card-text>
<v-card-text v-if="appInfo && appInfo.enableOpenai"> <v-card-text v-if="$appInfo.enableOpenai">
{{ $t('recipe.recipe-debugger-use-openai-description') }} {{ $t('recipe.recipe-debugger-use-openai-description') }}
<v-checkbox <v-checkbox
v-model="useOpenAI" v-model="useOpenAI"
@@ -68,7 +68,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { useAppInfo, useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import type { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
@@ -83,7 +83,6 @@ export default defineNuxtComponent({
const api = useUserApi(); const api = useUserApi();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const appInfo = useAppInfo();
const recipeUrl = computed({ const recipeUrl = computed({
set(recipe_import_url: string | null) { set(recipe_import_url: string | null) {
@@ -115,7 +114,6 @@ export default defineNuxtComponent({
} }
return { return {
appInfo,
recipeUrl, recipeUrl,
debugTreeView, debugTreeView,
debugUrl, debugUrl,

View File

@@ -59,7 +59,7 @@
<v-card-text class="w-100"> <v-card-text class="w-100">
<v-form @submit.prevent="authenticate"> <v-form @submit.prevent="authenticate">
<v-text-field <v-text-field
v-if="allowPasswordLogin" v-if="$appInfo.allowPasswordLogin"
v-model="form.email" v-model="form.email"
:prepend-inner-icon="$globals.icons.email" :prepend-inner-icon="$globals.icons.email"
variant="solo-filled" variant="solo-filled"
@@ -72,7 +72,7 @@
type="text" type="text"
/> />
<v-text-field <v-text-field
v-if="allowPasswordLogin" v-if="$appInfo.allowPasswordLogin"
id="password" id="password"
v-model="form.password" v-model="form.password"
:prepend-inner-icon="$globals.icons.lock" :prepend-inner-icon="$globals.icons.lock"
@@ -86,12 +86,12 @@
@click:append-inner="togglePasswordShow" @click:append-inner="togglePasswordShow"
/> />
<v-checkbox <v-checkbox
v-if="allowPasswordLogin" v-if="$appInfo.allowPasswordLogin"
v-model="form.remember" v-model="form.remember"
class="ml-2 mt-n2" class="ml-2 mt-n2"
:label="$t('user.remember-me')" :label="$t('user.remember-me')"
/> />
<v-card-actions v-if="allowPasswordLogin" class="justify-center pt-0"> <v-card-actions v-if="$appInfo.allowPasswordLogin" class="justify-center pt-0">
<div class="max-button"> <div class="max-button">
<v-btn <v-btn
:loading="loggingIn" :loading="loggingIn"
@@ -110,7 +110,7 @@
</v-card-actions> </v-card-actions>
<div <div
v-if="appInfoLoaded && allowOidc && allowPasswordLogin" v-if="$appInfo.enableOidc && $appInfo.allowPasswordLogin"
class="d-flex my-4 justify-center align-center" class="d-flex my-4 justify-center align-center"
width="80%" width="80%"
> >
@@ -126,7 +126,7 @@
</span> </span>
</div> </div>
<v-card-actions <v-card-actions
v-if="appInfoLoaded && allowOidc" v-if="$appInfo.enableOidc"
class="justify-center" class="justify-center"
> >
<div class="max-button"> <div class="max-button">
@@ -140,7 +140,7 @@
block block
@click="() => oidcAuthenticate()" @click="() => oidcAuthenticate()"
> >
{{ $t("user.login-oidc") }} {{ oidcProviderName }} {{ $t("user.login-oidc") }} {{ $appInfo.oidcProviderName }}
</v-btn> </v-btn>
</div> </div>
</v-card-actions> </v-card-actions>
@@ -148,7 +148,7 @@
</v-card-text> </v-card-text>
<v-card-actions class="d-flex justify-center flex-column flex-sm-row"> <v-card-actions class="d-flex justify-center flex-column flex-sm-row">
<v-btn <v-btn
v-if="allowSignup && allowPasswordLogin" v-if="$appInfo.allowSignup && $appInfo.allowPasswordLogin"
variant="text" variant="text"
to="/register" to="/register"
> >
@@ -162,7 +162,7 @@
{{ $t("user.invite-only") }} {{ $t("user.invite-only") }}
</v-btn> </v-btn>
<v-btn <v-btn
v-if="allowPasswordLogin" v-if="$appInfo.allowPasswordLogin"
class="mr-auto" class="mr-auto"
variant="text" variant="text"
to="/forgot-password" to="/forgot-password"
@@ -212,7 +212,6 @@
<script lang="ts"> <script lang="ts">
import { useDark, whenever } from "@vueuse/core"; import { useDark, whenever } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAppInfo } from "~/composables/api";
import { usePasswordField } from "~/composables/use-passwords"; import { usePasswordField } from "~/composables/use-passwords";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
import { useAsyncKey } from "~/composables/use-utils"; import { useAsyncKey } from "~/composables/use-utils";
@@ -229,7 +228,7 @@ export default defineNuxtComponent({
const router = useRouter(); const router = useRouter();
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const { $axios } = useNuxtApp(); const { $appInfo, $axios } = useNuxtApp();
const { loggedIn } = useLoggedInState(); const { loggedIn } = useLoggedInState();
const groupSlug = computed(() => $auth.user.value?.groupSlug); const groupSlug = computed(() => $auth.user.value?.groupSlug);
const isDemo = ref(false); const isDemo = ref(false);
@@ -276,19 +275,10 @@ export default defineNuxtComponent({
const loggingIn = ref(false); const loggingIn = ref(false);
const oidcLoggingIn = ref(false); const oidcLoggingIn = ref(false);
const appInfo = useAppInfo();
const { passwordIcon, inputType, togglePasswordShow } = usePasswordField(); const { passwordIcon, inputType, togglePasswordShow } = usePasswordField();
const appInfoLoaded = computed(() => appInfo.value !== null);
const allowSignup = computed(() => appInfo.value?.allowSignup || false);
const allowOidc = computed(() => appInfo.value?.enableOidc || false);
const oidcRedirect = computed(() => appInfo.value?.oidcRedirect || false);
const oidcProviderName = computed(() => appInfo.value?.oidcProviderName || "OAuth");
const allowPasswordLogin = computed(() => appInfo.value?.allowPasswordLogin ?? true);
whenever( whenever(
() => appInfoLoaded.value && allowOidc.value && oidcRedirect.value && !isCallback() && !isDirectLogin() /* && !$auth.check().valid */, () => $appInfo.enableOidc && $appInfo.oidcRedirect && !isCallback() && !isDirectLogin() /* && !$auth.check().valid */,
() => oidcAuthenticate(), () => oidcAuthenticate(),
{ immediate: true }, { immediate: true },
); );
@@ -368,13 +358,8 @@ export default defineNuxtComponent({
isDark, isDark,
form, form,
loggingIn, loggingIn,
appInfoLoaded,
allowSignup,
allowPasswordLogin,
allowOidc,
authenticate, authenticate,
oidcAuthenticate, oidcAuthenticate,
oidcProviderName,
oidcLoggingIn, oidcLoggingIn,
passwordIcon, passwordIcon,
inputType, inputType,

View File

@@ -0,0 +1,14 @@
import axios from "axios";
import type { AppInfo } from "~/lib/api/types/admin";
export default defineNuxtPlugin({
async setup() {
const { data } = await axios.get<AppInfo>("/api/app/about");
return {
provide: {
appInfo: data,
},
};
},
});

View File

@@ -128,11 +128,8 @@ class AppSettings(AppLoggingSettings):
def validate_token_time(cls, v: int) -> int: def validate_token_time(cls, v: int) -> int:
if v < 1: if v < 1:
raise ValueError("TOKEN_TIME must be at least 1 hour") raise ValueError("TOKEN_TIME must be at least 1 hour")
# If TOKEN_TIME is unreasonably high (e.g. hundreds of years), JWT encoding # Certain browsers (webkit) have issues with very long-lived cookies, so we limit to 400 days
# can overflow, so we set the max to 10 years (87600 hours). return min(v, 400 * 24)
if v > 87600:
raise ValueError("TOKEN_TIME is too high; maximum is 87600 hours (10 years)")
return v
SECRET: str SECRET: str
SESSION_SECRET: str SESSION_SECRET: str

View File

@@ -30,6 +30,7 @@ class AdminAboutController(BaseAdminController):
default_household=settings.DEFAULT_HOUSEHOLD, default_household=settings.DEFAULT_HOUSEHOLD,
allow_signup=settings.ALLOW_SIGNUP, allow_signup=settings.ALLOW_SIGNUP,
allow_password_login=settings.ALLOW_PASSWORD_LOGIN, allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
token_time=settings.TOKEN_TIME,
build_id=settings.GIT_COMMIT_HASH, build_id=settings.GIT_COMMIT_HASH,
recipe_scraper_version=recipe_scraper_version.__version__, recipe_scraper_version=recipe_scraper_version.__version__,
enable_oidc=settings.OIDC_AUTH_ENABLED, 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=settings.OPENAI_ENABLED,
enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES, enable_openai_image_services=settings.OPENAI_ENABLED and settings.OPENAI_ENABLE_IMAGE_SERVICES,
allow_password_login=settings.ALLOW_PASSWORD_LOGIN, 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 authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Header, Request, Response, status from fastapi import APIRouter, Depends, Header, Request, Response, status
@@ -54,59 +54,13 @@ class MealieAuthToken(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" 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 @classmethod
def respond(cls, token: str, token_type: str = "bearer") -> dict: def respond(cls, token: str, token_type: str = "bearer") -> dict:
return cls(access_token=token, token_type=token_type).model_dump() 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") @public_router.post("/token")
def get_token( def get_token(request: Request, data: CredentialsRequestForm = Depends(), session: Session = Depends(generate_session)):
request: Request,
response: Response,
data: CredentialsRequestForm = Depends(),
session: Session = Depends(generate_session),
):
if "x-forwarded-for" in request.headers: if "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"] ip = request.headers["x-forwarded-for"]
if "," in ip: # if there are multiple IPs, the first one is canonically the true client 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, status_code=status.HTTP_401_UNAUTHORIZED,
) )
access_token, duration = auth access_token, _ = auth
expires_in = duration.total_seconds() if duration else None
MealieAuthToken.set_cookie(
response,
access_token,
expires_in=expires_in,
samesite=get_samesite(request),
)
return MealieAuthToken.respond(access_token) return MealieAuthToken.respond(access_token)
@@ -160,7 +106,7 @@ async def oauth_login(request: Request):
@public_router.get("/oauth/callback") @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: if not oauth:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 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: if not auth:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
access_token, duration = auth access_token, _ = auth
expires_in = duration.total_seconds() if duration else None
MealieAuthToken.set_cookie(
response,
access_token,
expires_in=expires_in,
samesite=get_samesite(request),
)
return MealieAuthToken.respond(access_token) return MealieAuthToken.respond(access_token)

View File

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

View File

@@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './.', testDir: './.',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */