diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 7dbec2f6..414e1a9f 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -59,9 +59,6 @@ export async function loader({ request, context }: Route.LoaderArgs) { claims.sub, ); - // We have defaults that closely follow what Headscale uses, maybe we - // can make it configurable in the future, but for now we only need the - // `sub` claim. const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user"; const name = userInfo.name ?? @@ -87,15 +84,30 @@ export async function loader({ request, context }: Route.LoaderArgs) { .from(users) .where(eq(users.caps, Roles.owner)); + // Match OIDC subject to Headscale user providerId + let headscaleUserId: string | undefined; + if (context.config.oidc?.integrate_headscale) { + headscaleUserId = await findHeadscaleUser(context, oidcConnector.apiKey, claims.sub); + } + await context.db .insert(users) .values({ id: ulid(), sub: claims.sub, caps: userCount === 0 ? Roles.owner : Roles.member, + headscale_user_id: headscaleUserId, }) .onConflictDoNothing(); + // Update existing user with Headscale link if not set + if (headscaleUserId) { + await context.db + .update(users) + .set({ headscale_user_id: headscaleUserId }) + .where(eq(users.sub, claims.sub)); + } + return redirect("/", { headers: { "Set-Cookie": await context.sessions.createSession({ @@ -153,3 +165,26 @@ export async function loader({ request, context }: Route.LoaderArgs) { return redirect("/login?s=error_auth_failed"); } } + +async function findHeadscaleUser( + context: Route.LoaderArgs["context"], + apiKey: string, + subject: string, +): Promise { + try { + const api = context.hsApi.getRuntimeClient(apiKey); + const hsUsers = await api.getUsers(); + + for (const user of hsUsers) { + // providerId format is "oidc/subject123" + const userSubject = user.providerId?.split("/").pop(); + if (userSubject === subject) { + log.info("auth", "Linked to Headscale user %s", user.id); + return user.id; + } + } + } catch (err) { + log.debug("auth", "Failed to query Headscale users: %o", err); + } + return undefined; +} diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 6e0875b2..6bdacc58 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -71,6 +71,7 @@ const oidcConfig = type({ client_secret: "string", headscale_api_key: "string", use_pkce: "boolean = false", + integrate_headscale: "boolean = false", redirect_uri: type("string.url") .pipe((value, ctx) => { log.warn("config", "%s is deprecated and will be removed in 0.7.0", ctx.propString); @@ -111,6 +112,7 @@ const partialOidcConfig = type({ client_secret: "string?", use_pkce: "boolean?", headscale_api_key: "string?", + integrate_headscale: "boolean?", redirect_uri: "string.url?", disable_api_key_login: "boolean?", scope: "string?", diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 1f24d1f2..12db219c 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -1,30 +1,30 @@ -import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { HostInfo } from '~/types'; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -export const ephemeralNodes = sqliteTable('ephemeral_nodes', { - auth_key: text('auth_key').primaryKey(), - node_key: text('node_key'), +import { HostInfo } from "~/types"; + +export const ephemeralNodes = sqliteTable("ephemeral_nodes", { + auth_key: text("auth_key").primaryKey(), + node_key: text("node_key"), }); export type EphemeralNode = typeof ephemeralNodes.$inferSelect; export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert; -export const hostInfo = sqliteTable('host_info', { - host_id: text('host_id').primaryKey(), - payload: text('payload', { mode: 'json' }).$type(), - updated_at: integer('updated_at', { mode: 'timestamp' }).$default( - () => new Date(), - ), +export const hostInfo = sqliteTable("host_info", { + host_id: text("host_id").primaryKey(), + payload: text("payload", { mode: "json" }).$type(), + updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()), }); export type HostInfoRecord = typeof hostInfo.$inferSelect; export type HostInfoInsert = typeof hostInfo.$inferInsert; -export const users = sqliteTable('users', { - id: text('id').primaryKey(), - sub: text('sub').notNull().unique(), - caps: integer('caps').notNull().default(0), - onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false), +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + sub: text("sub").notNull().unique(), + caps: integer("caps").notNull().default(0), + onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false), + headscale_user_id: text("headscale_user_id"), }); export type User = typeof users.$inferSelect; diff --git a/config.example.yaml b/config.example.yaml index d2dd7e43..f3a53cd4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -174,6 +174,9 @@ integration: # The OIDC issuer URL # issuer: "https://accounts.google.com" +# Link OIDC users to Headscale users by matching the subject to providerId. +# integrate_headscale: false + # If you are using OIDC, you need to generate an API key # that can be used to authenticate other sessions when signing in. # diff --git a/drizzle/0003_link_headscale_users.sql b/drizzle/0003_link_headscale_users.sql new file mode 100644 index 00000000..bbb950de --- /dev/null +++ b/drizzle/0003_link_headscale_users.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN headscale_user_id TEXT; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..1f5c2c8f --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,126 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f8a2c1e4-3d7b-4a9e-b5c6-8e1f2a3b4c5d", + "prevId": "2c18fbcb-d5f5-47c0-962d-54121cbb2e71", + "tables": { + "ephemeral_nodes": { + "name": "ephemeral_nodes", + "columns": { + "auth_key": { + "name": "auth_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "node_key": { + "name": "node_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_info": { + "name": "host_info", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub": { + "name": "sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "caps": { + "name": "caps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "onboarded": { + "name": "onboarded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "headscale_user_id": { + "name": "headscale_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_sub_unique": { + "name": "users_sub_unique", + "columns": ["sub"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 78491b64..0ce66a50 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,27 +1,34 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1750355487927, - "tag": "0000_spicy_bloodscream", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1755554742267, - "tag": "0001_naive_lilith", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1755617607599, - "tag": "0002_square_bloodstorm", - "breakpoints": true - } - ] + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1750355487927, + "tag": "0000_spicy_bloodscream", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1755554742267, + "tag": "0001_naive_lilith", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1755617607599, + "tag": "0002_square_bloodstorm", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1772190754000, + "tag": "0003_link_headscale_users", + "breakpoints": true + } + ] } diff --git a/tests/integration/oidc-linking.test.ts b/tests/integration/oidc-linking.test.ts new file mode 100644 index 00000000..b7cc10bf --- /dev/null +++ b/tests/integration/oidc-linking.test.ts @@ -0,0 +1,125 @@ +import tc from "testcontainers"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { createHeadscaleInterface } from "~/server/headscale/api"; + +import { startDex, type DexEnv } from "./setup/start-dex"; +import { startHeadscale, type HeadscaleEnv } from "./setup/start-headscale"; + +describe("OIDC to Headscale user linking", () => { + let network: tc.StartedNetwork; + let dex: DexEnv; + let headscale: HeadscaleEnv; + + beforeAll(async () => { + network = await new tc.Network().start(); + + // Start Dex for OIDC + dex = await startDex(network); + + // Start Headscale + headscale = await startHeadscale("0.28.0"); + }, 60_000); + + afterAll(async () => { + await dex?.container.stop({ remove: true, removeVolumes: true }); + await headscale?.container.stop({ remove: true, removeVolumes: true }); + await network?.stop(); + }); + + test("Dex OIDC discovery endpoint is accessible", async () => { + const response = await fetch(`${dex.issuerUrl}/.well-known/openid-configuration`); + expect(response.status).toBe(200); + + const config = await response.json(); + expect(config.issuer).toContain("/dex"); + expect(config.authorization_endpoint).toBeDefined(); + expect(config.token_endpoint).toBeDefined(); + }); + + test("Headscale API is accessible", async () => { + const api = await createHeadscaleInterface(headscale.apiUrl); + const client = api.getRuntimeClient(headscale.apiKey); + const users = await client.getUsers(); + expect(Array.isArray(users)).toBe(true); + }); + + describe("user matching with providerId", () => { + test("creates Headscale user with providerId", async () => { + const api = await createHeadscaleInterface(headscale.apiUrl); + const client = api.getRuntimeClient(headscale.apiKey); + + // Create a user (providerId would be set by OIDC in real flow) + const user = await client.createUser("oidc-linked-user@"); + expect(user).toBeDefined(); + expect(user.name).toBe("oidc-linked-user@"); + }); + + test("findHeadscaleUser matches by providerId subject", async () => { + const api = await createHeadscaleInterface(headscale.apiUrl); + const client = api.getRuntimeClient(headscale.apiKey); + + const users = await client.getUsers(); + + // Test the matching logic that runs in oidc-callback.ts + const oidcSubject = "test-admin-uid"; + + const match = users.find((u) => { + const subject = u.providerId?.split("/").pop(); + return subject === oidcSubject; + }); + + // No match expected since we haven't set providerId via API + // (Headscale sets this during OIDC node registration) + expect(match).toBeUndefined(); + }); + + test("user list returns providerId when set", async () => { + const api = await createHeadscaleInterface(headscale.apiUrl); + const client = api.getRuntimeClient(headscale.apiKey); + + const users = await client.getUsers(); + + // Check the shape of the response + for (const user of users) { + expect(user).toHaveProperty("id"); + expect(user).toHaveProperty("name"); + // providerId may or may not be present + if (user.providerId) { + expect(typeof user.providerId).toBe("string"); + } + } + }); + }); + + describe("OIDC flow simulation", () => { + test("can fetch OIDC token endpoint", async () => { + const discoveryRes = await fetch(`${dex.issuerUrl}/.well-known/openid-configuration`); + const discovery = await discoveryRes.json(); + + expect(discovery.token_endpoint).toBeDefined(); + expect(discovery.authorization_endpoint).toBeDefined(); + }); + + test("authorization endpoint is properly configured", async () => { + const discoveryRes = await fetch(`${dex.issuerUrl}/.well-known/openid-configuration`); + const discovery = await discoveryRes.json(); + + // The authorization endpoint exists in the discovery + expect(discovery.authorization_endpoint).toBeDefined(); + expect(discovery.authorization_endpoint).toContain("/dex/auth"); + + // Build the auth URL using the external issuer URL + const authUrl = new URL(`${dex.issuerUrl}/auth`); + authUrl.searchParams.set("client_id", dex.clientId); + authUrl.searchParams.set("redirect_uri", "http://localhost:3000/oidc/callback"); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid email profile"); + authUrl.searchParams.set("state", "test-state"); + + const response = await fetch(authUrl.toString(), { redirect: "manual" }); + // Dex shows login page or redirects + expect([200, 302, 303]).toContain(response.status); + }); + }); +}); diff --git a/tests/integration/setup/dex-config.yaml b/tests/integration/setup/dex-config.yaml new file mode 100644 index 00000000..4ef39bf5 --- /dev/null +++ b/tests/integration/setup/dex-config.yaml @@ -0,0 +1,31 @@ +issuer: http://dex:5556/dex + +storage: + type: memory + +web: + http: 0.0.0.0:5556 + +staticClients: + - id: headplane-test + secret: headplane-test-secret + name: Headplane Test + redirectURIs: + - http://localhost:3000/oidc/callback + +connectors: + - type: mockCallback + id: mock + name: Mock + +enablePasswordDB: true + +staticPasswords: + - email: "admin@test.local" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "admin" + userID: "test-admin-uid" + - email: "user@test.local" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "user" + userID: "test-user-uid" diff --git a/tests/integration/setup/start-dex.ts b/tests/integration/setup/start-dex.ts new file mode 100644 index 00000000..58bfe6d9 --- /dev/null +++ b/tests/integration/setup/start-dex.ts @@ -0,0 +1,44 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import tc from "testcontainers"; + +export interface DexEnv { + container: tc.StartedTestContainer; + issuerUrl: string; + clientId: string; + clientSecret: string; +} + +const cwd = fileURLToPath(import.meta.url); +const config = join(cwd, "..", "dex-config.yaml"); + +export async function startDex(network: tc.StartedNetwork): Promise { + const container = await new tc.GenericContainer("dexidp/dex:v2.39.1") + .withExposedPorts(5556) + .withNetwork(network) + .withNetworkAliases("dex") + .withWaitStrategy( + tc.Wait.forHttp("/dex/.well-known/openid-configuration", 5556) + .withStartupTimeout(30_000) + .forStatusCode(200), + ) + .withCopyFilesToContainer([ + { + source: config, + target: "/etc/dex/config.yaml", + }, + ]) + .withCommand(["dex", "serve", "/etc/dex/config.yaml"]) + .start(); + + const host = container.getHost(); + const port = container.getMappedPort(5556); + const issuerUrl = `http://${host}:${port}/dex`; + + return { + container, + issuerUrl, + clientId: "headplane-test", + clientSecret: "headplane-test-secret", + }; +} diff --git a/tests/unit/auth/oidc-linking.test.ts b/tests/unit/auth/oidc-linking.test.ts new file mode 100644 index 00000000..9ae441cc --- /dev/null +++ b/tests/unit/auth/oidc-linking.test.ts @@ -0,0 +1,312 @@ +import { createClient } from "@libsql/client"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/libsql"; +import { ulid } from "ulidx"; +import { beforeEach, describe, expect, test } from "vitest"; + +import { users } from "~/server/db/schema"; +import { Roles } from "~/server/web/roles"; + +function createTestDb() { + const client = createClient({ url: ":memory:" }); + const db = drizzle(client); + return { client, db }; +} + +async function setupSchema(client: ReturnType) { + await client.execute(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + sub TEXT NOT NULL UNIQUE, + caps INTEGER NOT NULL DEFAULT 0, + onboarded INTEGER NOT NULL DEFAULT 0, + headscale_user_id TEXT + ) + `); +} + +// Extract subject from providerId (matches the logic in oidc-callback.ts) +function extractSubject(providerId: string | undefined): string | undefined { + return providerId?.split("/").pop(); +} + +describe("OIDC to Headscale user linking", () => { + let db: ReturnType; + let client: ReturnType; + + beforeEach(async () => { + const testDb = createTestDb(); + db = testDb.db; + client = testDb.client; + await setupSchema(client); + }); + + describe("providerId subject extraction", () => { + test("extracts subject from standard oidc format", () => { + expect(extractSubject("oidc/abc123")).toBe("abc123"); + }); + + test("extracts subject from nested path", () => { + expect(extractSubject("provider/tenant/user-456")).toBe("user-456"); + }); + + test("returns full value when no slash", () => { + expect(extractSubject("plain-subject")).toBe("plain-subject"); + }); + + test("returns undefined for undefined input", () => { + expect(extractSubject(undefined)).toBeUndefined(); + }); + + test("handles empty string", () => { + expect(extractSubject("")).toBe(""); + }); + + test("handles trailing slash", () => { + expect(extractSubject("oidc/")).toBe(""); + }); + }); + + describe("headscale_user_id storage", () => { + test("stores headscale_user_id on new user creation", async () => { + const subject = "oidc-subject-123"; + const headscaleUserId = "hs-user-456"; + + await db.insert(users).values({ + id: ulid(), + sub: subject, + caps: Roles.member, + headscale_user_id: headscaleUserId, + }); + + const [user] = await db.select().from(users).where(eq(users.sub, subject)); + expect(user.headscale_user_id).toBe(headscaleUserId); + }); + + test("creates user without headscale_user_id when no match", async () => { + const subject = "unlinked-subject"; + + await db.insert(users).values({ + id: ulid(), + sub: subject, + caps: Roles.member, + headscale_user_id: undefined, + }); + + const [user] = await db.select().from(users).where(eq(users.sub, subject)); + expect(user.headscale_user_id).toBeNull(); + }); + + test("updates existing user with headscale_user_id", async () => { + const subject = "existing-user"; + const headscaleUserId = "hs-newly-linked"; + + // Create user without link + await db.insert(users).values({ + id: ulid(), + sub: subject, + caps: Roles.member, + headscale_user_id: undefined, + }); + + // Later login finds a match + await db + .update(users) + .set({ headscale_user_id: headscaleUserId }) + .where(eq(users.sub, subject)); + + const [user] = await db.select().from(users).where(eq(users.sub, subject)); + expect(user.headscale_user_id).toBe(headscaleUserId); + }); + + test("preserves existing link on subsequent logins", async () => { + const subject = "stable-user"; + const headscaleUserId = "hs-stable-link"; + + await db.insert(users).values({ + id: ulid(), + sub: subject, + caps: Roles.member, + headscale_user_id: headscaleUserId, + }); + + // Simulate insert with onConflictDoNothing (login flow) + await db + .insert(users) + .values({ + id: ulid(), + sub: subject, + caps: Roles.member, + headscale_user_id: headscaleUserId, + }) + .onConflictDoNothing(); + + const [user] = await db.select().from(users).where(eq(users.sub, subject)); + expect(user.headscale_user_id).toBe(headscaleUserId); + }); + }); + + describe("user matching simulation", () => { + interface MockHeadscaleUser { + id: string; + name: string; + providerId?: string; + } + + function findMatchingUser( + hsUsers: MockHeadscaleUser[], + oidcSubject: string, + ): MockHeadscaleUser | undefined { + return hsUsers.find((u) => { + const userSubject = extractSubject(u.providerId); + return userSubject === oidcSubject; + }); + } + + test("matches user by providerId subject", () => { + const hsUsers: MockHeadscaleUser[] = [ + { id: "1", name: "alice", providerId: "oidc/alice-sub" }, + { id: "2", name: "bob", providerId: "oidc/bob-sub" }, + { id: "3", name: "charlie" }, // no providerId + ]; + + const match = findMatchingUser(hsUsers, "bob-sub"); + expect(match?.id).toBe("2"); + expect(match?.name).toBe("bob"); + }); + + test("returns undefined when no match", () => { + const hsUsers: MockHeadscaleUser[] = [ + { id: "1", name: "alice", providerId: "oidc/alice-sub" }, + ]; + + const match = findMatchingUser(hsUsers, "unknown-sub"); + expect(match).toBeUndefined(); + }); + + test("handles users without providerId", () => { + const hsUsers: MockHeadscaleUser[] = [ + { id: "1", name: "local-user" }, + { id: "2", name: "another-local" }, + ]; + + const match = findMatchingUser(hsUsers, "any-subject"); + expect(match).toBeUndefined(); + }); + + test("matches first user when multiple have same subject", () => { + const hsUsers: MockHeadscaleUser[] = [ + { id: "1", name: "first", providerId: "oidc/dupe-sub" }, + { id: "2", name: "second", providerId: "oidc/dupe-sub" }, + ]; + + const match = findMatchingUser(hsUsers, "dupe-sub"); + expect(match?.id).toBe("1"); + }); + + test("handles empty user list", () => { + const match = findMatchingUser([], "any-subject"); + expect(match).toBeUndefined(); + }); + }); + + describe("full login flow simulation", () => { + interface MockHeadscaleUser { + id: string; + name: string; + providerId?: string; + } + + async function simulateOidcLogin( + db: ReturnType, + oidcSubject: string, + hsUsers: MockHeadscaleUser[], + integrateHeadscale: boolean, + ) { + let headscaleUserId: string | undefined; + + if (integrateHeadscale) { + const match = hsUsers.find((u) => { + const userSubject = u.providerId?.split("/").pop(); + return userSubject === oidcSubject; + }); + headscaleUserId = match?.id; + } + + await db + .insert(users) + .values({ + id: ulid(), + sub: oidcSubject, + caps: Roles.member, + headscale_user_id: headscaleUserId, + }) + .onConflictDoNothing(); + + if (headscaleUserId) { + await db + .update(users) + .set({ headscale_user_id: headscaleUserId }) + .where(eq(users.sub, oidcSubject)); + } + + return headscaleUserId; + } + + test("links user when integrate_headscale enabled and match found", async () => { + const hsUsers: MockHeadscaleUser[] = [ + { id: "hs-123", name: "alice", providerId: "oidc/alice-oidc" }, + ]; + + const linkedId = await simulateOidcLogin(db, "alice-oidc", hsUsers, true); + expect(linkedId).toBe("hs-123"); + + const [user] = await db.select().from(users).where(eq(users.sub, "alice-oidc")); + expect(user.headscale_user_id).toBe("hs-123"); + }); + + test("creates user without link when integrate_headscale disabled", async () => { + const hsUsers: MockHeadscaleUser[] = [ + { id: "hs-123", name: "alice", providerId: "oidc/alice-oidc" }, + ]; + + const linkedId = await simulateOidcLogin(db, "alice-oidc", hsUsers, false); + expect(linkedId).toBeUndefined(); + + const [user] = await db.select().from(users).where(eq(users.sub, "alice-oidc")); + expect(user.headscale_user_id).toBeNull(); + }); + + test("creates user without link when no matching providerId", async () => { + const hsUsers: MockHeadscaleUser[] = [ + { id: "hs-456", name: "bob", providerId: "oidc/bob-oidc" }, + ]; + + const linkedId = await simulateOidcLogin(db, "charlie-oidc", hsUsers, true); + expect(linkedId).toBeUndefined(); + + const [user] = await db.select().from(users).where(eq(users.sub, "charlie-oidc")); + expect(user.headscale_user_id).toBeNull(); + }); + + test("updates link on returning user login", async () => { + // First login without link + await db.insert(users).values({ + id: ulid(), + sub: "returning-user", + caps: Roles.member, + headscale_user_id: undefined, + }); + + // Headscale user created after initial login + const hsUsers: MockHeadscaleUser[] = [ + { id: "hs-789", name: "returning", providerId: "oidc/returning-user" }, + ]; + + await simulateOidcLogin(db, "returning-user", hsUsers, true); + + const [user] = await db.select().from(users).where(eq(users.sub, "returning-user")); + expect(user.headscale_user_id).toBe("hs-789"); + }); + }); +}); diff --git a/tests/unit/sessions/sessions.test.ts b/tests/unit/sessions/sessions.test.ts index 90700db1..7abb1e84 100644 --- a/tests/unit/sessions/sessions.test.ts +++ b/tests/unit/sessions/sessions.test.ts @@ -21,7 +21,8 @@ async function setupSchema(client: ReturnType) { id TEXT PRIMARY KEY, sub TEXT NOT NULL UNIQUE, caps INTEGER NOT NULL DEFAULT 0, - onboarded INTEGER NOT NULL DEFAULT 0 + onboarded INTEGER NOT NULL DEFAULT 0, + headscale_user_id TEXT ) `); }