feat(auth): link credentials users with OIDC
This commit is contained in:
1 parent
cf0e469465
commit
00a65bfc58
8 files changed
+159
-15
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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") =>
|
||||
|
||||
Reference in new issue
Block a user