diff --git a/apps/docs/docs/advanced/single-sign-on/index.mdx b/apps/docs/docs/advanced/single-sign-on/index.mdx index bc0cbfc46..ae1d9ca31 100644 --- a/apps/docs/docs/advanced/single-sign-on/index.mdx +++ b/apps/docs/docs/advanced/single-sign-on/index.mdx @@ -127,7 +127,7 @@ Homarr supports multiple authentication options, from internal userbase (credent If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled. - To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Only enable this when your OIDC provider verifies email addresses, because an unverified matching email could be used to take over an existing account. + To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
@@ -206,7 +206,7 @@ Homarr supports multiple authentication options, from internal userbase (credent | ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- | | ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false | | ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false | - | ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Only enable this when the OIDC provider verifies email addresses. | false | + | ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false | | ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic | ### Permission System diff --git a/packages/auth/adapter.ts b/packages/auth/adapter.ts index 47b486f41..e120d3220 100644 --- a/packages/auth/adapter.ts +++ b/packages/auth/adapter.ts @@ -35,6 +35,9 @@ export const createAdapter = ( }); if (!user && provider === "oidc" && enableDangerousCredentialsLinking) { + // Credentials accounts do not have an email-verification flow, so their + // Auth.js emailVerified field is always null. The OIDC profile's strict + // email_verified check is the security boundary for this opt-in. user = await db.query.users.findFirst({ where: and(eq(users.email, email), eq(users.provider, "credentials")), columns: { diff --git a/packages/auth/providers/oidc/oidc-provider.ts b/packages/auth/providers/oidc/oidc-provider.ts index 76fde7f06..7b3e30cb5 100644 --- a/packages/auth/providers/oidc/oidc-provider.ts +++ b/packages/auth/providers/oidc/oidc-provider.ts @@ -7,6 +7,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import { env } from "../../env"; import { createRedirectUri } from "../../redirect"; +import { assertVerifiedEmailForCredentialsLinking } from "./verified-email"; export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig => ({ id: "oidc", @@ -56,6 +57,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig { + if (isCredentialsLinkingEnabled && profile.email_verified !== true) { + throw new Error("OIDC provider did not return a verified email while credentials linking is enabled"); + } +}; diff --git a/packages/auth/providers/test/oidc-provider.spec.ts b/packages/auth/providers/test/oidc-provider.spec.ts new file mode 100644 index 000000000..9976218d9 --- /dev/null +++ b/packages/auth/providers/test/oidc-provider.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; + +import { assertVerifiedEmailForCredentialsLinking } from "../oidc/verified-email"; + +describe("assertVerifiedEmailForCredentialsLinking", () => { + test("allows a strictly verified OIDC email when credentials linking is enabled", () => { + expect(() => assertVerifiedEmailForCredentialsLinking({ email_verified: true }, true)).not.toThrow(); + }); + + test.each([{ email_verified: false }, { email_verified: "true" }, {}])( + "rejects an OIDC profile without a strictly verified email when credentials linking is enabled", + (profile) => { + expect(() => assertVerifiedEmailForCredentialsLinking(profile, true)).toThrow( + "OIDC provider did not return a verified email while credentials linking is enabled", + ); + }, + ); + + test("does not require the claim when credentials linking is disabled", () => { + expect(() => assertVerifiedEmailForCredentialsLinking({}, false)).not.toThrow(); + }); +});