Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add OIDC_CLIENT_SECRET and other changes for v2 #4254

Merged
merged 27 commits into from
Oct 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c9723eb
initial impl
cmintey May 3, 2024
e76b1fe
working implementation
cmintey Aug 13, 2024
a5f387a
refine implementation
cmintey Sep 10, 2024
06ae181
hide client secret from logging and set session secret
cmintey Sep 10, 2024
1867194
allow admin users to sign in without the users group
cmintey Sep 14, 2024
26c3775
update docs
cmintey Sep 14, 2024
7832e19
execute callback earlier and reset url on failure
cmintey Sep 14, 2024
21bc5b7
remove oidc api
cmintey Sep 14, 2024
d33f153
add claims check and remove async from authenticate method
cmintey Sep 14, 2024
46e5808
add unit tests
cmintey Sep 14, 2024
4cd3345
exclude session secret from logs
cmintey Sep 21, 2024
08a9c79
enable PKCE
cmintey Sep 21, 2024
46fa494
update auth config
cmintey Sep 23, 2024
45935cc
only go to direct login on failure or user logout action
cmintey Sep 23, 2024
c9adc6b
fix group claims requirements
cmintey Sep 23, 2024
f30b900
update api docs
cmintey Sep 23, 2024
feddea6
update e2e docker
cmintey Sep 23, 2024
f48a91d
update lock file
cmintey Sep 23, 2024
30c6c06
set secure cookie
cmintey Sep 23, 2024
c13cc71
fix test
cmintey Sep 23, 2024
a7938e2
Merge remote-tracking branch 'upstream/mealie-next' into server-side-…
cmintey Oct 1, 2024
c51b72e
update lock file and api docs
cmintey Oct 1, 2024
b9fc128
fix doc links
cmintey Oct 3, 2024
c82f30d
Merge remote-tracking branch 'upstream/mealie-next' into server-side-…
cmintey Oct 3, 2024
0dc2249
remove unused env var for signing algorithm
cmintey Oct 3, 2024
962e629
fallback to OIDC_USER_CLAIM for username creation
cmintey Oct 3, 2024
ac47712
Merge branch 'mealie-next' into server-side-oidc
boc-the-git Oct 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ services:
POSTGRES_SERVER: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: mealie

# =====================================
# Email Configuration
# SMTP_HOST=
Expand Down
96 changes: 96 additions & 0 deletions docs/docs/documentation/getting-started/authentication/oidc-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# OpenID Connect (OIDC) Authentication

:octicons-tag-24: v2.0.0

