1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-24 23:59:45 +02:00

feat: Login with OAuth via OpenID Connect (OIDC) (#3280)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Docker Nightly Production / Frontend and End-to-End Tests (push) Waiting to run
Docker Nightly Production / Build Tagged Release (push) Blocked by required conditions
Docker Nightly Production / Backend Server Tests (push) Waiting to run
Docker Nightly Production / Notify Discord (push) Blocked by required conditions

* initial oidc implementation

* add dynamic scheme

* e2e test setup

* add caching

* fix

* try this

* add libldap-2.5 to runtime dependencies (#2849)

* New translations en-us.json (Norwegian) (#2851)

* New Crowdin updates (#2855)

* New translations en-us.json (Italian)

* New translations en-us.json (Norwegian)

* New translations en-us.json (Portuguese)

* fix

* remove cache

* cache yarn deps

* cache docker image

* cleanup action

* lint

* fix tests

* remove not needed variables

* run code gen

* fix tests

* add docs

* move code into custom scheme

* remove unneeded type

* fix oidc admin

* add more tests

* add better spacing on login page

* create auth providers

* clean up testing stuff

* type fixes

* add OIDC auth method to postgres enum

* add option to bypass login screen and go directly to iDP

* remove check so we can fallback to another auth method oauth fails

* Add provider name to be shown at the login screen

* add new properties to admin about api

* fix spec

* add a prompt to change auth method when changing password

* Create new auth section. Add more info on auth methods

* update docs

* run ruff

* update docs

* format

* docs gen

* formatting

* initialize logger in class

* mypy type fixes

* docs gen

* add models to get proper fields in docs and fix serialization

* validate id token before using it

* only request a mealie token on initial callback

* remove unused method

* fix unit tests

* docs gen

* check for valid idToken before getting token

* add iss to mealie token

* check to see if we already have a mealie token before getting one

* fix lock file

* update authlib

* update lock file

* add remember me environment variable

* add user group setting to allow only certain groups to log in

---------

Co-authored-by: Carter Mintey <cmintey8@gmail.com>
Co-authored-by: Carter <35710697+cmintey@users.noreply.github.com>
This commit is contained in:
Hayden 2024-03-10 13:51:36 -05:00 committed by GitHub
parent bea1a592d7
commit 5f6844eceb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1533 additions and 400 deletions

View file

@ -38,7 +38,7 @@ export const useUserForm = () => {
type: fieldTypes.SELECT,
hint: i18n.tc("user.authentication-method-hint"),
disableCreate: true,
options: [{ text: "Mealie" }, { text: "LDAP" }],
options: [{ text: "Mealie" }, { text: "LDAP" }, { text: "OIDC" }],
},
{
section: i18n.tc("user.permissions"),

View file

@ -728,7 +728,10 @@
"ldap-ready-error-text": "Not all LDAP Values are configured. This can be ignored if you are not using LDAP Authentication.",
"ldap-ready-success-text": "Required LDAP variables are all set.",
"build": "Build",
"recipe-scraper-version": "Recipe Scraper Version"
"recipe-scraper-version": "Recipe Scraper Version",
"oidc-ready": "OIDC Ready",
"oidc-ready-error-text": "Not all OIDC Values are configured. This can be ignored if you are not using OIDC Authentication.",
"oidc-ready-success-text": "Required OIDC variables are all set."
},
"shopping-list": {
"all-lists": "All Lists",
@ -836,6 +839,8 @@
"link-id": "Link ID",
"link-name": "Link Name",
"login": "Login",
"login-oidc": "Login with",
"or": "or",
"logout": "Logout",
"manage-users": "Manage Users",
"new-password": "New Password",

View file

@ -10,6 +10,9 @@ export interface AdminAboutInfo {
version: string;
demoStatus: boolean;
allowSignup: boolean;
enableOidc: boolean;
oidcRedirect: boolean;
oidcProviderName: string;
versionLatest: string;
apiPort: number;
apiDocs: boolean;
@ -34,6 +37,9 @@ export interface AppInfo {
demoStatus: boolean;
allowSignup: boolean;
defaultGroupSlug?: string;
enableOidc: boolean;
oidcRedirect: boolean;
oidcProviderName: string;
}
export interface AppStartupInfo {
isFirstLogin: boolean;
@ -72,6 +78,7 @@ export interface BackupOptions {
export interface CheckAppConfig {
emailReady: boolean;
ldapReady: boolean;
oidcReady: boolean;
baseUrlSet: boolean;
isUpToDate: boolean;
}
@ -218,6 +225,10 @@ export interface NotificationImport {
status: boolean;
exception?: string;
}
export interface OIDCInfo {
configurationUrl?: string;
clientId?: string;
}
export interface RecipeImport {
name: string;
status: boolean;

View file

@ -5,7 +5,7 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type AuthMethod = "Mealie" | "LDAP";
export type AuthMethod = "Mealie" | "LDAP" | "OIDC";
export interface ChangePassword {
currentPassword: string;

View file

@ -123,7 +123,7 @@ export default {
auth: {
redirect: {
login: "/login",
logout: "/login",
logout: "/login?direct=1",
callback: "/login",
home: "/",
},
@ -134,6 +134,7 @@ export default {
path: "/",
},
},
rewriteRedirects: false,
// Options
strategies: {
local: {
@ -158,6 +159,14 @@ export default {
user: { url: "api/users/self", method: "get" },
},
},
oidc: {
scheme: "~/schemes/DynamicOpenIDConnectScheme",
resetOnError: true,
clientId: "",
endpoints: {
configuration: "",
}
},
},
},

View file

@ -90,7 +90,7 @@ export default defineComponent({
const user = ref<UserOut | null>(null);
const disabledFields = computed(() => {
return user.value?.authMethod === "LDAP" ? ["admin"] : [];
return user.value?.authMethod !== "Mealie" ? ["admin"] : [];
})
const userError = ref(false);

View file

@ -201,6 +201,7 @@ export default defineComponent({
isSiteSecure: true,
isUpToDate: false,
ldapReady: false,
oidcReady: false,
});
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
@ -258,6 +259,15 @@ export default defineComponent({
color: appConfig.value.ldapReady ? goodColor : warningColor,
icon: appConfig.value.ldapReady ? goodIcon : warningIcon,
},
{
id: "oidc-ready",
text: i18n.t("settings.oidc-ready"),
status: appConfig.value.oidcReady,
errorText: i18n.t("settings.oidc-ready-error-text"),
successText: i18n.t("settings.oidc-ready-success-text"),
color: appConfig.value.oidcReady ? goodColor : warningColor,
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
},
];
return data;
});

View file

@ -70,6 +70,26 @@
</v-btn>
</div>
</v-card-actions>
<div v-if="allowOidc" class="d-flex my-4 justify-center align-center" width="80%">
<v-divider class="div-width"/>
<span
class="absolute px-2"
:class="{
'bg-white': !$vuetify.theme.dark && !isDark,
'bg-background': $vuetify.theme.dark || isDark,
}"
>
{{ $t("user.or") }}
</span>
</div>
<v-card-actions v-if="allowOidc" class="justify-center">
<div class="max-button">
<v-btn color="primary" large rounded class="rounded-xl" block @click.native="oidcAuthenticate">
{{ $t("user.login-oidc") }} {{ oidcProviderName }}
</v-btn>
</div>
</v-card-actions>
</v-form>
</v-card-text>
<v-card-actions class="d-flex justify-center flex-column flex-sm-row">
@ -161,6 +181,32 @@ export default defineComponent({
const { passwordIcon, inputType, togglePasswordShow } = usePasswordField();
const allowSignup = computed(() => appInfo.value?.allowSignup || false);
const allowOidc = computed(() => appInfo.value?.enableOidc || false);
const oidcRedirect = computed(() => appInfo.value?.oidcRedirect || false);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const oidcProviderName = computed(() => appInfo.value?.oidcProviderName || "OAuth")
whenever(
() => allowOidc.value && oidcRedirect.value && !isCallback() && !isDirectLogin(),
() => oidcAuthenticate(),
{immediate: true}
)
function isCallback() {
return router.currentRoute.query.state;
}
function isDirectLogin() {
return router.currentRoute.query.direct
}
async function oidcAuthenticate() {
try {
await $auth.loginWith("oidc")
} catch (error) {
alert.error(i18n.t("events.something-went-wrong") as string);
}
}
async function authenticate() {
if (form.email.length === 0 || form.password.length === 0) {
@ -199,7 +245,10 @@ export default defineComponent({
form,
loggingIn,
allowSignup,
allowOidc,
authenticate,
oidcAuthenticate,
oidcProviderName,
passwordIcon,
inputType,
togglePasswordShow,
@ -250,4 +299,20 @@ export default defineComponent({
.bg-off-white {
background: #f5f8fa;
}
.absolute {
position: absolute;
}
.div-width {
max-width: 75%;
}
.bg-background {
background-color: #1e1e1e;
}
.bg-white {
background-color: #fff;
}
</style>

View file

@ -0,0 +1,87 @@
import jwtDecode from "jwt-decode"
import { ConfigurationDocument, OpenIDConnectScheme } from "~auth/runtime"
/**
* Custom Scheme that dynamically gets the OpenID Connect configuration from the backend.
* This is needed because the SPA frontend does not have access to runtime environment variables.
*/
export default class DynamicOpenIDConnectScheme extends OpenIDConnectScheme {
async mounted() {
await this.getConfiguration();
this.options.scope = ["openid", "profile", "email", "groups"]
this.configurationDocument = new ConfigurationDocument(
this,
this.$auth.$storage
)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await super.mounted()
}
async fetchUser() {
if (!this.check().valid) {
return
}
const { data } = await this.$auth.requestWith(this.name, {
url: "/api/users/self"
})
this.$auth.setUser(data)
}
async _handleCallback() {
const redirect = await super._handleCallback()
await this.updateAccessToken()
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return redirect;
}
async updateAccessToken() {
if (!this.idToken.sync()) {
return
}
if (this.isValidMealieToken()) {
return
}
const response = await this.$auth.requestWith(this.name, {
url: "/api/auth/token",
method: "post"
})
// Update tokens with mealie token
this.updateTokens(response)
}
isValidMealieToken() {
if (this.token.status().valid()) {
let iss = null;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
iss = jwtDecode(this.token.get()).iss
} catch (e) {
// pass
}
return iss === "mealie"
}
return false
}
async getConfiguration() {
const route = "/api/app/about/oidc";
try {
const response = await fetch(route);
const data = await response.json();
this.options.endpoints.configuration = data.configurationUrl;
this.options.clientId = data.clientId;
} catch (error) {
// pass
}
}
}

View file

@ -19,4 +19,4 @@ THEME_DARK_SECONDARY=#973542
THEME_DARK_SUCCESS=#43A047
THEME_DARK_INFO=#1976D2
THEME_DARK_WARNING=#FF6D00
THEME_DARK_ERROR=#EF5350
THEME_DARK_ERROR=#EF5350