feat(auth): link credentials users with OIDC

This commit is contained in:
ajnart committed 2026-07-22 22:51:49 +02:00
1 parent cf0e469465
commit 00a65bfc58
8 files changed
+159 -15

No files matched your search

+21 -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,19 @@ export const createAdapter = (db: Database, provider: SupportedAuthProvider | "u
},
});
if (!user && provider === "oidc" && enableDangerousCredentialsLinking) {
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(",")}'`);
@@ -18,7 +18,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,
+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") =>