Compare commits

...
10 changed files with 262 additions and 15 deletions

No files matched your search

@@ -127,6 +127,8 @@ 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. 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.
<details>
<summary>
Example Setup
@@ -204,6 +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. 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
+24 -4
View File
@@ -6,20 +6,24 @@ import { and, eq } from "@homarr/db";
import { accounts, sessions, users } from "@homarr/db/schema";
import type { SupportedAuthProvider } from "@homarr/definitions";
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
export const createAdapter = (
db: Database,
provider: SupportedAuthProvider | "unknown",
enableDangerousCredentialsLinking = false,
): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
return {
...drizzleAdapter,
// We override the default implementation as we want to have a provider
// flag in the user instead of the account to not intermingle users from different providers
// Keep users isolated by their primary provider. The explicit credentials-linking
// opt-in is the only exception and keeps credentials as the primary provider.
// eslint-disable-next-line no-restricted-syntax
getUserByEmail: async (email) => {
if (provider === "unknown") {
throw new Error("Unable to get user by email for unknown provider");
}
const user = await db.query.users.findFirst({
let user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, provider)),
columns: {
id: true,
@@ -30,6 +34,22 @@ export const createAdapter = (db: Database, provider: SupportedAuthProvider | "u
},
});
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: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
});
}
if (!user) {
return null;
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const createConfiguration = (
headers: ReadonlyHeaders | null,
useSecureCookies: boolean,
) => {
const adapter = createAdapter(db, provider);
const adapter = createAdapter(db, provider, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
return NextAuth({
logger: {
error: (error) => {
+1
View File
@@ -46,6 +46,7 @@ export const env = createEnv({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: createBooleanSchema(false),
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: z
.enum(["client_secret_basic", "client_secret_post", "client_secret_jwt", "none"])
.default("client_secret_basic"),
+3 -1
View File
@@ -24,6 +24,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
name: true,
image: true,
colorScheme: true,
provider: true,
},
});
@@ -33,6 +34,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
// Groups from oidc provider are provided from the profile, it's not typed.
if (
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
dbUser.provider === "oidc" &&
profile &&
groupsKey in profile &&
Array.isArray(profile[groupsKey])
@@ -57,7 +59,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
});
}
if (profile) {
if (profile && dbUser.provider === "oidc") {
const profileUsername = extractProfileName(profile);
if (!profileUsername) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -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<Profile> => ({
id: "oidc",
@@ -18,7 +19,8 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
token_endpoint_auth_method: env.AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD,
},
issuer: env.AUTH_OIDC_ISSUER,
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
allowDangerousEmailAccountLinking:
env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING || env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING,
authorization: {
params: {
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
@@ -55,6 +57,7 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
}
assertVerifiedEmailForCredentialsLinking(profile, env.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING);
const name = extractProfileName(profile);
if (!name) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
@@ -0,0 +1,11 @@
export const assertVerifiedEmailForCredentialsLinking = (
profile: { email?: unknown; email_verified?: unknown },
isCredentialsLinkingEnabled: boolean,
) => {
if (
isCredentialsLinkingEnabled &&
(profile.email_verified !== true || typeof profile.email !== "string" || profile.email.trim().length === 0)
) {
throw new Error("OIDC provider did not return a verified email while credentials linking is enabled");
}
};
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { OidcProvider } from "../oidc/oidc-provider";
import { assertVerifiedEmailForCredentialsLinking } from "../oidc/verified-email";
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_CLIENT_ID: "client-id",
AUTH_OIDC_CLIENT_NAME: "OIDC",
AUTH_OIDC_CLIENT_SECRET: "client-secret",
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: false,
AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING: false,
AUTH_OIDC_FORCE_USERINFO: false,
AUTH_OIDC_ISSUER: "https://example.com",
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined,
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile",
AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_basic" as const,
}));
vi.mock("../../env", () => ({ env: mockEnv }));
vi.mock("next-auth", () => ({ customFetch: Symbol("customFetch") }));
afterEach(() => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = false;
});
describe("assertVerifiedEmailForCredentialsLinking", () => {
test("allows a strictly verified OIDC email when credentials linking is enabled", () => {
expect(() =>
assertVerifiedEmailForCredentialsLinking({ email: "test@example.com", email_verified: true }, true),
).not.toThrow();
});
test.each([
{ email: "test@example.com", email_verified: false },
{ email: "test@example.com", email_verified: "true" },
{ email_verified: true },
{ email: null, email_verified: true },
{ email: " ", 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();
});
test("OIDC provider rejects an unverified profile when credentials linking is enabled", () => {
mockEnv.AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING = true;
const provider = OidcProvider(null);
expect(provider.allowDangerousEmailAccountLinking).toBe(true);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).toThrow("OIDC provider did not return a verified email while credentials linking is enabled");
});
test("OIDC provider accepts an unverified profile when credentials linking is disabled", () => {
const provider = OidcProvider(null);
const mapProfile = provider.profile;
if (!mapProfile) throw new Error("Expected OIDC profile mapper to be defined");
expect(() =>
mapProfile(
{
sub: "subject",
preferred_username: "test",
email: "test@example.com",
email_verified: false,
},
{},
),
).not.toThrow();
});
});
+89 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { users } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { accounts, apiKeys, groupMembers, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createAdapter } from "../adapter";
@@ -64,4 +65,91 @@ describe("createAdapter should create drizzle adapter", () => {
// Assert
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
});
test("getUserByEmail should return a credentials user for oidc when credentials linking is enabled", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("credentials-user");
});
test("getUserByEmail should not return credentials users for non-oidc providers", async () => {
const db = createDb();
const adapter = createAdapter(db, "ldap", true);
const email = "test@example.com";
await db.insert(users).values({ id: "credentials-user", name: "test", email, provider: "credentials" });
const user = await adapter.getUserByEmail?.(email);
expect(user).toBeNull();
});
test("getUserByEmail should prefer an existing oidc user over a credentials user", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values([
{ id: "credentials-user", name: "credentials", email, provider: "credentials" },
{ id: "oidc-user", name: "oidc", email, provider: "oidc" },
]);
const user = await adapter.getUserByEmail?.(email);
expect(user?.id).toBe("oidc-user");
});
test("linkAccount should preserve the credentials user and their data", async () => {
const db = createDb();
const adapter = createAdapter(db, "oidc", true);
const email = "test@example.com";
await db.insert(users).values({
id: "credentials-user",
name: "test",
email,
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
await db.insert(groups).values({ id: "group", name: "Test group", position: 1 });
await db.insert(groupMembers).values({ groupId: "group", userId: "credentials-user" });
await db.insert(apiKeys).values({ id: "api-key", apiKey: "secret", userId: "credentials-user" });
const user = await adapter.getUserByEmail?.(email);
if (!user) throw new Error("Expected credentials user to be found for OIDC linking");
await adapter.linkAccount?.({
userId: user.id,
type: "oidc",
provider: "oidc",
providerAccountId: "oidc-subject",
});
const linkedUser = await db.query.users.findFirst({ where: eq(users.id, "credentials-user") });
const account = await db.query.accounts.findFirst({ where: eq(accounts.providerAccountId, "oidc-subject") });
const membership = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "credentials-user"),
});
const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys.userId, "credentials-user") });
const userByAccount = await adapter.getUserByAccount?.({
provider: "oidc",
providerAccountId: "oidc-subject",
});
const allUsers = await db.query.users.findMany();
expect(linkedUser).toMatchObject({
id: "credentials-user",
password: "hashed-password",
provider: "credentials",
colorScheme: "light",
completedBoardTour: true,
});
expect(account?.userId).toBe("credentials-user");
expect(membership?.groupId).toBe("group");
expect(apiKey?.id).toBe("api-key");
expect(userByAccount?.id).toBe("credentials-user");
expect(allUsers).toHaveLength(1);
});
});
+39 -7
View File
@@ -135,7 +135,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
@@ -155,7 +155,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
@@ -179,7 +179,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
@@ -204,7 +204,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
// Arrange
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
@@ -229,10 +229,10 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
["ldap" as const, { name: "test-new" }, undefined],
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
])("signInEventHandler should update username for %s provider", async (provider, user, profile) => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createUserAsync(db, provider);
const eventHandler = createSignInEventHandler(db);
// Act
@@ -251,6 +251,32 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
expect(dbUser?.name).toBe("test-new");
});
test("signInEventHandler should preserve credentials-owned data when the user signs in with linked oidc", async () => {
const db = createDb();
await createUserAsync(db, "credentials", "local-avatar");
await createGroupAsync(db);
await db.insert(groupMembers).values({ userId: "1", groupId: "1" });
const eventHandler = createSignInEventHandler(db);
await eventHandler?.({
user: { id: "1", name: "test" },
profile: {
preferred_username: "oidc-name",
picture: "oidc-avatar",
someRandomGroupsKey: [],
},
account: null,
});
const dbUser = await db.query.users.findFirst({ where: eq(users.id, "1") });
const membership = await db.query.groupMembers.findFirst({ where: eq(groupMembers.userId, "1") });
expect(dbUser).toMatchObject({
name: "test",
image: "local-avatar",
provider: "credentials",
});
expect(membership?.groupId).toBe("1");
});
test("signInEventHandler should set color-scheme cookie", async () => {
// Arrange
const db = createDb();
@@ -275,11 +301,17 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
});
const createUserAsync = async (db: Database) =>
const createUserAsync = async (
db: Database,
provider: "credentials" | "ldap" | "oidc" = "credentials",
image?: string,
) =>
await db.insert(users).values({
id: "1",
name: "test",
colorScheme: "dark",
image,
provider,
});
const createGroupAsync = async (db: Database, name = "test") =>