!!! note
Breaking changes to OIDC Authentication were introduced with Mealie v2. Please see the below for [migration steps](#migration-from-mealie-v1x).

Looking instead for the docs for Mealie :octicons-tag-24: v1.x? [Click here](./oidc.md)

Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:

- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
- [Okta](https://www.okta.com/openid-connect/)

## Account Linking

Signing in with OAuth will automatically find your account in Mealie and link to it. If a user does not exist in Mealie, then one will be created (if enabled), but will be unable to log in with any other authentication method. An admin can configure another authentication method for such a user.

## Provider Setup

Before you can start using OIDC Authentication, you must first configure a new client application in your identity provider. Your identity provider must support the OAuth **Authorization Code flow with PKCE**. The steps will vary by provider, but generally, the steps are as follows.

1. Create a new client application
- The Provider type should be OIDC or OAuth2
- The Grant type should be `Authorization Code`
- The Client type should be `private` (you should have a **Client Secret**)

2. Configure redirect URI

The redirect URI(s) that are needed:

1. `http(s)://DOMAIN:PORT/login`
2. `https(s)://DOMAIN:PORT/login?direct=1`
1. This URI is only required if your IdP supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) such as Keycloak. You may also be able to combine this into the previous URI by using a wildcard: `http(s)://DOMAIN:PORT/login*`

The redirect URI(s) should include any URL that Mealie is accessible from. Some examples include

http://localhost:9091/login
https://mealie.example.com/login

3. Configure allowed scopes

The scopes required are `openid profile email`

If you plan to use the [groups](#groups) to configure access within Mealie, you will need to also add the scope defined by the `OIDC_GROUPS_CLAIM` environment variable. The default claim is `groups`

## Mealie Setup

Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc).

### Groups

There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.

`OIDC_USER_GROUP`: Users must be a part of this group (within your IdP) to be able to log in.

`OIDC_ADMIN_GROUP`: Users that are in this group (within your IdP) will be made an **admin** in Mealie. Users in this group do not also need to be in the `OIDC_USER_GROUP`

## Examples

Example configurations for several Identity Providers have been provided by the Community in the [GitHub Discussions](https://github.com/mealie-recipes/mealie/discussions/categories/oauth-provider-example).

If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.


## Migration from Mealie v1.x

**High level changes**

- A Client Secret is now required
- CORS is no longer a requirement since all authentication happens server-side
- A user will be successfully authenticated if they are part of *either* `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. Admins no longer need to be part of both groups
- ID Token signing algorithm is now inferred using the `id_token_signing_alg_values_supported` metadata from the discovery URL

### Changes in your IdP

**Required**

- You must change the Mealie client in your IdP to be **private**. The option is different for every provider, but you need to obtain a **client secret**.

**Optional**

- You may now also remove the `OIDC_USER_GROUP` from your admin users if you so desire. Users within the `OIDC_ADMIN_GROUP` will now be able to successfully authenticate with only that group.
- You may remove any CORS configuration. i.e. configured origins

### Changes in Mealie

**Required**

- After obtaining the **client secret** from your IdP, you must add it to Mealie using the `OIDC_CLIENT_SECRET` environment variable or via [docker secrets](../installation/backend-config.md#docker-secrets). This secret will not be logged on startup.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This states that you can use a Docker secret for the OIDC_CLIENT_SECRET, but that doesn't seem to work in practice. I don't see where OIDC_CLIENT_SECRET is read from a file in this PR (which is how Docker secrets work). Am I missing something?


**Optional**

- Remove `OIDC_SIGNING_ALGORITHM` from your environment. It will no longer have any effect.
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,20 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea

:octicons-tag-24: v1.4.0

For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)

| Variables | Default | Description |
| ---------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
| OIDC_CLIENT_ID | None | The client id of your configured client in your provider |
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate, regardless of the `OIDC_ADMIN_GROUP`. For more information see [this page](../authentication/oidc.md#groups) |
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be made an admin. For more information see [this page](../authentication/oidc.md#groups) |
| OIDC_CLIENT_SECRET <br/> :octicons-tag-24: v2.0.0 | None | The client secret of your configured client in your provider|
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate. For more information see [this page](../authentication/oidc-v2.md#groups) |
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be able to successfully authenticate *and* be made an admin. For more information see [this page](../authentication/oidc-v2.md#groups) |
| OIDC_AUTO_REDIRECT | False | If `True`, then the login page will be bypassed an you will be sent directly to your Identity Provider. You can still get to the login page by adding `?direct=1` to the login URL |
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
| OIDC_SIGNING_ALGORITHM | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/overrides/api.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ nav:

- Authentication:
- LDAP: "documentation/getting-started/authentication/ldap.md"
- OpenID Connect: "documentation/getting-started/authentication/oidc.md"
- OpenID Connect: "documentation/getting-started/authentication/oidc-v2.md"

- Community Guides:
- iOS Shortcuts: "documentation/community-guide/ios.md"
Expand Down
10 changes: 8 additions & 2 deletions frontend/components/Layout/LayoutParts/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<v-btn v-else icon @click="activateSearch">
<v-icon> {{ $globals.icons.search }}</v-icon>
</v-btn>
<v-btn v-if="loggedIn" :text="$vuetify.breakpoint.smAndUp" :icon="$vuetify.breakpoint.xs" @click="$auth.logout()">
<v-btn v-if="loggedIn" :text="$vuetify.breakpoint.smAndUp" :icon="$vuetify.breakpoint.xs" @click="logout()">
<v-icon :left="$vuetify.breakpoint.smAndUp">{{ $globals.icons.logout }}</v-icon>
{{ $vuetify.breakpoint.smAndUp ? $t("user.logout") : "" }}
</v-btn>
Expand All @@ -48,7 +48,7 @@
</template>

<script lang="ts">
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, useContext, useRoute, useRouter } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";

Expand All @@ -64,6 +64,7 @@ export default defineComponent({
const { $auth } = useContext();
const { loggedIn } = useLoggedInState();
const route = useRoute();
const router = useRouter();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");

const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
Expand All @@ -89,11 +90,16 @@ export default defineComponent({
document.removeEventListener("keydown", handleKeyEvent);
});

async function logout() {
await $auth.logout().then(() => router.push("/login?direct=1"))
}

return {
activateSearch,
domSearchDialog,
routerLink,
loggedIn,
logout,
};
},
});
Expand Down
5 changes: 0 additions & 5 deletions frontend/lib/api/types/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,6 @@ export interface NotificationImport {
status: boolean;
exception?: string | null;
}
export interface OIDCInfo {
configurationUrl: string | null;
clientId: string | null;
groupsClaim: string | null;
}
export interface RecipeImport {
name: string;
status: boolean;
Expand Down
3 changes: 0 additions & 3 deletions frontend/lib/api/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,6 @@ export interface LongLiveTokenOut {
id: number;
createdAt?: string | null;
}
export interface OIDCRequest {
id_token: string;
}
export interface PasswordResetToken {
token: string;
}
Expand Down
22 changes: 17 additions & 5 deletions frontend/nuxt.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export default {
auth: {
redirect: {
login: "/login",
logout: "/login?direct=1",
logout: "/login",
callback: "/login",
home: "/",
},
Expand Down Expand Up @@ -161,12 +161,24 @@ export default {
},
},
oidc: {
scheme: "~/schemes/DynamicOpenIDConnectScheme",
scheme: "local",
resetOnError: true,
clientId: "",
token: {
property: "access_token",
global: true,
},
user: {
property: "",
autoFetch: true,
},
endpoints: {
configuration: "",
}
login: {
url: "api/auth/oauth/callback",
method: "get",
},
logout: { url: "api/auth/logout", method: "post" },
user: { url: "api/users/self", method: "get" },
},
},
},
},
Expand Down
45 changes: 33 additions & 12 deletions frontend/pages/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
<v-checkbox v-model="form.remember" class="ml-2 mt-n2" :label="$t('user.remember-me')"></v-checkbox>
<v-card-actions class="justify-center pt-0">
<div class="max-button">
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
<v-btn :loading="loggingIn" :disabled="oidcLoggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
{{ $t("user.login") }}
</v-btn>
</div>
Expand All @@ -85,7 +85,7 @@
</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">
<v-btn :loading="oidcLoggingIn" color="primary" large rounded class="rounded-xl" block @click.native="() => oidcAuthenticate()">
{{ $t("user.login-oidc") }} {{ oidcProviderName }}
</v-btn>
</div>
Expand Down Expand Up @@ -133,7 +133,7 @@
</template>

<script lang="ts">
import { defineComponent, ref, useContext, computed, reactive, useRouter, useAsync } from "@nuxtjs/composition-api";
import { defineComponent, ref, useContext, computed, reactive, useRouter, useAsync, onBeforeMount } from "@nuxtjs/composition-api";
import { useDark, whenever } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAppInfo } from "~/composables/api";
Expand Down Expand Up @@ -180,6 +180,7 @@ export default defineComponent({
);

const loggingIn = ref(false);
const oidcLoggingIn = ref(false)

const appInfo = useAppInfo();

Expand All @@ -196,19 +197,34 @@ export default defineComponent({
{immediate: true}
)

onBeforeMount(async () => {
if (isCallback()) {
await oidcAuthenticate(true)
}
})

function isCallback() {
return router.currentRoute.query.state;
const params = new URLSearchParams(window.location.search)
return params.has("code") || params.has("error")
}

function isDirectLogin() {
return Object.keys(router.currentRoute.query).includes("direct")
const params = new URLSearchParams(window.location.search)
return params.has("direct") && params.get("direct") === "1"
}

async function oidcAuthenticate() {
try {
await $auth.loginWith("oidc")
} catch (error) {
alert.error(i18n.t("events.something-went-wrong") as string);
async function oidcAuthenticate(callback = false) {
if (callback) {
oidcLoggingIn.value = true
try {
await $auth.loginWith("oidc", { params: new URLSearchParams(window.location.search)})
} catch (error) {
await router.replace("/login?direct=1")
alertOnError(error)
}
oidcLoggingIn.value = false
} else {
window.location.replace("/api/auth/oauth") // start the redirect process
}
}

Expand All @@ -227,6 +243,12 @@ export default defineComponent({
try {
await $auth.loginWith("local", { data: formData });
} catch (error) {
alertOnError(error)
}
loggingIn.value = false;
}

function alertOnError(error: any) {
// TODO Check if error is an AxiosError, but isAxiosError is not working right now
// See https://github.com/nuxt-community/axios-module/issues/550
// Import $axios from useContext()
Expand All @@ -240,8 +262,6 @@ export default defineComponent({
} else {
alert.error(i18n.t("events.something-went-wrong") as string);
}
}
loggingIn.value = false;
}

return {
Expand All @@ -253,6 +273,7 @@ export default defineComponent({
authenticate,
oidcAuthenticate,
oidcProviderName,
oidcLoggingIn,
passwordIcon,
inputType,
togglePasswordShow,
Expand Down
Loading
Loading