From 3146dc82a630ccf400702e052c59e06700e87fe3 Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Tue, 25 Aug 2026 21:00:19 +0000 Subject: [PATCH 1/7] refactor(auth): migrate authentication to better auth - Replace legacy sessions and OIDC auth with Better Auth - Preserve existing user IDs, credentials, adapters, and integration flows - Add migration coverage and update authentication documentation --- .tests/auth/auth-adapters.test.js | 212 +++++ .tests/auth/better-auth-core.int.test.js | 188 +++++ .tests/auth/better-auth-migration.int.test.js | 117 +++ .tests/auth/lidarr-preferences.int.test.js | 7 +- .tests/auth/navidrome-settings.int.test.js | 4 +- .tests/auth/oidc-auth.test.js | 379 --------- .tests/auth/proxy-auth.test.js | 59 +- .../auth/quality-profile-settings.int.test.js | 4 +- .tests/auth/session-helpers.test.js | 79 -- .tests/frontend/better-auth-contracts.test.js | 184 ++++ .tests/helpers/backendTestHarness.js | 9 + .tests/helpers/betterAuthFixtures.js | 170 ++++ .../subsonic/subsonic-canonical.int.test.js | 12 +- .../plex-global-account-owner.int.test.js | 4 +- .tests/users/plex-link-routes.int.test.js | 7 +- backend/config/db-sqlite.js | 138 ++- backend/config/session-helpers.js | 88 -- backend/db/helpers/users.js | 57 +- backend/middleware/auth.js | 263 +++--- backend/package.json | 4 +- backend/routes/auth.js | 96 +-- backend/routes/health.js | 10 +- backend/routes/onboarding.js | 50 +- backend/routes/users.js | 70 +- backend/scripts/resetAdminPassword.js | 46 +- backend/server.js | 31 +- backend/services/betterAuth.js | 298 +++++++ backend/services/honkerDb.js | 6 - backend/services/oidcAuth.js | 306 ------- backend/services/systemTaskWorker.js | 4 - backend/services/websocketService.js | 14 +- docker-compose.example.yml | 2 + docs/architecture/0002-better-auth.md | 49 ++ docs/src/content/docs/admin/environment.mdx | 52 +- docs/src/content/docs/admin/storage.mdx | 8 +- docs/src/content/docs/admin/users.mdx | 121 +-- docs/src/content/docs/api/endpoints.mdx | 29 +- docs/src/content/docs/api/overview.mdx | 30 +- .../content/docs/getting-started/docker.mdx | 12 +- .../docs/getting-started/first-run.mdx | 2 +- frontend/src/contexts/AuthContext.jsx | 14 +- frontend/src/pages/Login.jsx | 36 +- frontend/src/pages/Onboarding.jsx | 43 +- frontend/src/pages/Settings/SettingsPage.jsx | 10 +- .../components/AdminPlexLinkField.jsx | 7 +- .../Settings/components/SettingsUsersTab.jsx | 117 ++- .../pages/Settings/hooks/useSettingsUsers.js | 23 +- .../src/pages/Settings/settingsTabsConfig.js | 2 + frontend/src/pages/SsoComplete.jsx | 20 +- frontend/src/utils/api/core.js | 18 +- frontend/src/utils/api/endpoints/auth.js | 93 ++- package-lock.json | 785 ++++++++++++++++-- 52 files changed, 2865 insertions(+), 1524 deletions(-) create mode 100644 .tests/auth/auth-adapters.test.js create mode 100644 .tests/auth/better-auth-core.int.test.js create mode 100644 .tests/auth/better-auth-migration.int.test.js delete mode 100644 .tests/auth/oidc-auth.test.js delete mode 100644 .tests/auth/session-helpers.test.js create mode 100644 .tests/frontend/better-auth-contracts.test.js create mode 100644 .tests/helpers/betterAuthFixtures.js delete mode 100644 backend/config/session-helpers.js create mode 100644 backend/services/betterAuth.js delete mode 100644 backend/services/oidcAuth.js create mode 100644 docs/architecture/0002-better-auth.md diff --git a/.tests/auth/auth-adapters.test.js b/.tests/auth/auth-adapters.test.js new file mode 100644 index 000000000..4f8eb199e --- /dev/null +++ b/.tests/auth/auth-adapters.test.js @@ -0,0 +1,212 @@ +import test, { mock } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import bcrypt from "bcrypt"; + +import { + cleanupIsolatedState, + resetDatabase, + setupIsolatedBackend, +} from "../helpers/backendTestHarness.js"; +import { + assertBetterAuthCoreSchema, + readBetterAuthAccount, + readBetterAuthUser, + seedBetterAuthUser, +} from "../helpers/betterAuthFixtures.js"; + +const [isolatedState, { db }, { dbOps }, authModule] = await setupIsolatedBackend( + "better-auth-adapters", + "backend/config/db-sqlite.js", + "backend/db/helpers/index.js", + "backend/middleware/auth.js", +); + +const { + getApiKey, + getLocalNetworkBypassStatus, + hasPermission, + isRequestFromTrustedLocalSubnet, + issueProxySession, + issueStreamToken, + resolveLocalNetworkBypassUser, + resolveProxyUser, + resolveRequestUser, + resolveSessionUserFromToken, + resolveSubsonicTokenUser, + verifyTokenAuth, + rotateApiKey, +} = authModule; + +const password = "adapter-password"; + +function proxyRequest(headers = {}, remoteAddress = "127.0.0.1") { + return { + headers, + query: {}, + socket: { remoteAddress }, + connection: { remoteAddress }, + ip: remoteAddress, + ips: [remoteAddress], + }; +} + +function resetAdapterEnv() { + for (const key of [ + "AUTH_PROXY_ENABLED", + "AUTH_PROXY_HEADER", + "AUTH_PROXY_TRUSTED_IPS", + "AUTH_PROXY_DEFAULT_ROLE", + "AUTH_PROXY_ADMIN_USERS", + "AUTH_PROXY_ROLE_HEADER", + "AUTH_PROXY_ADMIN_GROUPS", + "AUTH_USER", + "AUTH_PASSWORD", + ]) { + delete process.env[key]; + } +} + +test.beforeEach(() => { + resetDatabase(db); + resetAdapterEnv(); + dbOps.updateSettings({ onboardingComplete: true, integrations: {}, security: {} }); + assertBetterAuthCoreSchema(db); +}); + +test.after(async () => { + resetAdapterEnv(); + await cleanupIsolatedState(isolatedState); +}); + +test("proxy identity provisioning is a Better Auth user with Aurral role and permission mapping", () => { + process.env.AUTH_PROXY_ENABLED = "true"; + process.env.AUTH_PROXY_ADMIN_USERS = "sso-admin"; + + const resolved = resolveProxyUser(proxyRequest({ "x-forwarded-user": "sso-admin" })); + assert.ok(resolved); + assert.equal(resolved.role, "admin"); + assert.equal(resolved.permissions.accessSettings, true); + + const user = readBetterAuthUser(db, resolved.id); + assert.match(user.email, /^proxy-[a-f0-9]+@aurral\.invalid$/); + assert.equal(user.name, "sso-admin"); + assert.equal(user.role, "admin"); + assert.equal(resolveProxyUser(proxyRequest({ "x-forwarded-user": "sso-admin" })).id, resolved.id); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM "users"').get().count, 1); +}); + +test("proxy identity headers are trusted only from configured addresses and sessions survive header removal", async () => { + process.env.AUTH_PROXY_ENABLED = "true"; + process.env.AUTH_PROXY_TRUSTED_IPS = "10.0.0.1"; + assert.equal(resolveProxyUser(proxyRequest({ "x-forwarded-user": "mallory" })), null); + + delete process.env.AUTH_PROXY_TRUSTED_IPS; + const issued = await issueProxySession(proxyRequest({ "x-forwarded-user": "erin" })); + assert.ok(issued?.token); + const user = await resolveSessionUserFromToken(issued.token); + assert.equal(user?.username, "erin"); + assert.match(readBetterAuthUser(db, user.id).email, /^proxy-[a-f0-9]+@aurral\.invalid$/); +}); + +test("LAN bypass resolves the sole Better Auth admin and stays ineligible for multiple users", () => { + seedBetterAuthUser(db, { + id: 41, + email: "admin@example.com", + name: "Admin", + username: "admin", + password: bcrypt.hashSync(password, 4), + role: "admin", + }); + dbOps.updateSettings({ + onboardingComplete: true, + security: { localNetworkBypass: { enabled: true } }, + }); + + const request = proxyRequest(); + assert.equal(isRequestFromTrustedLocalSubnet(request), true); + const status = getLocalNetworkBypassStatus(request); + assert.equal(status.active, status.eligible); + if (status.active) assert.equal(resolveLocalNetworkBypassUser(request).id, 41); + + seedBetterAuthUser(db, { + id: 42, + email: "second@example.com", + name: "Second", + username: "second", + password: bcrypt.hashSync(password, 4), + }); + assert.equal(getLocalNetworkBypassStatus(request).reason, "not_single_user"); + assert.equal(resolveLocalNetworkBypassUser(request), null); +}); + +test("instance API keys remain a separate admin adapter and rotation invalidates the old key", () => { + const first = getApiKey(); + assert.match(first, /^[a-f0-9]{64}$/); + const firstUser = resolveRequestUser({ headers: { "x-api-key": first }, query: {} }); + assert.equal(firstUser.role, "admin"); + assert.equal(hasPermission(firstUser, "accessSettings"), true); + + const second = rotateApiKey(); + assert.notEqual(second, first); + assert.equal(resolveRequestUser({ headers: { "x-api-key": first }, query: {} }), null); + assert.equal(resolveRequestUser({ headers: { "x-api-key": second }, query: {} }).role, "admin"); +}); + +test("Subsonic MD5 tokens use the protocol-specific shared secret", async () => { + const credentialHash = bcrypt.hashSync(password, 4); + seedBetterAuthUser(db, { + id: 51, + email: "subsonic@example.com", + name: "Subsonic User", + username: "subsonic", + password: credentialHash, + }); + const salt = "test-salt"; + process.env.AUTH_USER = "subsonic"; + process.env.AUTH_PASSWORD = password; + const token = createHash("md5").update(`${password}${salt}`).digest("hex"); + + const user = await resolveSubsonicTokenUser("subsonic", token, salt); + assert.equal(user?.id, 51); + assert.equal(user?.username, "subsonic"); + assert.match(readBetterAuthAccount(db, 51)[0].password, /^scrypt\$/); +}); + +test("media stream tokens remain short-lived Aurral adapter credentials", () => { + seedBetterAuthUser(db, { + id: 61, + email: "media@example.com", + name: "Media User", + username: "media-user", + password: bcrypt.hashSync(password, 4), + }); + const user = { + id: 61, + username: "media-user", + role: "user", + permissions: { addArtist: true }, + }; + const token = issueStreamToken(user, 1000); + const request = { query: { st: token }, headers: {} }; + assert.equal(verifyTokenAuth(request), true); + assert.equal(request.user.id, user.id); + assert.equal(request.user.username, user.username); + + const now = Date.now(); + const clock = mock.method(Date, "now", () => now + 2000); + try { + const expiredRequest = { query: { st: token }, headers: {} }; + assert.equal(verifyTokenAuth(expiredRequest), false); + } finally { + clock.mock.restore(); + } +}); + +test("Aurral permissions remain an adapter over Better Auth role fields", () => { + assert.equal(hasPermission({ role: "admin", permissions: {} }, "deleteTrack"), true); + assert.equal(hasPermission({ role: "user", permissions: { addArtist: true } }, "addArtist"), true); + assert.equal(hasPermission({ role: "user", permissions: { addArtist: false } }, "addArtist"), false); + assert.equal(hasPermission(null, "addArtist"), false); +}); diff --git a/.tests/auth/better-auth-core.int.test.js b/.tests/auth/better-auth-core.int.test.js new file mode 100644 index 000000000..cab04b89c --- /dev/null +++ b/.tests/auth/better-auth-core.int.test.js @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + cleanupIsolatedState, + resetDatabase, + setupIsolatedBackend, + startServerProcess, +} from "../helpers/backendTestHarness.js"; +import { + assertBetterAuthCoreSchema, + readBetterAuthSessions, + readBetterAuthUser, + requestBetterAuth, +} from "../helpers/betterAuthFixtures.js"; + +const [isolatedState, { db }, { dbOps }] = await setupIsolatedBackend( + "better-auth-core", + "backend/config/db-sqlite.js", + "backend/db/helpers/index.js", +); + +let server; + +const password = "better-auth-password"; + +async function signUp(overrides = {}) { + return requestBetterAuth(server, "/sign-up/email", { + method: "POST", + body: { + email: "alice@example.com", + name: "Alice Example", + username: "alice", + password, + ...overrides, + }, + }); +} + +async function signIn(email = "alice@example.com", secret = password) { + return requestBetterAuth(server, "/sign-in/email", { + method: "POST", + body: { email, password: secret }, + }); +} + +test.before(async () => { + resetDatabase(db); + dbOps.updateSettings({ onboardingComplete: false, integrations: {} }); + process.env.BETTER_AUTH_SECRET = "aurral-test-secret-for-better-auth"; + server = await startServerProcess(); +}); + +test.after(async () => { + await server?.stop(); + delete process.env.BETTER_AUTH_SECRET; + await cleanupIsolatedState(isolatedState); +}); + +test("Better Auth core schema owns users, credentials, sessions, and username fields", async () => { + assertBetterAuthCoreSchema(db); + + const result = await signUp(); + assert.equal(result.response.status, 200, JSON.stringify(result.payload)); + assert.ok(result.authToken); + assert.deepEqual( + { + email: result.payload.user.email, + name: result.payload.user.name, + username: result.payload.user.username, + }, + { + email: "alice@example.com", + name: "Alice Example", + username: "alice", + }, + ); + assert.equal(typeof result.payload.user.id, "string"); + + const user = readBetterAuthUser(db, result.payload.user.id); + assert.equal(Number.isInteger(user.id), true); + assert.equal(user.email, "alice@example.com"); + assert.equal(user.name, "Alice Example"); + assert.equal(user.username, "alice"); + + const account = db + .prepare('SELECT * FROM "accounts" WHERE "user_id" = ?') + .get(user.id); + assert.equal(account.issuer, "local:credential"); + assert.equal(account.provider_id, "credential"); + assert.equal(String(account.account_id), String(user.id)); + assert.ok(account.password); + + const sessions = readBetterAuthSessions(db, user.id); + assert.equal(sessions.length, 1); + assert.equal(sessions[0].user_id, user.id); + assert.ok(sessions[0].token); + assert.ok(new Date(sessions[0].expires_at).getTime() > Date.now()); +}); + +test("Better Auth sign-up requires the documented email and name fields", async () => { + const missingEmail = await signUp({ email: undefined, username: "missing-email" }); + assert.equal(missingEmail.response.ok, false); + + const missingName = await signUp({ + email: "missing-name@example.com", + name: undefined, + username: "missing-name", + }); + assert.equal(missingName.response.ok, false); +}); + +test("Bearer sign-in, session lookup, sign-out, and expiry use Better Auth sessions", async () => { + const created = await signUp({ + email: "sessions@example.com", + name: "Session User", + username: "sessions", + }); + assert.equal(created.response.status, 200, JSON.stringify(created.payload)); + const userId = created.payload.user.id; + + const signedOut = await requestBetterAuth(server, "/sign-out", { + method: "POST", + token: created.authToken, + }); + assert.equal(signedOut.response.status, 200, JSON.stringify(signedOut.payload)); + + const signedIn = await signIn("sessions@example.com"); + assert.equal(signedIn.response.status, 200, JSON.stringify(signedIn.payload)); + assert.ok(signedIn.authToken); + + const current = await requestBetterAuth(server, "/get-session", { + token: signedIn.authToken, + }); + assert.equal(current.response.status, 200, JSON.stringify(current.payload)); + assert.equal(current.payload.user.id, userId); + assert.equal(current.payload.session.userId, userId); + + const activeSessions = readBetterAuthSessions(db, userId); + assert.equal(activeSessions.length, 1); + + const loggedOut = await requestBetterAuth(server, "/sign-out", { + method: "POST", + token: signedIn.authToken, + }); + assert.equal(loggedOut.response.status, 200, JSON.stringify(loggedOut.payload)); + + const afterLogout = await requestBetterAuth(server, "/get-session", { + token: signedIn.authToken, + }); + assert.equal(afterLogout.response.status, 200); + assert.equal(afterLogout.payload, null); + assert.equal(readBetterAuthSessions(db, userId).length, 0); + + const expiryCandidate = await signUp({ + email: "expired@example.com", + name: "Expired User", + username: "expired", + }); + const expiryUserId = expiryCandidate.payload.user.id; + db.prepare('UPDATE "sessions" SET "expires_at" = ? WHERE "user_id" = ?').run( + new Date(Date.now() - 1000).toISOString(), + expiryUserId, + ); + + const expired = await requestBetterAuth(server, "/get-session", { + token: expiryCandidate.authToken, + }); + assert.equal(expired.response.status, 200); + assert.equal(expired.payload, null); +}); + +test("Better Auth bearer sessions persist across an Aurral restart", async () => { + const created = await signUp({ + email: "restart@example.com", + name: "Restart User", + username: "restart", + }); + const userId = created.payload.user.id; + const token = created.authToken; + await server.stop(); + server = await startServerProcess(); + + const restored = await requestBetterAuth(server, "/get-session", { token }); + assert.equal(restored.response.status, 200, JSON.stringify(restored.payload)); + assert.equal(restored.payload.user.id, userId); + assert.equal(restored.payload.session.userId, userId); +}); diff --git a/.tests/auth/better-auth-migration.int.test.js b/.tests/auth/better-auth-migration.int.test.js new file mode 100644 index 000000000..02af90f85 --- /dev/null +++ b/.tests/auth/better-auth-migration.int.test.js @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import bcrypt from "bcrypt"; + +import { + cleanupIsolatedState, + resetDatabase, + setupIsolatedBackend, + startServerProcess, +} from "../helpers/backendTestHarness.js"; +import { + assertBetterAuthCoreSchema, + readBetterAuthAccount, + requestBetterAuth, +} from "../helpers/betterAuthFixtures.js"; + +const [isolatedState, { db }, { dbOps, userOps }] = await setupIsolatedBackend( + "better-auth-migration", + "backend/config/db-sqlite.js", + "backend/db/helpers/index.js", +); + +let server; +const legacyPassword = "legacy-password"; + +test.before(async () => { + resetDatabase(db); + dbOps.updateSettings({ onboardingComplete: true, integrations: {} }); + + const legacyHash = bcrypt.hashSync(legacyPassword, 4); + const legacyUser = userOps.createUser("legacy@example.com", legacyHash, "user"); + db.prepare( + `INSERT INTO play_events + (user_id, track_id, title, artist, played_at, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + legacyUser.id, + "legacy-track", + "Legacy Track", + "Legacy Artist", + Date.now(), + "migration-test", + Date.now(), + ); + + process.env.BETTER_AUTH_SECRET = "aurral-test-secret-for-better-auth"; + server = await startServerProcess(); +}); + +test.after(async () => { + await server?.stop(); + delete process.env.BETTER_AUTH_SECRET; + await cleanupIsolatedState(isolatedState); +}); + +test("migrates legacy bcrypt credentials into Better Auth without changing numeric app IDs", async () => { + assertBetterAuthCoreSchema(db); + + const legacyUser = db + .prepare('SELECT id FROM "users" WHERE username = ?') + .get("legacy@example.com"); + const migratedUser = db + .prepare('SELECT * FROM "users" WHERE email = ?') + .get("legacy@example.com"); + assert.ok(migratedUser); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM "users" WHERE email = ?').get("legacy@example.com").count, + 1, + ); + assert.equal( + db + .prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'user'") + .get().count, + 0, + ); + assert.equal(Number(migratedUser.id), legacyUser.id); + assert.equal(Number.isInteger(legacyUser.id), true); + assert.equal(migratedUser.email, "legacy@example.com"); + assert.ok(String(migratedUser.name).trim()); + + const credentialAccounts = readBetterAuthAccount(db, migratedUser.id).filter( + (account) => account.provider_id === "credential", + ); + assert.equal(credentialAccounts.length, 1); + const credential = credentialAccounts[0]; + assert.equal(credential.issuer, "local:credential"); + assert.equal(String(credential.account_id), String(migratedUser.id)); + assert.equal(await bcrypt.compare(legacyPassword, credential.password), true); + + for (const table of ["lastfm_link_states", "subsonic_stars", "play_events", "inbox_items"]) { + const foreignKeys = db.pragma(`foreign_key_list(${table})`); + assert.equal( + foreignKeys.some( + (foreignKey) => + foreignKey.table === "users" && foreignKey.from === "user_id" && foreignKey.to === "id", + ), + true, + `${table}.user_id must continue referencing users.id`, + ); + } + + const appData = db + .prepare('SELECT user_id, track_id FROM "play_events" WHERE track_id = ?') + .get("legacy-track"); + assert.equal(appData.user_id, legacyUser.id); + assert.equal(Number(migratedUser.id), appData.user_id); + + const login = await requestBetterAuth(server, "/sign-in/email", { + method: "POST", + body: { email: "legacy@example.com", password: legacyPassword }, + }); + assert.equal(login.response.status, 200, JSON.stringify(login.payload)); + assert.ok(login.authToken); + assert.equal(String(login.payload.user.id), String(legacyUser.id)); + assert.equal(await bcrypt.compare(legacyPassword, credential.password), true); +}); diff --git a/.tests/auth/lidarr-preferences.int.test.js b/.tests/auth/lidarr-preferences.int.test.js index efd201404..0e48ba345 100644 --- a/.tests/auth/lidarr-preferences.int.test.js +++ b/.tests/auth/lidarr-preferences.int.test.js @@ -226,14 +226,13 @@ async function apiFetch(path, options = {}) { } async function loginAsAdmin() { - const response = await fetch(`http://127.0.0.1:${server.port}/api/auth/login`, { + const response = await fetch(`http://127.0.0.1:${server.port}/api/auth/sign-in/username`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "admin", password: "password123" }), }); - const payload = await response.json(); - assert.equal(response.status, 200); - return payload.token; + assert.equal(response.status, 200, await response.text()); + return response.headers.get("set-auth-token"); } async function saveLidarrSettings({ diff --git a/.tests/auth/navidrome-settings.int.test.js b/.tests/auth/navidrome-settings.int.test.js index 60fe10b54..b939ad1e7 100644 --- a/.tests/auth/navidrome-settings.int.test.js +++ b/.tests/auth/navidrome-settings.int.test.js @@ -80,12 +80,12 @@ test.before(async () => { navidromeUrl = `http://127.0.0.1:${navidrome.address().port}`; aurral = await startServerProcess(); - const login = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/login`, { + const login = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/sign-in/username`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "admin", password: "password123" }), }); - authToken = (await login.json()).token; + authToken = login.headers.get("set-auth-token"); assert.equal(login.status, 200); }); diff --git a/.tests/auth/oidc-auth.test.js b/.tests/auth/oidc-auth.test.js deleted file mode 100644 index 03c95452d..000000000 --- a/.tests/auth/oidc-auth.test.js +++ /dev/null @@ -1,379 +0,0 @@ -import test, { mock } from "node:test"; -import assert from "node:assert/strict"; -import { createSign, generateKeyPairSync } from "node:crypto"; - -import { - createMockHttpServer, - setupIsolatedBackend, - cleanupIsolatedState, - resetDatabase, -} from "../helpers/backendTestHarness.js"; - -const [isolatedState, { db }, dbHelpers, authModule, sessionModule, oidcModule] = - await setupIsolatedBackend( - "oidc-auth", - "backend/config/db-sqlite.js", - "backend/db/helpers/index.js", - "backend/middleware/auth.js", - "backend/config/session-helpers.js", - "backend/services/oidcAuth.js", - ); - -const { dbOps, userOps } = dbHelpers; -const { ensureExternalUser, isAuthRequiredByConfig, isOidcAuthEnabled } = authModule; -const { createSession, getSessionByToken } = sessionModule; -const { - exchangeOidcCallback, - isOidcEnabled, - resolveOidcUsername, - resolveOidcRole, - getOidcBootstrapInfo, - handleOidcCallback, - resetOidcStateForTests, - startOidcLogin, -} = oidcModule; - -const completeOnboarding = () => dbOps.updateSettings({ onboardingComplete: true }); - -const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); -const oidcKey = { ...publicKey.export({ format: "jwk" }), kid: "test-key", use: "sig", alg: "RS256" }; - -const createIdToken = (issuer, nonce, claimOverrides = {}) => { - const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); - const header = encode({ alg: "RS256", kid: oidcKey.kid, typ: "JWT" }); - const payload = encode({ - iss: issuer, - aud: "aurral", - sub: "oidc-subject", - preferred_username: "callback-user", - nonce, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 300, - ...claimOverrides, - }); - const input = `${header}.${payload}`; - const signature = createSign("RSA-SHA256").update(input).sign(privateKey).toString("base64url"); - return `${input}.${signature}`; -}; - -function resetOidcEnv() { - delete process.env.OIDC_ENABLED; - delete process.env.OIDC_ISSUER; - delete process.env.OIDC_CLIENT_ID; - delete process.env.OIDC_CLIENT_SECRET; - delete process.env.OIDC_REDIRECT_URI; - delete process.env.OIDC_SCOPES; - delete process.env.OIDC_USERNAME_CLAIM; - delete process.env.OIDC_DEFAULT_ROLE; - delete process.env.OIDC_ADMIN_USERS; - delete process.env.OIDC_GROUPS_CLAIM; - delete process.env.OIDC_ADMIN_GROUPS; - delete process.env.OIDC_LOGOUT_URL; - delete process.env.AUTH_PROXY_ENABLED; - delete process.env.AUTH_PROXY_HEADER; - resetOidcStateForTests(); -} - -function enableOidcEnv(overrides = {}) { - process.env.OIDC_ENABLED = "true"; - process.env.OIDC_ISSUER = "https://auth.example.com/application/o/aurral/"; - process.env.OIDC_CLIENT_ID = "aurral"; - process.env.OIDC_CLIENT_SECRET = "secret"; - process.env.OIDC_REDIRECT_URI = "https://aurral.example.com/sso/callback"; - Object.assign(process.env, overrides); -} - -async function createPendingOidcLogin({ idTokenClaims = {}, userInfo = null, userInfoError = false } = {}) { - let issuer; - let nonce; - const discoveryServer = await createMockHttpServer((request, response) => { - if (request.url === "/jwks") { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ keys: [oidcKey] })); - return; - } - if (request.method === "POST" && request.url === "/token") { - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - access_token: "access-token", - token_type: "Bearer", - id_token: createIdToken(issuer, nonce, idTokenClaims), - }), - ); - return; - } - if (request.url === "/userinfo") { - response.writeHead(userInfoError ? 500 : 200, { "content-type": "application/json" }); - response.end(JSON.stringify(userInfoError ? { error: "userinfo_unavailable" } : userInfo)); - return; - } - response.writeHead(200, { "content-type": "application/json" }); - response.end( - JSON.stringify({ - issuer, - authorization_endpoint: `${issuer}authorize`, - token_endpoint: `${issuer}token`, - ...(userInfo ? { userinfo_endpoint: `${issuer}userinfo` } : {}), - jwks_uri: `${issuer}jwks`, - }), - ); - }); - issuer = `${discoveryServer.url}/`; - enableOidcEnv({ - OIDC_ISSUER: issuer, - OIDC_REDIRECT_URI: `${issuer}callback`, - }); - - const response = { - headers: {}, - redirect(_status, location) { - this.location = location; - }, - setHeader(name, value) { - this.headers[name] = value; - }, - }; - await startOidcLogin({}, response); - const redirect = new URL(response.location); - const state = redirect.searchParams.get("state"); - nonce = redirect.searchParams.get("nonce"); - assert.ok(state, "OIDC login redirect must include state"); - const setCookie = response.headers["Set-Cookie"]; - assert.ok(setCookie, "OIDC login must set a transaction cookie"); - return { - state, - nonce, - cookie: setCookie.split(";", 1)[0], - close: discoveryServer.close, - }; -} - -test.beforeEach(() => { - resetDatabase(db); - resetOidcEnv(); - dbOps.updateSettings({ onboardingComplete: false }); -}); - -test.after(async () => { - resetOidcEnv(); - await cleanupIsolatedState(isolatedState); -}); - -test("OIDC username prefers configured claim then email", () => { - assert.equal( - resolveOidcUsername({ preferred_username: "Alice", email: "alice@example.com" }), - "alice", - ); - assert.equal(resolveOidcUsername({ email: "Alice@example.com" }), "alice@example.com"); - - process.env.OIDC_USERNAME_CLAIM = "nickname"; - assert.equal(resolveOidcUsername({ nickname: "Bob", email: "bob@example.com" }), "bob"); - assert.equal(resolveOidcUsername({}), ""); -}); - -test("OIDC role mapping uses admin users and groups claim", () => { - assert.equal(resolveOidcRole("carol", {}), "user"); - - process.env.OIDC_ADMIN_USERS = "carol"; - assert.equal(resolveOidcRole("carol", {}), "admin"); - - delete process.env.OIDC_ADMIN_USERS; - process.env.OIDC_GROUPS_CLAIM = "groups"; - process.env.OIDC_ADMIN_GROUPS = "aurral-admins"; - assert.equal(resolveOidcRole("dave", { groups: ["users", "aurral-admins"] }), "admin"); - assert.equal(resolveOidcRole("erin", { groups: "users,aurral-admins" }), "admin"); - assert.equal(resolveOidcRole("frank", { groups: ["users"] }), "user"); - assert.equal(resolveOidcRole("gina", { groups: ["admin"] }), "user"); - - process.env.OIDC_DEFAULT_ROLE = "admin"; - assert.equal(resolveOidcRole("hank", { groups: ["users"] }), "admin"); -}); - -test("ensureExternalUser JIT-creates and re-syncs role", () => { - const created = ensureExternalUser("oidc-user", "user"); - assert.ok(created); - assert.equal(created.username, "oidc-user"); - assert.equal(created.role, "user"); - assert.equal(userOps.getAllUsers().length, 1); - - const promoted = ensureExternalUser("oidc-user", "admin"); - assert.equal(promoted.id, created.id); - assert.equal(promoted.role, "admin"); - assert.equal(userOps.getUserByUsername("oidc-user")?.role, "admin"); - assert.equal(userOps.getAllUsers().length, 1); -}); - -test("OIDC enablement requires full config and marks auth required after onboarding", () => { - process.env.OIDC_ENABLED = "true"; - assert.equal(isOidcEnabled(), false); - assert.equal(getOidcBootstrapInfo().oidcEnabled, false); - - enableOidcEnv(); - assert.equal(isOidcEnabled(), true); - assert.equal(isOidcAuthEnabled(), true); - assert.equal(getOidcBootstrapInfo().oidcEnabled, true); - - assert.equal(isAuthRequiredByConfig(), false); - completeOnboarding(); - assert.equal(isAuthRequiredByConfig(), true); -}); - -test("OIDC bootstrap exposes logout URL when configured", () => { - enableOidcEnv({ OIDC_LOGOUT_URL: "https://auth.example.com/logout" }); - assert.deepEqual(getOidcBootstrapInfo(), { - oidcEnabled: true, - oidcLogoutUrl: "https://auth.example.com/logout", - }); -}); - -test("OIDC-provisioned users get normal Aurral sessions", () => { - completeOnboarding(); - const user = ensureExternalUser("sso-erin", "user"); - const session = createSession(user.id, "127.0.0.1", "test-agent"); - assert.ok(session?.token); - assert.equal(getSessionByToken(session.token)?.user?.username, "sso-erin"); -}); - -test("OIDC callback issues a cookie-bound one-time session exchange", async () => { - const pending = await createPendingOidcLogin(); - - try { - const callback = await handleOidcCallback({ - query: { state: pending.state, code: "authorization-code" }, - headers: { cookie: pending.cookie }, - ip: "127.0.0.1", - }); - assert.ok(callback.code); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, 0); - - assert.throws( - () => exchangeOidcCallback(callback.code, { headers: { cookie: "aurral_oidc_transaction=wrong" } }), - { status: 400, message: "OIDC login session expired" }, - ); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, 0); - - const session = exchangeOidcCallback(callback.code, { - headers: { cookie: pending.cookie, "user-agent": "test-agent" }, - ip: "127.0.0.1", - }); - assert.ok(session.token); - assert.equal(getSessionByToken(session.token)?.user?.username, "callback-user"); - assert.throws( - () => exchangeOidcCallback(callback.code, { headers: { cookie: pending.cookie } }), - { status: 400, message: "OIDC login session expired" }, - ); - } finally { - await pending.close(); - } -}); - -test("OIDC callback combines UserInfo with ID-token claims", async () => { - process.env.OIDC_GROUPS_CLAIM = "groups"; - process.env.OIDC_ADMIN_GROUPS = "aurral-admins"; - const pending = await createPendingOidcLogin({ - idTokenClaims: { preferred_username: undefined, groups: ["aurral-admins"] }, - userInfo: { - sub: "oidc-subject", - preferred_username: "userinfo-user", - groups: ["regular-users"], - }, - }); - - try { - const callback = await handleOidcCallback({ - query: { state: pending.state, code: "authorization-code" }, - headers: { cookie: pending.cookie }, - ip: "127.0.0.1", - }); - assert.equal(callback.user.username, "userinfo-user"); - assert.equal(callback.user.role, "admin"); - } finally { - await pending.close(); - } -}); - -test("OIDC callback falls back to ID-token claims when UserInfo fails", async () => { - const pending = await createPendingOidcLogin({ - idTokenClaims: { preferred_username: "id-token-user" }, - userInfo: { sub: "oidc-subject" }, - userInfoError: true, - }); - - try { - const callback = await handleOidcCallback({ - query: { state: pending.state, code: "authorization-code" }, - headers: { cookie: pending.cookie }, - ip: "127.0.0.1", - }); - assert.equal(callback.user.username, "id-token-user"); - } finally { - await pending.close(); - } -}); - -test("OIDC groups claim ignores UserInfo-only admin groups", async () => { - process.env.OIDC_GROUPS_CLAIM = "groups"; - process.env.OIDC_ADMIN_GROUPS = "aurral-admins"; - const pending = await createPendingOidcLogin({ - idTokenClaims: { preferred_username: "id-token-user" }, - userInfo: { - sub: "oidc-subject", - preferred_username: "userinfo-user", - groups: ["aurral-admins"], - }, - }); - - try { - const callback = await handleOidcCallback({ - query: { state: pending.state, code: "authorization-code" }, - headers: { cookie: pending.cookie }, - ip: "127.0.0.1", - }); - assert.equal(callback.user.username, "userinfo-user"); - assert.equal(callback.user.role, "user"); - } finally { - await pending.close(); - } -}); - -test("OIDC callback rejects an expired state without creating a session", async () => { - const pending = await createPendingOidcLogin(); - const now = Date.now(); - const clock = mock.method(Date, "now", () => now + 11 * 60 * 1000); - - try { - await assert.rejects( - () => - handleOidcCallback({ - query: { state: pending.state }, - headers: { cookie: pending.cookie }, - ip: "127.0.0.1", - }), - { status: 400, message: "OIDC login session expired" }, - ); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, 0); - } finally { - clock.mock.restore(); - await pending.close(); - } -}); - -test("OIDC callback rejects a mismatched state without creating a session", async () => { - const pending = await createPendingOidcLogin(); - - try { - await assert.rejects( - () => - handleOidcCallback({ - query: { state: `${pending.state}-mismatched` }, - headers: { cookie: pending.cookie }, - ip: "127.0.0.1", - }), - { status: 400, message: "OIDC login session expired" }, - ); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, 0); - } finally { - await pending.close(); - } -}); diff --git a/.tests/auth/proxy-auth.test.js b/.tests/auth/proxy-auth.test.js index e6ab3e6bd..0d8a04e2a 100644 --- a/.tests/auth/proxy-auth.test.js +++ b/.tests/auth/proxy-auth.test.js @@ -6,18 +6,21 @@ import { cleanupIsolatedState, resetDatabase, } from "../helpers/backendTestHarness.js"; +import { + assertBetterAuthCoreSchema, + readBetterAuthSessions, + readBetterAuthUser, +} from "../helpers/betterAuthFixtures.js"; -const [isolatedState, { db }, dbHelpers, authModule, sessionModule] = await setupIsolatedBackend( +const [isolatedState, { db }, dbHelpers, authModule] = await setupIsolatedBackend( "proxy-auth", "backend/config/db-sqlite.js", "backend/db/helpers/index.js", "backend/middleware/auth.js", - "backend/config/session-helpers.js", ); -const { dbOps, userOps } = dbHelpers; +const { dbOps } = dbHelpers; const { issueProxySession, resolveProxyUser, resolveRequestUser } = authModule; -const { getSessionByToken } = sessionModule; const completeOnboarding = () => dbOps.updateSettings({ onboardingComplete: true }); @@ -45,6 +48,7 @@ test.beforeEach(() => { resetDatabase(db); resetProxyEnv(); dbOps.updateSettings({ onboardingComplete: false }); + assertBetterAuthCoreSchema(db); }); test.after(async () => { @@ -71,16 +75,16 @@ test("proxy auth creates a persistent user for a new proxied identity", () => { assert.equal(resolved.permissions.accessFlow, false); assert.equal(resolved.permissions.accessSettings, false); - const stored = userOps.getUserByUsername("Alice@example.com"); + const stored = readBetterAuthUser(db, resolved.id); assert.equal(stored?.id, resolved.id); - assert.equal(stored?.username, "alice@example.com"); - assert.ok(stored?.passwordHash); + assert.equal(stored?.email, "alice@example.com"); + assert.equal(stored?.name, "alice@example.com"); const secondResolve = resolveProxyUser( proxyRequest({ "x-forwarded-user": "alice@example.com" }), ); assert.equal(secondResolve?.id, resolved.id); - assert.equal(userOps.getAllUsers().length, 1); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM "users"').get().count, 1); }); test("proxy auth creates configured admin users as admins", () => { @@ -93,7 +97,7 @@ test("proxy auth creates configured admin users as admins", () => { assert.ok(resolved); assert.equal(resolved.role, "admin"); assert.equal(resolved.permissions.accessSettings, true); - assert.equal(userOps.getUserByUsername("sso-admin")?.role, "admin"); + assert.equal(readBetterAuthUser(db, resolved.id)?.role, "admin"); }); test("proxy auth does not create users from untrusted proxy IPs", () => { @@ -104,7 +108,7 @@ test("proxy auth does not create users from untrusted proxy IPs", () => { ); assert.equal(resolved, null); - assert.equal(userOps.getAllUsers().length, 0); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM "users"').get().count, 0); }); test("proxy auth grants admin via AUTH_PROXY_ADMIN_GROUPS membership", () => { @@ -120,7 +124,7 @@ test("proxy auth grants admin via AUTH_PROXY_ADMIN_GROUPS membership", () => { assert.ok(resolved); assert.equal(resolved.role, "admin"); - assert.equal(userOps.getUserByUsername("bob")?.role, "admin"); + assert.equal(readBetterAuthUser(db, resolved.id)?.role, "admin"); }); test("proxy auth does not grant admin for a literal 'admin' group unless configured", () => { @@ -137,35 +141,40 @@ test("proxy auth does not grant admin for a literal 'admin' group unless configu assert.equal(resolved.role, "user"); }); -test("proxy auth issues one Aurral session that outlives the identity header", () => { +test("proxy auth issues one Aurral session that outlives the identity header", async () => { completeOnboarding(); - const issued = issueProxySession(proxyRequest({ "x-forwarded-user": "erin" })); + const issued = await issueProxySession(proxyRequest({ "x-forwarded-user": "erin" })); assert.ok(issued?.token); - assert.equal(getSessionByToken(issued.token)?.user?.username, "erin"); + const issuedUser = resolveRequestUser(proxyRequest({ "x-forwarded-user": "erin" })); + assert.ok(issuedUser?.id); + assert.equal(readBetterAuthSessions(db, issuedUser.id).length, 1); - const headerlessRequest = proxyRequest({ authorization: `Bearer ${issued.token}` }); - assert.equal(resolveRequestUser(headerlessRequest)?.username, "erin"); + const headerlessUser = await authModule.resolveSessionUserFromToken(issued.token); + assert.equal(headerlessUser?.username, "erin"); - assert.equal(issueProxySession(headerlessRequest), null); + assert.equal( + await issueProxySession(proxyRequest({ authorization: `Bearer ${issued.token}` })), + null, + ); }); -test("proxy auth issues no session without a trusted identity header", () => { +test("proxy auth issues no session without a trusted identity header", async () => { completeOnboarding(); - assert.equal(issueProxySession(proxyRequest()), null); + assert.equal(await issueProxySession(proxyRequest()), null); process.env.AUTH_PROXY_TRUSTED_IPS = "10.0.0.1"; assert.equal( - issueProxySession(proxyRequest({ "x-forwarded-user": "mallory" }, "192.168.1.10")), + await issueProxySession(proxyRequest({ "x-forwarded-user": "mallory" }, "192.168.1.10")), null, ); }); -test("proxy auth issues no session while onboarding leaves authentication off", () => { - assert.equal(issueProxySession(proxyRequest({ "x-forwarded-user": "frank" })), null); +test("proxy auth issues no session while onboarding leaves authentication off", async () => { + assert.equal(await issueProxySession(proxyRequest({ "x-forwarded-user": "frank" })), null); completeOnboarding(); - assert.ok(issueProxySession(proxyRequest({ "x-forwarded-user": "frank" }))?.token); + assert.ok((await issueProxySession(proxyRequest({ "x-forwarded-user": "frank" })))?.token); }); test("proxy auth re-syncs role on every request instead of only at creation", () => { @@ -175,10 +184,10 @@ test("proxy auth re-syncs role on every request instead of only at creation", () process.env.AUTH_PROXY_ADMIN_USERS = "dave"; const promoted = resolveProxyUser(proxyRequest({ "x-forwarded-user": "dave" })); assert.equal(promoted.role, "admin"); - assert.equal(userOps.getUserByUsername("dave")?.role, "admin"); + assert.equal(readBetterAuthUser(db, promoted.id)?.role, "admin"); delete process.env.AUTH_PROXY_ADMIN_USERS; const demoted = resolveProxyUser(proxyRequest({ "x-forwarded-user": "dave" })); assert.equal(demoted.role, "user"); - assert.equal(userOps.getUserByUsername("dave")?.role, "user"); + assert.equal(readBetterAuthUser(db, demoted.id)?.role, "user"); }); diff --git a/.tests/auth/quality-profile-settings.int.test.js b/.tests/auth/quality-profile-settings.int.test.js index 37e5b9bf2..faee279da 100644 --- a/.tests/auth/quality-profile-settings.int.test.js +++ b/.tests/auth/quality-profile-settings.int.test.js @@ -38,12 +38,12 @@ test.before(async () => { dbOps.updateSettings({ integrations: {}, onboardingComplete: true }); userOps.createUser("admin", bcrypt.hashSync("password123", 4), "admin"); aurral = await startServerProcess(); - const response = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/login`, { + const response = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/sign-in/username`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "admin", password: "password123" }), }); - authToken = (await response.json()).token; + authToken = response.headers.get("set-auth-token"); assert.equal(response.status, 200); }); diff --git a/.tests/auth/session-helpers.test.js b/.tests/auth/session-helpers.test.js deleted file mode 100644 index 7b5692523..000000000 --- a/.tests/auth/session-helpers.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - setupIsolatedBackend, - cleanupIsolatedState, - resetDatabase, -} from "../helpers/backendTestHarness.js"; - -const [isolatedState, { db }, { userOps }, sessionHelpers] = - await setupIsolatedBackend( - "sessions", - "backend/config/db-sqlite.js", - "backend/db/helpers/index.js", - "backend/config/session-helpers.js", - ); - -const bcryptModule = await import("bcrypt"); - -const bcrypt = bcryptModule.default; - -const { - createSession, - getSessionByToken, - deleteSession, - deleteSessionsByUserId, - cleanExpiredSessions, -} = sessionHelpers; - -test.beforeEach(() => { - resetDatabase(db); -}); - -test.after(async () => { - await cleanupIsolatedState(isolatedState); -}); - -test("creates and resolves sessions with user payload metadata", () => { - const hash = bcrypt.hashSync("secret", 4); - const user = userOps.createUser("alice", hash, "admin"); - - const session = createSession(user.id, "127.0.0.1", "node:test"); - const stored = getSessionByToken(session.token); - - assert.ok(session.token); - assert.equal(typeof session.expiresAt, "number"); - assert.equal(stored?.userId, user.id); - assert.equal(stored?.user?.username, "alice"); - assert.equal(stored?.ipAddress, "127.0.0.1"); - assert.equal(stored?.userAgent, "node:test"); -}); - -test("deletes expired sessions when looked up or cleaned", () => { - const hash = bcrypt.hashSync("secret", 4); - const user = userOps.createUser("bob", hash, "user"); - const session = createSession(user.id); - - db.prepare("UPDATE sessions SET expires_at = ? WHERE token = ?").run( - Date.now() - 1000, - session.token, - ); - - assert.equal(getSessionByToken(session.token), null); - assert.equal(cleanExpiredSessions(), 0); -}); - -test("can delete one session or all sessions for a user", () => { - const hash = bcrypt.hashSync("secret", 4); - const user = userOps.createUser("carol", hash, "user"); - const first = createSession(user.id); - const second = createSession(user.id); - - assert.equal(deleteSession(first.token), true); - assert.equal(getSessionByToken(first.token), null); - assert.ok(getSessionByToken(second.token)); - - assert.equal(deleteSessionsByUserId(user.id), 1); - assert.equal(getSessionByToken(second.token), null); -}); diff --git a/.tests/frontend/better-auth-contracts.test.js b/.tests/frontend/better-auth-contracts.test.js new file mode 100644 index 000000000..d7edc8ad3 --- /dev/null +++ b/.tests/frontend/better-auth-contracts.test.js @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createServer } from "vite"; + +const createStorage = (initial = {}) => { + const values = new Map(Object.entries(initial)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + }; +}; + +const withFrontend = async (t) => { + const originalGlobals = { + fetch: globalThis.fetch, + localStorage: globalThis.localStorage, + sessionStorage: globalThis.sessionStorage, + window: globalThis.window, + }; + const vite = await createServer({ + root: "frontend", + server: { middlewareMode: true }, + appType: "custom", + optimizeDeps: { noDiscovery: true }, + }); + + t.after(async () => { + await vite.close(); + Object.assign(globalThis, originalGlobals); + }); + + globalThis.localStorage = createStorage(); + globalThis.sessionStorage = createStorage(); + globalThis.window = { location: { origin: "https://aurral.example.com" } }; + return vite; +}; + +test("local auth uses Better Auth email login and bearer response headers", async (t) => { + const vite = await withFrontend(t); + let request; + globalThis.fetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ + session: { userId: "7" }, + user: { id: "7", name: "Ada Lovelace", email: "ada@example.com", role: "admin" }, + }), { + status: 200, + headers: { + "content-type": "application/json", + "set-auth-token": "better-auth-session-token", + }, + }); + }; + + const { loginApi } = await vite.ssrLoadModule( + "/src/utils/api/endpoints/auth.js?better-auth-login-contract", + ); + const result = await loginApi("ada@example.com", "password123"); + + assert.equal(request.url, "/api/auth/sign-in/email"); + assert.deepEqual(JSON.parse(request.init.body), { + email: "ada@example.com", + password: "password123", + }); + assert.equal(result.token, "better-auth-session-token"); + assert.equal(globalThis.localStorage.getItem("bearer_token"), "better-auth-session-token"); +}); + +test("session restore and logout use Better Auth contracts", async (t) => { + const vite = await withFrontend(t); + globalThis.localStorage.setItem("bearer_token", "session-token"); + const requests = []; + globalThis.fetch = async (url, init) => { + requests.push({ url, init }); + if (url.includes("get-session")) { + return new Response(JSON.stringify({ + session: { userId: "7" }, + user: { id: "7", name: "Ada Lovelace", email: "ada@example.com" }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const { getMe, logoutApi } = await vite.ssrLoadModule( + "/src/utils/api/endpoints/auth.js?better-auth-session-contract", + ); + const session = await getMe(); + await logoutApi(); + + assert.equal(requests[0].url, "/api/auth/get-session"); + assert.equal(requests[0].init.headers.Authorization, "Bearer session-token"); + assert.deepEqual(session.user, { id: "7", name: "Ada Lovelace", email: "ada@example.com" }); + assert.equal(requests[1].url, "/api/auth/sign-out"); +}); + +test("OIDC sign-in starts through Better Auth social sign-in", async (t) => { + const vite = await withFrontend(t); + let request; + globalThis.fetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ + url: "https://idp.example.com/authorize?state=state-token", + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const { startOidcLogin } = await vite.ssrLoadModule( + "/src/utils/api/endpoints/auth.js?better-auth-oidc-contract", + ); + const redirectUrl = await startOidcLogin("/aurral/"); + + assert.equal(request.url, "/api/auth/sign-in/social"); + assert.deepEqual(JSON.parse(request.init.body), { + provider: "oidc", + callbackURL: "/aurral/", + disableRedirect: true, + }); + assert.equal(redirectUrl, "https://idp.example.com/authorize?state=state-token"); +}); + +test("local account management uses Better Auth admin shapes", async (t) => { + const vite = await withFrontend(t); + const requests = []; + globalThis.fetch = async (url, init) => { + requests.push({ url, init }); + const payload = url === "/api/users" + ? [{ id: "7", name: "Ada Lovelace" }] + : { users: [{ id: "7", name: "Ada Lovelace" }] }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const { createUser, getUsers, changeMyPassword, deleteUser } = await vite.ssrLoadModule( + "/src/utils/api/endpoints/auth.js?better-auth-admin-contract", + ); + await createUser({ + name: "Grace Hopper", + email: "grace@example.com", + password: "password123", + role: "user", + permissions: { addArtist: true }, + }); + await changeMyPassword("old-password", "new-password"); + await deleteUser(9); + const users = await getUsers(); + + assert.deepEqual(JSON.parse(requests[0].init.body), { + email: "grace@example.com", + name: "Grace Hopper", + password: "password123", + role: "user", + data: { permissions: { addArtist: true } }, + }); + assert.deepEqual(JSON.parse(requests[1].init.body), { + currentPassword: "old-password", + newPassword: "new-password", + revokeOtherSessions: true, + }); + assert.deepEqual(JSON.parse(requests[2].init.body), { userId: "9" }); + assert.equal(requests[3].url, "/api/users"); + assert.deepEqual(users, [{ id: "7", name: "Ada Lovelace" }]); +}); + +test("onboarding and login forms expose Better Auth account fields", async () => { + const onboarding = await readFile("frontend/src/pages/Onboarding.jsx", "utf8"); + const login = await readFile("frontend/src/pages/Login.jsx", "utf8"); + + assert.match(onboarding, /id="onboarding-name"/); + assert.match(onboarding, /id="onboarding-email"/); + assert.match(onboarding, /auth:\s*\{\s*name: authName\.trim\(\),\s*email: authEmail\.trim\(\)/s); + assert.doesNotMatch(onboarding, /onboarding-username/); + assert.match(login, /id="identifier"/); + assert.match(login, /Email or username/); + assert.doesNotMatch(login, /id="username"/); +}); diff --git a/.tests/helpers/backendTestHarness.js b/.tests/helpers/backendTestHarness.js index ae868f178..8841e2407 100644 --- a/.tests/helpers/backendTestHarness.js +++ b/.tests/helpers/backendTestHarness.js @@ -9,6 +9,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, "..", ".."); const RESET_TABLES = [ + "verifications", + "accounts", "sessions", "lastfm_link_states", "subsonic_stars", @@ -88,7 +90,14 @@ export async function setupIsolatedBackend(name, ...modulePaths) { } export function resetDatabase(db) { + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((table) => table.name), + ); for (const table of RESET_TABLES) { + if (!tables.has(table)) continue; db.prepare(`DELETE FROM ${table}`).run(); } } diff --git a/.tests/helpers/betterAuthFixtures.js b/.tests/helpers/betterAuthFixtures.js new file mode 100644 index 000000000..8b4f375b0 --- /dev/null +++ b/.tests/helpers/betterAuthFixtures.js @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; + +const quoteIdentifier = (value) => `"${String(value).replaceAll('"', '""')}"`; + +export function tableColumns(db, table) { + return new Set( + db + .prepare(`PRAGMA table_info(${quoteIdentifier(table)})`) + .all() + .map((column) => column.name), + ); +} + +export function assertBetterAuthCoreSchema(db) { + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((table) => table.name), + ); + for (const table of ["users", "sessions", "accounts", "verifications"]) { + assert.equal(tables.has(table), true, `Better Auth table ${table} is missing`); + } + + for (const column of [ + "id", + "name", + "email", + "email_verified", + "created_at", + "updated_at", + "username", + "display_username", + ]) { + assert.equal(tableColumns(db, "users").has(column), true, `users.${column} is missing`); + } + for (const column of [ + "id", + "user_id", + "token", + "expires_at", + "created_at", + "updated_at", + "ip_address", + "user_agent", + ]) { + assert.equal(tableColumns(db, "sessions").has(column), true, `sessions.${column} is missing`); + } + for (const column of [ + "id", + "user_id", + "issuer", + "account_id", + "provider_id", + "password", + "created_at", + "updated_at", + ]) { + assert.equal(tableColumns(db, "accounts").has(column), true, `accounts.${column} is missing`); + } + for (const column of [ + "id", + "identifier", + "value", + "expires_at", + "created_at", + "updated_at", + ]) { + assert.equal( + tableColumns(db, "verifications").has(column), + true, + `verifications.${column} is missing`, + ); + } +} + +function insertKnownColumns(db, table, values) { + const columns = tableColumns(db, table); + const entries = Object.entries(values).filter(([column]) => columns.has(column)); + const names = entries.map(([column]) => quoteIdentifier(column)).join(", "); + const placeholders = entries.map(() => "?").join(", "); + db.prepare(`INSERT INTO ${quoteIdentifier(table)} (${names}) VALUES (${placeholders})`).run( + ...entries.map(([, value]) => value), + ); +} + +export function seedBetterAuthUser( + db, + { + id, + email, + name, + username, + password, + role = "user", + permissions = {}, + issuer = "local:credential", + providerId = "credential", + }, +) { + const now = new Date().toISOString(); + insertKnownColumns(db, "users", { + id, + name, + email, + email_verified: 0, + created_at: now, + updated_at: now, + username, + display_username: username, + role, + permissions: JSON.stringify(permissions), + }); + insertKnownColumns(db, "accounts", { + user_id: id, + issuer, + account_id: String(id), + provider_id: providerId, + password, + created_at: now, + updated_at: now, + }); + return readBetterAuthUser(db, id); +} + +export function readBetterAuthUser(db, id) { + return db.prepare(`SELECT * FROM ${quoteIdentifier("users")} WHERE id = ?`).get(id); +} + +export function readBetterAuthAccount(db, userId) { + return db + .prepare(`SELECT * FROM ${quoteIdentifier("accounts")} WHERE user_id = ? ORDER BY created_at`) + .all(userId); +} + +export function readBetterAuthSessions(db, userId) { + return db + .prepare(`SELECT * FROM ${quoteIdentifier("sessions")} WHERE user_id = ? ORDER BY created_at`) + .all(userId); +} + +export async function requestBetterAuth(server, path, { + method = "GET", + body, + token, + headers = {}, +} = {}) { + const response = await fetch(`http://127.0.0.1:${server.port}/api/auth${path}`, { + method, + headers: { + ...(body ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = text; + } + return { + response, + payload, + authToken: response.headers.get("set-auth-token"), + cookie: response.headers.get("set-cookie"), + }; +} diff --git a/.tests/subsonic/subsonic-canonical.int.test.js b/.tests/subsonic/subsonic-canonical.int.test.js index c1f8e6a04..3c6963011 100644 --- a/.tests/subsonic/subsonic-canonical.int.test.js +++ b/.tests/subsonic/subsonic-canonical.int.test.js @@ -237,12 +237,12 @@ test.before(async () => { "shared-artwork", ); aurral = await startServerProcess(); - const login = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/login`, { + const login = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/sign-in/username`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "alice", password: "password123" }), }); - authToken = (await login.json()).token; + authToken = login.headers.get("set-auth-token"); assert.equal(login.status, 200); }); @@ -730,13 +730,7 @@ test("streams canonical files with full and range responses", async () => { }); test("streams canonical files through the authenticated native route", async () => { - const login = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: "alice", password: "password123" }), - }); - const { token } = await login.json(); - const headers = { Authorization: `Bearer ${token}` }; + const headers = { Authorization: `Bearer ${authToken}` }; const canonical = await fetch( `http://127.0.0.1:${aurral.port}/api/library/canonical?source=lidarr&availableOnly=true&kind=tracks&page=1&pageSize=100`, { headers }, diff --git a/.tests/users/plex-global-account-owner.int.test.js b/.tests/users/plex-global-account-owner.int.test.js index 1ae9960cc..8f8d055de 100644 --- a/.tests/users/plex-global-account-owner.int.test.js +++ b/.tests/users/plex-global-account-owner.int.test.js @@ -42,13 +42,13 @@ async function apiFetch(token, path, options = {}) { } async function login(username) { - const res = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/login`, { + const res = await fetch(`http://127.0.0.1:${aurral.port}/api/auth/sign-in/username`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password: "password123" }), }); assert.equal(res.status, 200); - return (await res.json()).token; + return res.headers.get("set-auth-token"); } test.before(async () => { diff --git a/.tests/users/plex-link-routes.int.test.js b/.tests/users/plex-link-routes.int.test.js index a17e50ff6..b2cdd092f 100644 --- a/.tests/users/plex-link-routes.int.test.js +++ b/.tests/users/plex-link-routes.int.test.js @@ -27,14 +27,13 @@ let userAToken = ""; let userBToken = ""; async function login(username, password) { - const response = await fetch(`http://127.0.0.1:${server.port}/api/auth/login`, { + const response = await fetch(`http://127.0.0.1:${server.port}/api/auth/sign-in/username`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); - const payload = await response.json(); - assert.equal(response.status, 200, JSON.stringify(payload)); - return payload.token; + assert.equal(response.status, 200, await response.text()); + return response.headers.get("set-auth-token"); } async function apiFetch(token, path, options = {}) { diff --git a/backend/config/db-sqlite.js b/backend/config/db-sqlite.js index 2134d0d46..3737442cb 100644 --- a/backend/config/db-sqlite.js +++ b/backend/config/db-sqlite.js @@ -60,23 +60,67 @@ db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, + display_username TEXT, + name TEXT NOT NULL DEFAULT '', + email TEXT UNIQUE NOT NULL, + email_verified INTEGER NOT NULL DEFAULT 0, + image TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + password_hash TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'user', permissions TEXT, - discover_layout TEXT + discover_layout TEXT, + banned INTEGER NOT NULL DEFAULT 0, + ban_reason TEXT, + ban_expires TEXT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, ip_address TEXT, user_agent TEXT, + impersonated_by INTEGER, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + account_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + issuer TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + id_token TEXT, + access_token_expires_at TEXT, + refresh_token_expires_at TEXT, + scope TEXT, + password TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_issuer_account + ON accounts(issuer, account_id); + CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id); + + CREATE TABLE IF NOT EXISTS verifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_verifications_identifier ON verifications(identifier); + CREATE TABLE IF NOT EXISTS lastfm_link_states ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, @@ -581,6 +625,92 @@ if (!userColumns.includes("discover_layout")) { if (!userColumns.includes("listen_history_url")) { tryAddColumn("ALTER TABLE users ADD COLUMN listen_history_url TEXT"); } +if (!userColumns.includes("display_username")) { + tryAddColumn("ALTER TABLE users ADD COLUMN display_username TEXT"); +} +if (!userColumns.includes("name")) { + tryAddColumn("ALTER TABLE users ADD COLUMN name TEXT"); +} +if (!userColumns.includes("email")) { + tryAddColumn("ALTER TABLE users ADD COLUMN email TEXT"); +} +if (!userColumns.includes("email_verified")) { + tryAddColumn("ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0"); +} +if (!userColumns.includes("image")) { + tryAddColumn("ALTER TABLE users ADD COLUMN image TEXT"); +} +if (!userColumns.includes("created_at")) { + tryAddColumn("ALTER TABLE users ADD COLUMN created_at TEXT"); +} +if (!userColumns.includes("updated_at")) { + tryAddColumn("ALTER TABLE users ADD COLUMN updated_at TEXT"); +} +if (!userColumns.includes("banned")) { + tryAddColumn("ALTER TABLE users ADD COLUMN banned INTEGER NOT NULL DEFAULT 0"); +} +if (!userColumns.includes("ban_reason")) { + tryAddColumn("ALTER TABLE users ADD COLUMN ban_reason TEXT"); +} +if (!userColumns.includes("ban_expires")) { + tryAddColumn("ALTER TABLE users ADD COLUMN ban_expires TEXT"); +} + +db.exec(` + UPDATE users + SET display_username = COALESCE(NULLIF(TRIM(display_username), ''), username), + name = COALESCE(NULLIF(TRIM(name), ''), username), + email = COALESCE( + NULLIF(LOWER(TRIM(email)), ''), + CASE + WHEN username LIKE '%@%' THEN LOWER(TRIM(username)) + ELSE 'legacy-' || id || '@aurral.invalid' + END + ), + created_at = COALESCE(NULLIF(created_at, ''), CURRENT_TIMESTAMP), + updated_at = COALESCE(NULLIF(updated_at, ''), CURRENT_TIMESTAMP); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email); +`); + +const sessionColumns = db + .prepare("PRAGMA table_info(sessions)") + .all() + .map((column) => column.name); + +if (!sessionColumns.includes("updated_at")) { + db.exec(` + DROP TABLE sessions; + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + impersonated_by INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + `); +} + +const now = new Date().toISOString(); +db.prepare(` + INSERT INTO accounts + (user_id, account_id, provider_id, issuer, password, created_at, updated_at) + SELECT id, CAST(id AS TEXT), 'credential', 'local:credential', password_hash, ?, ? + FROM users + WHERE password_hash IS NOT NULL + AND password_hash != '' + AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE accounts.user_id = users.id + AND accounts.provider_id = 'credential' + AND accounts.issuer = 'local:credential' + ) +`).run(now, now); db.exec(` UPDATE users diff --git a/backend/config/session-helpers.js b/backend/config/session-helpers.js deleted file mode 100644 index 8e41a597d..000000000 --- a/backend/config/session-helpers.js +++ /dev/null @@ -1,88 +0,0 @@ -import crypto from "crypto"; -import { db } from "./db-sqlite.js"; -import { userOps } from "../db/helpers/index.js"; - -const DEFAULT_EXPIRY_HOURS = 24 * 30; - -const insertSessionStmt = db.prepare( - "INSERT INTO sessions (user_id, token, created_at, expires_at, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?)", -); -const getSessionByTokenStmt = db.prepare("SELECT * FROM sessions WHERE token = ? LIMIT 1"); -const deleteSessionByTokenStmt = db.prepare("DELETE FROM sessions WHERE token = ?"); -const deleteSessionsByUserIdStmt = db.prepare("DELETE FROM sessions WHERE user_id = ?"); -const deleteExpiredSessionsStmt = db.prepare("DELETE FROM sessions WHERE expires_at <= ?"); - -const getSessionExpiryMs = () => { - const hours = Number(process.env.SESSION_EXPIRY_HOURS); - const safeHours = Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_EXPIRY_HOURS; - return safeHours * 60 * 60 * 1000; -}; - -const toUserPayload = (user) => { - if (!user) return null; - return { - id: user.id, - username: user.username, - role: user.role, - permissions: user.permissions, - }; -}; - -export const createSession = (userId, ipAddress = null, userAgent = null) => { - const now = Date.now(); - const expiresAt = now + getSessionExpiryMs(); - const token = crypto.randomBytes(32).toString("hex"); - insertSessionStmt.run( - Number(userId), - token, - now, - expiresAt, - ipAddress ? String(ipAddress).slice(0, 255) : null, - userAgent ? String(userAgent).slice(0, 1024) : null, - ); - return { - token, - expiresAt, - }; -}; - -export const getSessionByToken = (token) => { - const rawToken = String(token || "").trim(); - if (!rawToken) return null; - const row = getSessionByTokenStmt.get(rawToken); - if (!row) return null; - if (row.expires_at <= Date.now()) { - deleteSessionByTokenStmt.run(rawToken); - return null; - } - const user = userOps.getUserAuthById(row.user_id); - if (!user) { - deleteSessionByTokenStmt.run(rawToken); - return null; - } - return { - id: row.id, - token: row.token, - userId: row.user_id, - createdAt: row.created_at, - expiresAt: row.expires_at, - ipAddress: row.ip_address, - userAgent: row.user_agent, - user: toUserPayload(user), - }; -}; - -export const deleteSession = (token) => { - const result = deleteSessionByTokenStmt.run(String(token || "").trim()); - return result.changes > 0; -}; - -export const deleteSessionsByUserId = (userId) => { - const result = deleteSessionsByUserIdStmt.run(Number(userId)); - return result.changes; -}; - -export const cleanExpiredSessions = () => { - const result = deleteExpiredSessionsStmt.run(Date.now()); - return result.changes; -}; diff --git a/backend/db/helpers/users.js b/backend/db/helpers/users.js index d315ec519..d05a9f6bc 100644 --- a/backend/db/helpers/users.js +++ b/backend/db/helpers/users.js @@ -11,18 +11,24 @@ const getUserByUsernameStmt = db.prepare( "SELECT * FROM users WHERE username = ?" ); const getAllUsersStmt = db.prepare( - "SELECT id, username, role, permissions, lastfm_username, listen_history_provider, listen_history_username, listen_history_url, lidarr_root_folder_path, lidarr_quality_profile_id FROM users ORDER BY username" + "SELECT id, username, display_username, name, email, role, permissions, lastfm_username, listen_history_provider, listen_history_username, listen_history_url, lidarr_root_folder_path, lidarr_quality_profile_id FROM users ORDER BY name, email" ); const getUserByIdStmt = db.prepare("SELECT * FROM users WHERE id = ?"); const getUserAuthByIdStmt = db.prepare( - "SELECT id, username, role, permissions FROM users WHERE id = ?" + "SELECT id, username, display_username, name, email, role, permissions FROM users WHERE id = ?" +); +const getCredentialPasswordStmt = db.prepare( + "SELECT password FROM accounts WHERE user_id = ? AND provider_id = 'credential' LIMIT 1" +); +const updateCredentialPasswordStmt = db.prepare( + "UPDATE accounts SET password = ?, updated_at = ? WHERE user_id = ? AND provider_id = 'credential'" ); const countUsersStmt = db.prepare("SELECT COUNT(*) AS count FROM users"); const insertUserStmt = db.prepare( - "INSERT INTO users (username, password_hash, role, permissions, lidarr_root_folder_path, lidarr_quality_profile_id) VALUES (?, ?, ?, ?, ?, ?)" + "INSERT INTO users (username, display_username, name, email, email_verified, created_at, updated_at, password_hash, role, permissions, lidarr_root_folder_path, lidarr_quality_profile_id) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)" ); const updateUserStmt = db.prepare( - "UPDATE users SET username = ?, password_hash = ?, role = ?, permissions = ?, lastfm_username = ?, listen_history_provider = ?, listen_history_username = ?, listen_history_url = ?, lidarr_root_folder_path = ?, lidarr_quality_profile_id = ? WHERE id = ?" + "UPDATE users SET username = ?, display_username = ?, name = ?, email = ?, password_hash = ?, role = ?, permissions = ?, lastfm_username = ?, listen_history_provider = ?, listen_history_username = ?, listen_history_url = ?, lidarr_root_folder_path = ?, lidarr_quality_profile_id = ?, updated_at = ? WHERE id = ?" ); const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?"); const getAllListeningHistoryUsersStmt = db.prepare( @@ -52,6 +58,8 @@ export const userOps = { return { id: row.id, username: row.username, + name: row.name || row.display_username || row.username, + email: row.email, passwordHash: row.password_hash, role: row.role || "user", permissions: dbHelpers.parseJSON(row.permissions) || { @@ -72,6 +80,8 @@ export const userOps = { return { id: row.id, username: row.username, + name: row.name || row.display_username || row.username, + email: row.email, passwordHash: row.password_hash, role: row.role || "user", permissions: dbHelpers.parseJSON(row.permissions) || { @@ -91,12 +101,24 @@ export const userOps = { return { id: row.id, username: row.username, + name: row.name || row.display_username || row.username, + email: row.email, role: row.role || "user", permissions: dbHelpers.parseJSON(row.permissions) || { ...DEFAULT_PERMISSIONS, }, }; }, + getCredentialPasswordHash(id) { + return getCredentialPasswordStmt.get(parseInt(id, 10))?.password || null; + }, + updateCredentialPasswordHash(id, passwordHash) { + updateCredentialPasswordStmt.run( + passwordHash, + new Date().toISOString(), + parseInt(id, 10), + ); + }, countUsers() { return countUsersStmt.get().count; }, @@ -117,15 +139,28 @@ export const userOps = { : null, })); }, - createUser(username, passwordHash, role = "user", permissions = null) { + createUser(username, passwordHash, role = "user", permissions = null, identity = {}) { const un = String(username).trim(); if (!un) return null; const perms = permissions ? { ...DEFAULT_PERMISSIONS, ...permissions } : { ...DEFAULT_PERMISSIONS }; try { + const now = new Date().toISOString(); + const name = String(identity.name || un).trim(); + const email = String( + identity.email || + (un.includes("@") ? un : `legacy-${Buffer.from(un).toString("hex")}@aurral.invalid`), + ) + .trim() + .toLowerCase(); const result = insertUserStmt.run( un.toLowerCase(), + un, + name, + email, + now, + now, passwordHash, role, dbHelpers.stringifyJSON(perms), @@ -135,6 +170,8 @@ export const userOps = { return { id: result.lastInsertRowid, username: un, + name, + email, role, permissions: perms, listenHistoryProvider: DEFAULT_LISTEN_HISTORY_PROVIDER, @@ -159,6 +196,10 @@ export const userOps = { data.passwordHash !== undefined ? data.passwordHash : existing.passwordHash; + const name = data.name !== undefined ? String(data.name).trim() : existing.name; + const email = data.email !== undefined + ? String(data.email).trim().toLowerCase() + : existing.email; const role = data.role !== undefined ? data.role : existing.role; const permissions = data.permissions !== undefined @@ -212,6 +253,9 @@ export const userOps = { try { updateUserStmt.run( username.toLowerCase(), + username, + name, + email, passwordHash, role, dbHelpers.stringifyJSON(permissions), @@ -221,11 +265,14 @@ export const userOps = { resolvedUrl, lidarrRootFolderPath, lidarrQualityProfileId, + new Date().toISOString(), parseInt(id, 10) ); return { id: parseInt(id, 10), username, + name, + email, role, permissions, listenHistoryProvider, diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index c094d096e..ea6530493 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,7 +1,10 @@ import crypto from "crypto"; import os from "os"; import { dbOps, userOps } from "../db/helpers/index.js"; -import { createSession, getSessionByToken } from "../config/session-helpers.js"; +import { + createAuthSession, + getSessionForHeaders, +} from "../services/betterAuth.js"; import { hashPassword, verifyPassword, needsRehash } from "./passwordHash.js"; const safeCompare = (a, b) => { @@ -254,10 +257,21 @@ function buildPermissions(role, permissions) { }; } +function getProxyIdentity(username) { + const normalized = String(username || "").trim().toLowerCase(); + const key = crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 32); + return { + name: normalized, + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized) + ? normalized + : `proxy-${key}@aurral.invalid`, + }; +} + function toResolvedUser(user) { if (!user) return null; return { - id: user.id, + id: Number(user.id), username: user.username, role: user.role, permissions: buildPermissions(user.role, user.permissions), @@ -268,13 +282,41 @@ const toSessionUser = (session) => { if (!session?.user) return null; const baseUser = session.user; return { - id: baseUser.id, + id: Number(baseUser.id), username: baseUser.username, role: baseUser.role, permissions: buildPermissions(baseUser.role, baseUser.permissions), }; }; +async function getBetterAuthUser(headers) { + try { + return toSessionUser(await getSessionForHeaders(headers)); + } catch { + return null; + } +} + +function getBearerHeaders(token, headers = {}) { + return { + ...headers, + authorization: `Bearer ${String(token || "").trim()}`, + }; +} + +async function hydrateBetterAuthUser(req, { queryToken = false } = {}) { + if (req.user) return req.user; + const token = queryToken ? String(req.query?.token || "").trim() : ""; + const authorization = String(req.headers?.authorization || "").trim(); + const cookie = String(req.headers?.cookie || "").trim(); + if (!token && !authorization && !cookie) return null; + const user = await getBetterAuthUser( + token ? getBearerHeaders(token, req.headers) : req.headers, + ); + if (user) req.user = user; + return user; +} + const getBearerToken = (req) => { const authHeader = String(req.headers.authorization || ""); if (!authHeader.startsWith("Bearer ")) return null; @@ -306,10 +348,14 @@ export function sendUnauthorizedResponse(req, res, { challenge = false, ...overr }); } -export const resolveSessionUserFromToken = (token) => { +export async function resolveSessionUserFromToken(token) { if (!token) return null; - return toSessionUser(getSessionByToken(token)); -}; + return getBetterAuthUser(getBearerHeaders(token)); +} + +export async function resolveSessionUserFromHeaders(headers) { + return getBetterAuthUser(headers || {}); +} function resolveApiKeyUser(req) { const headerKey = (req.headers["x-api-key"] || "").trim(); @@ -441,19 +487,27 @@ export function reconcileLocalNetworkBypassSetting() { } export function ensureExternalUser(username, role) { + const identity = getProxyIdentity(username); const existing = userOps.getUserByUsername(username); if (existing) { if (existing.role !== role) { const updated = userOps.updateUser(existing.id, { role }); - return toResolvedUser(updated || existing); + return { + ...toResolvedUser(updated || existing), + ...identity, + }; } - return toResolvedUser(existing); + return { + ...toResolvedUser(existing), + ...identity, + }; } const passwordHash = hashPassword(crypto.randomBytes(32).toString("hex")); - const created = userOps.createUser(username, passwordHash, role, null); - return created + const created = userOps.createUser(username, passwordHash, role, null, identity); + const resolved = created ? toResolvedUser(userOps.getUserByUsername(created.username) || created) : toResolvedUser(userOps.getUserByUsername(username)); + return resolved ? { ...resolved, ...identity } : null; } function isProxyAdmin(req, username) { @@ -494,12 +548,12 @@ export function resolveProxyUser(req) { return ensureExternalUser(username, role); } -export function issueProxySession(req) { +export async function issueProxySession(req) { if (!isAuthRequiredByConfig()) return null; - if (resolveSessionUserFromToken(getBearerToken(req))) return null; + if (await resolveSessionUserFromToken(getBearerToken(req))) return null; const proxyUser = resolveProxyUser(req); if (!proxyUser?.id || proxyUser.id < 0) return null; - return createSession(proxyUser.id, req.ip || null, req.headers["user-agent"] || null); + return createAuthSession(proxyUser.id, req); } function migrateLegacyAdmin() { @@ -523,9 +577,10 @@ export function resolveUser(username, password) { .toLowerCase(); const u = userOps.getUserByUsername(un); if (!u || !password) return null; - if (!verifyPassword(password, u.passwordHash)) return null; - if (needsRehash(u.passwordHash)) { - userOps.updateUser(u.id, { passwordHash: hashPassword(password) }); + const passwordHash = userOps.getCredentialPasswordHash(u.id) || u.passwordHash; + if (!passwordHash || !verifyPassword(password, passwordHash)) return null; + if (needsRehash(passwordHash)) { + userOps.updateCredentialPasswordHash(u.id, hashPassword(password)); } const perms = buildPermissions(u.role, u.permissions); return { @@ -554,30 +609,6 @@ export function resolveSubsonicTokenUser(username, token, salt) { return matchedPassword ? resolveUser(username, matchedPassword) : null; } -function legacyAuth(username, password) { - const authUser = getAuthUser(); - const passwords = getAuthPassword(); - if (passwords.length === 0) return null; - const userMatches = safeCompare(username, authUser); - const passwordMatches = passwords.some((p) => safeCompare(password, p)); - if (!userMatches || !passwordMatches) return null; - return { - id: 0, - username: authUser, - role: "admin", - permissions: { - accessSettings: true, - accessFlow: true, - addArtist: true, - addAlbum: true, - changeMonitoring: true, - deleteArtist: true, - deleteAlbum: true, - deleteTrack: true, - }, - }; -} - export function resolveLocalNetworkBypassUser(req) { const status = getLocalNetworkBypassStatus(req); if (!status.active) return null; @@ -585,27 +616,11 @@ export function resolveLocalNetworkBypassUser(req) { } export function resolveRequestUser(req) { - const sessionUser = resolveSessionUserFromToken(getBearerToken(req)); - if (sessionUser) return sessionUser; + if (req.user) return req.user; const proxyUser = resolveProxyUser(req); if (proxyUser) return proxyUser; const apiKeyUser = resolveApiKeyUser(req); if (apiKeyUser) return apiKeyUser; - const authHeader = req.headers.authorization; - if (authHeader && authHeader.startsWith("Basic ")) { - try { - const token = authHeader.substring(6); - const decoded = Buffer.from(token, "base64").toString("utf8"); - const colon = decoded.indexOf(":"); - const username = colon >= 0 ? decoded.slice(0, colon) : decoded; - const password = colon >= 0 ? decoded.slice(colon + 1) : ""; - let user = resolveUser(username, password); - if (!user) user = legacyAuth(username, password); - if (user) return user; - } catch (e) { - return null; - } - } return resolveLocalNetworkBypassUser(req); } @@ -647,112 +662,72 @@ function consumeStreamToken(rawToken) { return payload.user || null; } -export const authMiddleware = (req, res, next) => { - if (!req.path.startsWith("/api")) return next(); - if ( - req.path === "/api/health" || - req.path === "/api/health/live" || - req.path === "/api/health/bootstrap" || - req.path === "/api/filesystem/browse" || - req.path === "/api/filesystem/ensure" || - req.path === "/api/image-proxy" || - req.path.startsWith("/api/image-proxy/") || - (req.method === "GET" && /^\/api\/feeds\/lidarr\/flows\/[^/]+\.json$/i.test(req.path)) - ) { - return next(); - } - if ( - /^\/api\/library\/stream\/[^/]+$/.test(req.path) || - /^\/api\/library\/canonical-stream\/[^/]+\/[^/]+$/i.test(req.path) || - /^\/api\/library\/file-stream\/[^/]+\/[^/]+$/i.test(req.path) || - /^\/api\/artists\/[a-f0-9-]{36}\/stream$/i.test(req.path) || - /^\/api\/weekly-flow\/stream\/[^/]+$/i.test(req.path) || - /^\/api\/playlists\/stream\/[^/]+$/i.test(req.path) || - /^\/api\/playlists\/staging-stream\/[^/]+$/i.test(req.path) || - (req.method === "GET" && /^\/api\/weekly-flow\/artwork\/[^/]+$/i.test(req.path)) || - (req.method === "GET" && /^\/api\/playlists\/artwork\/[^/]+$/i.test(req.path)) || - (req.method === "GET" && /^\/api\/discover\/artwork\/[^/]+$/i.test(req.path)) - ) { - return next(); - } - if ( - req.path === "/api/auth/login" || - req.path === "/api/auth/oidc/login" || - req.path === "/api/auth/oidc/exchange" - || (req.method === "GET" && req.path === "/api/scrobbling/lastfm/link/callback") - ) { - return next(); - } +const isPublicAuthPath = (req) => + req.path === "/api/health" || + req.path === "/api/health/live" || + req.path === "/api/health/bootstrap" || + req.path === "/api/filesystem/browse" || + req.path === "/api/filesystem/ensure" || + req.path === "/api/image-proxy" || + req.path.startsWith("/api/image-proxy/") || + (req.method === "GET" && /^\/api\/feeds\/lidarr\/flows\/[^/]+\.json$/i.test(req.path)); + +const isMediaAuthPath = (req) => + /^\/api\/library\/stream\/[^/]+$/.test(req.path) || + /^\/api\/library\/canonical-stream\/[^/]+\/[^/]+$/i.test(req.path) || + /^\/api\/library\/file-stream\/[^/]+\/[^/]+$/i.test(req.path) || + /^\/api\/artists\/[a-f0-9-]{36}\/stream$/i.test(req.path) || + /^\/api\/weekly-flow\/stream\/[^/]+$/i.test(req.path) || + /^\/api\/playlists\/stream\/[^/]+$/i.test(req.path) || + /^\/api\/playlists\/staging-stream\/[^/]+$/i.test(req.path) || + (req.method === "GET" && /^\/api\/weekly-flow\/artwork\/[^/]+$/i.test(req.path)) || + (req.method === "GET" && /^\/api\/playlists\/artwork\/[^/]+$/i.test(req.path)) || + (req.method === "GET" && /^\/api\/discover\/artwork\/[^/]+$/i.test(req.path)); + +export const authMiddleware = async (req, res, next) => { + if (!req.path.startsWith("/api")) return next(); + + if (isPublicAuthPath(req)) { + await hydrateBetterAuthUser(req); + return next(); + } - const settings = dbOps.getSettings(); - const onboardingDone = settings.onboardingComplete; + if (isMediaAuthPath(req)) { + await hydrateBetterAuthUser(req, { queryToken: true }); + return next(); + } - if (req.path.startsWith("/api/onboarding") && !onboardingDone) return next(); + if (req.method === "GET" && req.path === "/api/scrobbling/lastfm/link/callback") { + return next(); + } - const authRequired = isAuthRequiredByConfig(); + const settings = dbOps.getSettings(); + const onboardingDone = settings.onboardingComplete; - if (!authRequired) return next(); + if (req.path.startsWith("/api/onboarding") && !onboardingDone) return next(); + if (!isAuthRequiredByConfig()) return next(); - const user = resolveRequestUser(req); - if (user) { - req.user = user; - return next(); - } + const user = (await hydrateBetterAuthUser(req)) || resolveRequestUser(req); + if (user) { + req.user = user; + return next(); + } - return sendUnauthorizedResponse(req, res); + return sendUnauthorizedResponse(req, res); }; -function getCredentialsFromRequest(req) { - const sessionUser = resolveSessionUserFromToken(req.query.token); - if (sessionUser) { - return { type: "session", user: sessionUser }; - } - const authHeader = req.headers.authorization; - if (authHeader && authHeader.startsWith("Basic ")) { - try { - const token = authHeader.substring(6); - const decoded = Buffer.from(token, "base64").toString("utf8"); - const colon = decoded.indexOf(":"); - const username = colon >= 0 ? decoded.slice(0, colon) : decoded; - const password = colon >= 0 ? decoded.slice(colon + 1) : ""; - return { type: "basic", username, password }; - } catch (e) { - return null; - } - } - return null; -} - export const verifyTokenAuth = (req) => { const user = resolveRequestUser(req); if (user) { req.user = user; return true; } - const creds = getCredentialsFromRequest(req); - if (creds) { - if (creds.type === "session" && creds.user) { - req.user = creds.user; - return true; - } - if (creds.type === "basic") { - let u = resolveUser(creds.username, creds.password); - if (!u) u = legacyAuth(creds.username, creds.password); - if (u) { - req.user = u; - return true; - } - } - } const streamTokenUser = consumeStreamToken(req.query.st); if (streamTokenUser) { req.user = streamTokenUser; return true; } - if (isProxyAuthEnabled()) return false; - const passwords = getAuthPassword(); - if (passwords.length === 0) return true; - return false; + return !isAuthRequiredByConfig(); }; export function hasPermission(user, permission) { diff --git a/backend/package.json b/backend/package.json index 70e884b06..6ac38c22e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,12 +27,12 @@ "dependencies": { "@russellthehippo/honker-node": "^0.4.5", "bcrypt": "^6.0.0", - "better-sqlite3": "^13.0.3", + "better-auth": "^1.7.1", + "better-sqlite3": "^12.10.0", "express": "^5.2.1", "express-rate-limit": "^8.6.2", "helmet": "^8.3.0", "music-metadata": "^11.15.0", - "openid-client": "^6.8.7", "sharp": "^0.35.3", "undici": "^8.10.0", "ws": "^8.21.3" diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 31cb342bd..ae7b93fe0 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,109 +1,15 @@ import express from "express"; -import { userOps } from "../db/helpers/index.js"; -import { createSession, deleteSession, getSessionByToken } from "../config/session-helpers.js"; import { requireAuth } from "../middleware/requirePermission.js"; import { getApiKey, rotateApiKey } from "../middleware/auth.js"; -import { hashPassword, verifyPassword, needsRehash } from "../middleware/passwordHash.js"; -import { clearOidcTransactionCookie, exchangeOidcCallback, startOidcLogin } from "../services/oidcAuth.js"; -import { logger } from "../services/logger.js"; const router = express.Router(); -const getBearerToken = (req) => { - const authHeader = String(req.headers.authorization || ""); - if (!authHeader.startsWith("Bearer ")) return null; - return authHeader.slice(7).trim(); -}; - -router.post("/login", async (req, res) => { - try { - const username = String(req.body?.username || "") - .trim() - .toLowerCase(); - const password = String(req.body?.password || ""); - if (!username || !password) { - return res.status(400).json({ error: "Username and password are required" }); - } - const user = userOps.getUserByUsername(username); - if (!user || !verifyPassword(password, user.passwordHash)) { - return res.status(401).json({ error: "Invalid username or password" }); - } - if (needsRehash(user.passwordHash)) { - userOps.updateUser(user.id, { passwordHash: hashPassword(password) }); - } - const session = createSession(user.id, req.ip || null, req.headers["user-agent"] || null); - res.json({ - token: session.token, - expiresAt: session.expiresAt, - user: { - id: user.id, - username: user.username, - role: user.role, - permissions: user.permissions, - }, - }); - } catch (error) { - res.status(500).json({ error: "Login failed" }); - } -}); - -router.post("/logout", requireAuth, (req, res) => { - const token = getBearerToken(req); - if (token) { - deleteSession(token); - } - res.json({ success: true }); -}); - -router.get("/me", requireAuth, (req, res) => { - const token = getBearerToken(req); - if (!token) { - return res.json({ - user: req.user, - expiresAt: null, - }); - } - const session = getSessionByToken(token); - if (!session?.user) { - return res.json({ - user: req.user, - expiresAt: null, - }); - } - res.json({ - user: session.user, - expiresAt: session.expiresAt, - }); -}); - router.get("/api-key", requireAuth, (req, res) => { res.json({ apiKey: getApiKey() }); }); router.post("/api-key/rotate", requireAuth, (req, res) => { - const newKey = rotateApiKey(); - res.json({ apiKey: newKey }); -}); - -router.get("/oidc/login", async (req, res) => { - try { - await startOidcLogin(req, res); - } catch (error) { - logger.error("auth", "OIDC login start failed:", { message: error.message }); - if (!res.headersSent) { - res.status(500).json({ error: "OIDC login failed" }); - } - } -}); - -router.post("/oidc/exchange", (req, res) => { - try { - const result = exchangeOidcCallback(req.body?.code, req); - clearOidcTransactionCookie(req, res); - res.json(result); - } catch (error) { - res.status(error.status || 500).json({ error: error.message || "OIDC exchange failed" }); - } + res.json({ apiKey: rotateApiKey() }); }); export default router; diff --git a/backend/routes/health.js b/backend/routes/health.js index 6364d05ae..04202f59b 100644 --- a/backend/routes/health.js +++ b/backend/routes/health.js @@ -17,7 +17,7 @@ import { issueStreamToken, getLocalNetworkBypassStatus, } from "../middleware/auth.js"; -import { getOidcBootstrapInfo } from "../services/oidcAuth.js"; +import { getOidcBootstrapInfo } from "../services/betterAuth.js"; import { lidarrClient } from "../services/lidarrClient.js"; import { getDiscoveryCache, @@ -233,7 +233,7 @@ async function buildSystemPayload(settings) { }; } -function buildBootstrapPayload(req) { +async function buildBootstrapPayload(req) { lidarrClient.updateConfig(); const settings = dbOps.getSettings(); const onboardingDone = settings.onboardingComplete; @@ -288,7 +288,7 @@ function buildBootstrapPayload(req) { payload.metadataProviders = getMetadataProviderHealthSnapshot(); payload.localNetworkBypass = getLocalNetworkBypassStatus(req); payload.proxyLogoutUrl = process.env.AUTH_PROXY_LOGOUT_URL || null; - const proxySession = issueProxySession(req); + const proxySession = await issueProxySession(req); if (proxySession) payload.token = proxySession.token; } @@ -299,9 +299,9 @@ router.get("/live", noCache, (_req, res) => { res.json({ status: "ok" }); }); -router.get("/bootstrap", noCache, (req, res) => { +router.get("/bootstrap", noCache, async (req, res) => { try { - res.json(buildBootstrapPayload(req)); + res.json(await buildBootstrapPayload(req)); } catch (error) { logger.error("health", "Bootstrap check error:", { message: error.message }); res.status(500).json({ diff --git a/backend/routes/onboarding.js b/backend/routes/onboarding.js index 9033af97e..b4a982c81 100644 --- a/backend/routes/onboarding.js +++ b/backend/routes/onboarding.js @@ -1,6 +1,5 @@ import express from "express"; import { dbOps, userOps } from "../db/helpers/index.js"; -import { hashPassword } from "../middleware/passwordHash.js"; import { defaultData } from "../config/constants.js"; import { requirePasswordStrength, reconcileLocalNetworkBypassSetting } from "../middleware/auth.js"; import { validateDownloadFolderPath } from "../services/downloadFolderConfig.js"; @@ -11,6 +10,7 @@ import { fetchQualityProfiles, fetchMetadataProfiles, } from "../services/lidarrSettingsService.js"; +import { auth } from "../services/betterAuth.js"; const router = express.Router(); @@ -103,12 +103,16 @@ async function resolveLidarrProfiles(lidarr) { router.post("/complete", async (req, res) => { try { - const { authUser, authPassword, lidarr, security, downloadFolderPath } = req.body; - if (authPassword != null && String(authPassword).length > 0) { - const passwordValidation = requirePasswordStrength(authPassword); - if (!passwordValidation.valid) { - return res.status(400).json({ error: passwordValidation.error }); - } + const { auth: authInput, lidarr, security, downloadFolderPath } = req.body; + const authName = String(authInput?.name || "").trim(); + const authEmail = String(authInput?.email || "").trim().toLowerCase(); + const authPassword = String(authInput?.password || ""); + if (!authName || !authEmail || !authPassword) { + return res.status(400).json({ error: "Name, email, and password are required" }); + } + const passwordValidation = requirePasswordStrength(authPassword); + if (!passwordValidation.valid) { + return res.status(400).json({ error: passwordValidation.error }); } if (!lidarr?.url || !lidarr?.apiKey) { @@ -124,14 +128,6 @@ router.post("/complete", async (req, res) => { ...(current.integrations || defaultData.settings.integrations || {}), general: { ...(current.integrations?.general || {}), - authUser: - authUser != null - ? String(authUser).trim() - : current.integrations?.general?.authUser || "admin", - authPassword: - authPassword != null - ? String(authPassword) - : current.integrations?.general?.authPassword || "", }, lidarr: { ...(current.integrations?.lidarr || {}), @@ -173,15 +169,25 @@ router.post("/complete", async (req, res) => { nextSettings.downloadFolderPath = validation.path; } - dbOps.updateSettings(nextSettings); - - const authUserFinal = integrations?.general?.authUser || "admin"; - const authPasswordFinal = integrations?.general?.authPassword || ""; - if (authPasswordFinal && userOps.getAllUsers().length === 0) { - const hash = hashPassword(authPasswordFinal); - userOps.createUser(authUserFinal, hash, "admin", null); + if (userOps.countUsers() === 0) { + await auth.api.signUpEmail({ + body: { + name: authName, + email: authEmail, + password: authPassword, + username: authEmail, + displayUsername: authName, + role: "admin", + }, + }); + const createdUser = userOps.getUserByUsername(authEmail); + if (!createdUser || !userOps.updateUser(createdUser.id, { role: "admin" })) { + throw new Error("Failed to create the administrator account"); + } } + dbOps.updateSettings(nextSettings); + reconcileLocalNetworkBypassSetting(); if (integrations?.lidarr?.apiKey) { diff --git a/backend/routes/users.js b/backend/routes/users.js index 892042fd0..78fe6a636 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,10 +1,10 @@ import express from "express"; import { userOps, dbOps } from "../db/helpers/index.js"; -import { hashPassword, verifyPassword } from "../middleware/passwordHash.js"; import { requireAuth, requireAdmin } from "../middleware/requirePermission.js"; import { reconcileLocalNetworkBypassSetting } from "../middleware/auth.js"; import { requirePasswordStrength } from "../middleware/auth.js"; -import { deleteSessionsByUserId } from "../config/session-helpers.js"; +import { auth, revokeUserSessions } from "../services/betterAuth.js"; +import { fromNodeHeaders } from "better-auth/node"; import { websocketService } from "../services/websocketService.js"; import { getListenHistoryCacheNamespace, @@ -190,21 +190,29 @@ router.get("/", requireAuth, requireAdmin, async (req, res) => { router.post("/", requireAuth, requireAdmin, async (req, res) => { try { - const { username, password, role = "user", permissions } = req.body; - const un = String(username || "").trim(); - if (!un || !password) { - return res.status(400).json({ error: "Username and password required" }); - } - if (userOps.getUserByUsername(un)) { - return res.status(409).json({ error: "Username already exists" }); + const { email, name, password, role = "user", permissions } = req.body; + if (!String(email || "").trim() || !String(name || "").trim() || !password) { + return res.status(400).json({ error: "Name, email, and password required" }); } const passwordValidation = requirePasswordStrength(password); if (!passwordValidation.valid) { return res.status(400).json({ error: passwordValidation.error }); } - const hash = hashPassword(password); const perms = permissions ? { ...userOps.getDefaultPermissions(), ...permissions } : null; - const created = userOps.createUser(un, hash, role, perms); + const result = await auth.api.createUser({ + body: { + email: String(email).trim().toLowerCase(), + name: String(name).trim(), + password, + role, + data: { + username: String(email).trim().toLowerCase(), + displayUsername: String(name).trim(), + permissions: perms, + }, + }, + }); + const created = result?.user || result; if (!created) { return res.status(500).json({ error: "Failed to create user" }); } @@ -262,14 +270,18 @@ router.patch("/:id", requireAuth, async (req, res) => { if (!currentPassword) { return res.status(400).json({ error: "currentPassword required to change password" }); } - if (!verifyPassword(currentPassword, existing.passwordHash)) { - return res.status(400).json({ error: "Current password is incorrect" }); - } const passwordValidation = requirePasswordStrength(password); if (!passwordValidation.valid) { return res.status(400).json({ error: passwordValidation.error }); } - updates.passwordHash = hashPassword(password); + await auth.api.changePassword({ + headers: fromNodeHeaders(req.headers), + body: { + currentPassword, + newPassword: password, + revokeOtherSessions: true, + }, + }); } if (Object.keys(updates).length === 0) { return res.json({ @@ -285,9 +297,6 @@ router.patch("/:id", requireAuth, async (req, res) => { }); } const updated = userOps.updateUser(id, updates); - if (updates.passwordHash) { - deleteSessionsByUserId(id); - } return res.json(updated); } const updates = {}; @@ -296,7 +305,9 @@ router.patch("/:id", requireAuth, async (req, res) => { if (!passwordValidation.valid) { return res.status(400).json({ error: passwordValidation.error }); } - updates.passwordHash = hashPassword(password); + await auth.api.setUserPassword({ + body: { userId: String(id), newPassword: password }, + }); } if (permissions !== undefined) updates.permissions = permissions; if (role !== undefined) updates.role = role; @@ -524,20 +535,21 @@ router.post("/me/password", requireAuth, async (req, res) => { if (!passwordValidation.valid) { return res.status(400).json({ error: passwordValidation.error }); } - const u = userOps.getUserById(req.user.id); - if (!u || !verifyPassword(currentPassword || "", u.passwordHash)) { - return res.status(400).json({ error: "Current password is incorrect" }); - } - const hash = hashPassword(newPassword); - userOps.updateUser(req.user.id, { passwordHash: hash }); - deleteSessionsByUserId(req.user.id); + await auth.api.changePassword({ + headers: fromNodeHeaders(req.headers), + body: { + currentPassword: currentPassword || "", + newPassword, + revokeOtherSessions: true, + }, + }); res.json({ success: true }); } catch (e) { res.status(500).json({ error: "Failed to change password", message: e.message }); } }); -router.delete("/:id", requireAuth, requireAdmin, (req, res) => { +router.delete("/:id", requireAuth, requireAdmin, async (req, res) => { try { const id = parseInt(req.params.id, 10); if (req.user.id === id) { @@ -547,8 +559,8 @@ router.delete("/:id", requireAuth, requireAdmin, (req, res) => { if (!existing) { return res.status(404).json({ error: "User not found" }); } - deleteSessionsByUserId(id); - userOps.deleteUser(id); + await revokeUserSessions(id); + await auth.api.removeUser({ body: { userId: String(id) } }); reconcileLocalBypassAfterUserMutation(); res.json({ success: true }); } catch (e) { diff --git a/backend/scripts/resetAdminPassword.js b/backend/scripts/resetAdminPassword.js index 9216ca505..8d891668e 100644 --- a/backend/scripts/resetAdminPassword.js +++ b/backend/scripts/resetAdminPassword.js @@ -1,6 +1,6 @@ import crypto from "crypto"; import { dbOps, userOps } from "../db/helpers/index.js"; -import { hashPassword } from "../middleware/passwordHash.js"; +import { auth } from "../services/betterAuth.js"; function parseArgs(argv) { const args = { @@ -89,21 +89,7 @@ function resolveConfiguredAdminUsername(settings) { ); } -function upsertGeneralAuth(settings, username, password) { - return { - ...settings, - integrations: { - ...(settings.integrations || {}), - general: { - ...(settings.integrations?.general || {}), - authUser: username, - authPassword: password, - }, - }, - }; -} - -function main() { +async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { printUsage(); @@ -124,17 +110,26 @@ function main() { process.exit(1); } - const hash = hashPassword(password); const existing = userOps.getUserByUsername(username); let resultUser = null; if (existing) { - resultUser = userOps.updateUser(existing.id, { - passwordHash: hash, - role: "admin", + await auth.api.setUserPassword({ + body: { userId: String(existing.id), newPassword: password }, }); + resultUser = userOps.updateUser(existing.id, { role: "admin" }); } else { - resultUser = userOps.createUser(username, hash, "admin", null); + const email = username.includes("@") ? username : `${username}@aurral.invalid`; + const created = await auth.api.createUser({ + body: { + email, + name: username, + password, + role: "admin", + data: { username, displayUsername: username }, + }, + }); + resultUser = created?.user || created; } if (!resultUser) { @@ -142,11 +137,12 @@ function main() { process.exit(1); } - dbOps.updateSettings(upsertGeneralAuth(currentSettings, username, password)); - console.log("Admin password reset successful."); - console.log(`Username: ${username}`); + console.log(`Email: ${resultUser.email}`); console.log(`Password: ${password}`); } -main(); +main().catch((error) => { + console.error(error.message || "Failed to update admin password."); + process.exit(1); +}); diff --git a/backend/server.js b/backend/server.js index 58a7bcfad..a2932c99c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -10,7 +10,10 @@ import dns from "node:dns"; dns.setDefaultResultOrder("ipv4first"); import { authMiddleware, isProxyAuthEnabled } from "./middleware/auth.js"; -import { handleOidcCallback, isOidcEnabled } from "./services/oidcAuth.js"; +import { + betterAuthHandler, + isOidcEnabled, +} from "./services/betterAuth.js"; import { logger } from "./services/logger.js"; import { websocketService } from "./services/websocketService.js"; import { @@ -185,17 +188,17 @@ app.use((req, res, next) => { } next(); }); -app.use(express.json({ limit: JSON_BODY_LIMIT })); - -app.use(authMiddleware); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, }); -app.use("/api/auth/login", authLimiter); -app.use("/api/auth/oidc/login", authLimiter); -app.use("/api/auth/oidc/exchange", authLimiter); +app.all("/api/auth/*splat", betterAuthHandler); + +app.use(express.json({ limit: JSON_BODY_LIMIT })); + +app.use(authMiddleware); + app.use("/api/users/me/password", authLimiter); const limiter = rateLimit({ @@ -223,24 +226,12 @@ app.use("/api/weekly-flow", (req, res) => { const target = req.originalUrl.replace("/api/weekly-flow", "/api/playlists"); res.redirect(308, target); }); -app.use("/api/auth", authRouter); +app.use("/api/aurral-auth", authRouter); app.use("/api/scrobbling", scrobblingRouter); app.use("/api/play-events", playEventsRouter); app.use("/api/image-proxy", imageProxyRouter); app.use("/rest", subsonicRouter); -app.get("/sso/callback", async (req, res) => { - try { - const result = await handleOidcCallback(req); - const code = encodeURIComponent(result.code); - res.redirect(302, `/sso/complete#code=${code}`); - } catch (error) { - logger.error("auth", "OIDC callback failed:", { message: error.message }); - const message = encodeURIComponent(error.message || "OIDC login failed"); - res.redirect(302, `/sso/complete#error=${message}`); - } -}); - const frontendDist = path.join(__dirname, "..", "frontend", "dist"); const frontendFallbackRoute = /.*/; diff --git a/backend/services/betterAuth.js b/backend/services/betterAuth.js new file mode 100644 index 000000000..e63b48d59 --- /dev/null +++ b/backend/services/betterAuth.js @@ -0,0 +1,298 @@ +import crypto from "node:crypto"; +import { betterAuth } from "better-auth"; +import { toNodeHandler, fromNodeHeaders } from "better-auth/node"; +import { admin, bearer, genericOAuth, username } from "better-auth/plugins"; +import { db } from "../config/db-sqlite.js"; +import { hashPassword, verifyPassword } from "../middleware/passwordHash.js"; + +const DEFAULT_PERMISSIONS = { + accessFlow: false, + addArtist: true, + addAlbum: true, + changeMonitoring: false, + deleteArtist: false, + deleteAlbum: false, + deleteTrack: false, +}; + +const parseCsv = (value) => + String(value || "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + +function getSecret() { + const configured = String(process.env.BETTER_AUTH_SECRET || "").trim(); + if (configured) return configured; + const stored = db.prepare("SELECT value FROM settings WHERE key = ?").get("_betterAuthSecret"); + if (stored?.value) return stored.value; + const generated = crypto.randomBytes(32).toString("base64url"); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( + "_betterAuthSecret", + generated, + ); + return generated; +} + +function getBaseURL() { + return ( + String(process.env.BETTER_AUTH_URL || process.env.AURRAL_PUBLIC_URL || "").replace(/\/+$/, "") || + `http://127.0.0.1:${process.env.PORT || 3001}` + ); +} + +function getSessionExpirySeconds() { + const hours = Number(process.env.SESSION_EXPIRY_HOURS); + return Math.round((Number.isFinite(hours) && hours > 0 ? hours : 720) * 60 * 60); +} + +function resolveOidcUsername(profile = {}) { + const claim = String(process.env.OIDC_USERNAME_CLAIM || "preferred_username").trim(); + return String(profile[claim] || profile.email || profile.sub || "").trim().toLowerCase(); +} + +function resolveOidcRole(profile = {}, usernameValue = "") { + const usernameLower = String(usernameValue).toLowerCase(); + if (parseCsv(process.env.OIDC_ADMIN_USERS).some((entry) => entry.toLowerCase() === usernameLower)) { + return "admin"; + } + const groupsClaim = String(process.env.OIDC_GROUPS_CLAIM || "").trim(); + const groups = Array.isArray(profile[groupsClaim]) + ? profile[groupsClaim] + : String(profile[groupsClaim] || "").split(/[\s,]+/); + const adminGroups = new Set( + parseCsv(process.env.OIDC_ADMIN_GROUPS).map((entry) => entry.toLowerCase()), + ); + if (groups.some((entry) => adminGroups.has(String(entry).trim().toLowerCase()))) return "admin"; + return String(process.env.OIDC_DEFAULT_ROLE || "user").toLowerCase() === "admin" + ? "admin" + : "user"; +} + +export function isOidcEnabled() { + return ( + process.env.OIDC_ENABLED === "true" && + Boolean( + String(process.env.OIDC_ISSUER || "").trim() && + String(process.env.OIDC_CLIENT_ID || "").trim() && + String(process.env.OIDC_CLIENT_SECRET || "").trim(), + ) + ); +} + +export function getOidcBootstrapInfo() { + return { + oidcEnabled: isOidcEnabled(), + oidcLogoutUrl: isOidcEnabled() ? process.env.OIDC_LOGOUT_URL || null : null, + }; +} + +function getOidcPlugin() { + if (!isOidcEnabled()) return null; + const issuer = String(process.env.OIDC_ISSUER).replace(/\/+$/, ""); + const providerId = String(process.env.OIDC_PROVIDER_ID || "oidc").trim(); + return genericOAuth({ + config: [ + { + providerId, + discoveryUrl: + String(process.env.OIDC_DISCOVERY_URL || "").trim() || + `${issuer}/.well-known/openid-configuration`, + clientId: String(process.env.OIDC_CLIENT_ID), + clientSecret: String(process.env.OIDC_CLIENT_SECRET), + redirectURI: String(process.env.OIDC_REDIRECT_URI || "").trim() || undefined, + scopes: String(process.env.OIDC_SCOPES || "openid profile email") + .split(/\s+/) + .filter(Boolean), + requireIdTokenVerification: true, + mapProfileToUser(profile) { + const resolvedUsername = resolveOidcUsername(profile); + const role = resolveOidcRole(profile, resolvedUsername); + return { + username: resolvedUsername, + displayUsername: resolvedUsername, + role, + permissions: role === "admin" ? {} : DEFAULT_PERMISSIONS, + }; + }, + }, + ], + }); +} + +const oidcPlugin = getOidcPlugin(); +const trustedOrigins = [getBaseURL(), ...parseCsv(process.env.CORS_ORIGIN)]; + +export const auth = betterAuth({ + appName: "Aurral", + baseURL: getBaseURL(), + basePath: "/api/auth", + secret: getSecret(), + database: db, + trustedOrigins, + emailAndPassword: { + enabled: true, + minPasswordLength: 8, + revokeSessionsOnPasswordReset: true, + password: { + hash: async (password) => hashPassword(password), + verify: async ({ hash, password }) => verifyPassword(password, hash), + }, + }, + user: { + modelName: "users", + fields: { + name: "name", + email: "email", + emailVerified: "email_verified", + image: "image", + createdAt: "created_at", + updatedAt: "updated_at", + }, + additionalFields: { + permissions: { + type: "json", + fieldName: "permissions", + required: false, + defaultValue: DEFAULT_PERMISSIONS, + input: true, + }, + passwordHash: { + type: "string", + fieldName: "password_hash", + required: false, + defaultValue: "", + returned: false, + input: false, + }, + }, + }, + session: { + modelName: "sessions", + fields: { + userId: "user_id", + token: "token", + expiresAt: "expires_at", + ipAddress: "ip_address", + userAgent: "user_agent", + createdAt: "created_at", + updatedAt: "updated_at", + }, + expiresIn: getSessionExpirySeconds(), + updateAge: 24 * 60 * 60, + }, + account: { + modelName: "accounts", + fields: { + userId: "user_id", + accountId: "account_id", + providerId: "provider_id", + accessToken: "access_token", + refreshToken: "refresh_token", + idToken: "id_token", + accessTokenExpiresAt: "access_token_expires_at", + refreshTokenExpiresAt: "refresh_token_expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + verification: { + modelName: "verifications", + fields: { + expiresAt: "expires_at", + createdAt: "created_at", + updatedAt: "updated_at", + }, + }, + databaseHooks: { + user: { + create: { + before: async (user) => ({ + data: { + ...user, + username: String(user.username || user.email || "").trim().toLowerCase(), + displayUsername: String(user.displayUsername || user.name || user.email || "").trim(), + }, + }), + }, + }, + }, + advanced: { + database: { generateId: "serial" }, + useSecureCookies: getBaseURL().startsWith("https://"), + }, + plugins: [ + bearer(), + username({ + minUsernameLength: 1, + maxUsernameLength: 254, + usernameValidator: (value) => Boolean(String(value || "").trim()), + schema: { + user: { + fields: { + username: "username", + displayUsername: "display_username", + }, + }, + }, + }), + admin({ + defaultRole: "user", + adminRoles: ["admin"], + schema: { + user: { + fields: { + role: "role", + banned: "banned", + banReason: "ban_reason", + banExpires: "ban_expires", + }, + }, + session: { fields: { impersonatedBy: "impersonated_by" } }, + }, + }), + ...(oidcPlugin ? [oidcPlugin] : []), + ], + telemetry: { enabled: false }, +}); + +export const betterAuthHandler = toNodeHandler(auth); + +export async function getSessionForHeaders(headers) { + const session = await auth.api.getSession({ + headers: headers instanceof Headers ? headers : fromNodeHeaders(headers || {}), + }); + return session || null; +} + +export async function createAuthUser({ email, name, username: usernameValue, role = "user", permissions }) { + const normalizedEmail = String(email || "").trim().toLowerCase(); + const normalizedName = String(name || usernameValue || normalizedEmail).trim(); + const normalizedUsername = String(usernameValue || normalizedEmail).trim().toLowerCase(); + const result = await auth.api.createUser({ + body: { + email: normalizedEmail, + name: normalizedName, + role, + data: { + username: normalizedUsername, + displayUsername: normalizedName, + permissions: permissions || DEFAULT_PERMISSIONS, + }, + }, + }); + return result?.user || result || null; +} + +export async function createAuthSession(userId, request = {}) { + const context = await auth.$context; + return context.internalAdapter.createSession(String(userId), false, { + ipAddress: request.ip || null, + userAgent: request.headers?.["user-agent"] || null, + }); +} + +export async function revokeUserSessions(userId) { + const context = await auth.$context; + await context.internalAdapter.deleteUserSessions(String(userId)); +} diff --git a/backend/services/honkerDb.js b/backend/services/honkerDb.js index c6981bcae..1102a8d67 100644 --- a/backend/services/honkerDb.js +++ b/backend/services/honkerDb.js @@ -55,12 +55,6 @@ export const SCHEDULED_SYSTEM_TASKS = [ schedule: "@every 1h", payload: { kind: "weekly-flow-refresh" }, }, - { - name: "session-cleanup", - queue: "system-task", - schedule: "@every 1h", - payload: { kind: "session-cleanup" }, - }, { name: "weekly-flow-reuse-repair", queue: "system-task", diff --git a/backend/services/oidcAuth.js b/backend/services/oidcAuth.js deleted file mode 100644 index a129ad80d..000000000 --- a/backend/services/oidcAuth.js +++ /dev/null @@ -1,306 +0,0 @@ -import * as client from "openid-client"; -import { createSession } from "../config/session-helpers.js"; -import { ensureExternalUser } from "../middleware/auth.js"; - -const STATE_TTL_MS = 10 * 60 * 1000; -const EXCHANGE_TTL_MS = 60 * 1000; -const OIDC_TRANSACTION_COOKIE = "aurral_oidc_transaction"; -const pendingLogins = new Map(); -const pendingExchanges = new Map(); -let discoveryConfig = null; -let discoveryKey = ""; - -function parseCsv(value) { - if (!value) return []; - return String(value) - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -function prunePendingLogins(now = Date.now()) { - for (const [state, entry] of pendingLogins) { - if (!entry || entry.expiresAt <= now) pendingLogins.delete(state); - } -} - -function prunePendingExchanges(now = Date.now()) { - for (const [code, entry] of pendingExchanges) { - if (!entry || entry.expiresAt <= now) pendingExchanges.delete(code); - } -} - -function getTransactionCookie(req) { - const cookies = String(req.headers?.cookie || "").split(";"); - for (const cookie of cookies) { - const [name, ...parts] = cookie.trim().split("="); - if (name !== OIDC_TRANSACTION_COOKIE) continue; - try { - return decodeURIComponent(parts.join("=")); - } catch { - return ""; - } - } - return ""; -} - -function setTransactionCookie(req, res, value, maxAge) { - const secure = req.secure || req.protocol === "https"; - const attributes = [ - `${OIDC_TRANSACTION_COOKIE}=${encodeURIComponent(value)}`, - "Path=/", - "HttpOnly", - "SameSite=Lax", - ]; - if (secure) attributes.push("Secure"); - if (maxAge != null) attributes.push(`Max-Age=${maxAge}`); - res.setHeader("Set-Cookie", attributes.join("; ")); -} - -function getRequiredConfig() { - const issuer = String(process.env.OIDC_ISSUER || "").trim(); - const clientId = String(process.env.OIDC_CLIENT_ID || "").trim(); - const clientSecret = String(process.env.OIDC_CLIENT_SECRET || "").trim(); - const redirectUri = String(process.env.OIDC_REDIRECT_URI || "").trim(); - if (!issuer || !clientId || !clientSecret || !redirectUri) return null; - return { issuer, clientId, clientSecret, redirectUri }; -} - -export function isOidcEnabled() { - if (process.env.OIDC_ENABLED !== "true") return false; - return !!getRequiredConfig(); -} - -export function getOidcBootstrapInfo() { - if (!isOidcEnabled()) { - return { - oidcEnabled: false, - oidcLogoutUrl: null, - }; - } - return { - oidcEnabled: true, - oidcLogoutUrl: process.env.OIDC_LOGOUT_URL || null, - }; -} - -function getScopes() { - const scopes = String(process.env.OIDC_SCOPES || "openid profile email") - .trim() - .replace(/\s+/g, " "); - return scopes || "openid profile email"; -} - -function getUsernameClaim() { - return String(process.env.OIDC_USERNAME_CLAIM || "preferred_username").trim() || "preferred_username"; -} - -function getGroupsClaim() { - return String(process.env.OIDC_GROUPS_CLAIM || "").trim(); -} - -function normalizeGroups(value) { - if (Array.isArray(value)) { - return value.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean); - } - if (value == null) return []; - return String(value) - .split(/[,\s]+/) - .map((item) => item.trim().toLowerCase()) - .filter(Boolean); -} - -export function resolveOidcRole(username, claims = {}) { - const adminUsers = parseCsv(process.env.OIDC_ADMIN_USERS).map((u) => u.toLowerCase()); - if (adminUsers.includes(String(username || "").toLowerCase())) return "admin"; - - const groupsClaim = getGroupsClaim(); - if (groupsClaim) { - const groups = normalizeGroups(claims[groupsClaim]); - const adminGroups = parseCsv(process.env.OIDC_ADMIN_GROUPS).map((g) => g.toLowerCase()); - if (groups.some((g) => adminGroups.includes(g))) return "admin"; - } - - return (process.env.OIDC_DEFAULT_ROLE || "user").trim().toLowerCase() === "admin" - ? "admin" - : "user"; -} - -export function resolveOidcUsername(claims = {}) { - const claimName = getUsernameClaim(); - const primary = String(claims[claimName] || "").trim(); - if (primary) return primary.toLowerCase(); - const email = String(claims.email || "").trim(); - if (email) return email.toLowerCase(); - return ""; -} - -async function fetchEffectiveClaims(oidc, tokens, claims) { - if (!claims.sub || !tokens.access_token) return claims; - - try { - const userInfo = await client.fetchUserInfo(oidc, tokens.access_token, claims.sub); - const effectiveClaims = { ...claims, ...userInfo }; - const groupsClaim = getGroupsClaim(); - if (groupsClaim) { - effectiveClaims[groupsClaim] = claims[groupsClaim]; - } - return effectiveClaims; - } catch { - return claims; - } -} - -async function getDiscoveryConfig() { - const config = getRequiredConfig(); - if (!config) { - throw new Error("OIDC is not configured"); - } - const key = `${config.issuer}|${config.clientId}|${config.clientSecret}|${config.redirectUri}`; - if (discoveryConfig && discoveryKey === key) return { config, oidc: discoveryConfig }; - const issuerUrl = new URL(config.issuer); - const discoveryOptions = - issuerUrl.protocol === "http:" ? { execute: [client.allowInsecureRequests] } : undefined; - discoveryConfig = await client.discovery( - issuerUrl, - config.clientId, - config.clientSecret, - undefined, - discoveryOptions, - ); - discoveryKey = key; - return { config, oidc: discoveryConfig }; -} - -function buildCallbackUrl(req) { - const redirectUri = getRequiredConfig()?.redirectUri; - if (!redirectUri) throw new Error("OIDC_REDIRECT_URI is required"); - const url = new URL(redirectUri); - for (const [key, value] of Object.entries(req.query || {})) { - if (value == null) continue; - if (Array.isArray(value)) { - if (value[0] != null) url.searchParams.set(key, String(value[0])); - continue; - } - url.searchParams.set(key, String(value)); - } - return url; -} - -export async function startOidcLogin(req, res) { - if (!isOidcEnabled()) { - res.status(404).json({ error: "OIDC is not enabled" }); - return; - } - - const { config, oidc } = await getDiscoveryConfig(); - const codeVerifier = client.randomPKCECodeVerifier(); - const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); - const state = client.randomState(); - const nonce = client.randomNonce(); - const transactionId = client.randomState(); - - prunePendingLogins(); - pendingLogins.set(state, { - codeVerifier, - nonce, - transactionId, - expiresAt: Date.now() + STATE_TTL_MS, - }); - - const parameters = { - redirect_uri: config.redirectUri, - scope: getScopes(), - code_challenge: codeChallenge, - code_challenge_method: "S256", - state, - nonce, - }; - - const redirectTo = client.buildAuthorizationUrl(oidc, parameters); - setTransactionCookie(req, res, transactionId); - res.redirect(302, redirectTo.href); -} - -export async function handleOidcCallback(req) { - if (!isOidcEnabled()) { - throw Object.assign(new Error("OIDC is not enabled"), { status: 404 }); - } - - const state = String(req.query?.state || ""); - const transactionId = getTransactionCookie(req); - prunePendingLogins(); - const pending = pendingLogins.get(state); - pendingLogins.delete(state); - if (!pending || pending.expiresAt <= Date.now() || pending.transactionId !== transactionId) { - throw Object.assign(new Error("OIDC login session expired"), { status: 400 }); - } - - const { oidc } = await getDiscoveryConfig(); - const tokens = await client.authorizationCodeGrant(oidc, buildCallbackUrl(req), { - pkceCodeVerifier: pending.codeVerifier, - expectedState: state, - expectedNonce: pending.nonce, - idTokenExpected: true, - }); - - const claims = await fetchEffectiveClaims(oidc, tokens, tokens.claims() || {}); - - const username = resolveOidcUsername(claims); - if (!username) { - throw Object.assign(new Error("OIDC identity did not include a usable username"), { - status: 400, - }); - } - - const role = resolveOidcRole(username, claims); - const user = ensureExternalUser(username, role); - if (!user?.id || user.id < 0) { - throw Object.assign(new Error("Failed to provision OIDC user"), { status: 500 }); - } - - const code = client.randomState(); - prunePendingExchanges(); - pendingExchanges.set(code, { - expiresAt: Date.now() + EXCHANGE_TTL_MS, - transactionId, - user, - }); - return { - code, - user, - }; -} - -export function exchangeOidcCallback(code, req) { - if (!isOidcEnabled()) { - throw Object.assign(new Error("OIDC is not enabled"), { status: 404 }); - } - - const transactionId = getTransactionCookie(req); - const exchangeCode = String(code || ""); - prunePendingExchanges(); - const pending = pendingExchanges.get(exchangeCode); - if (!pending || pending.expiresAt <= Date.now() || pending.transactionId !== transactionId) { - throw Object.assign(new Error("OIDC login session expired"), { status: 400 }); - } - - pendingExchanges.delete(exchangeCode); - const session = createSession(pending.user.id, req.ip || null, req.headers["user-agent"] || null); - return { - token: session.token, - expiresAt: session.expiresAt, - user: pending.user, - }; -} - -export function clearOidcTransactionCookie(req, res) { - setTransactionCookie(req, res, "", 0); -} - -export function resetOidcStateForTests() { - pendingLogins.clear(); - pendingExchanges.clear(); - discoveryConfig = null; - discoveryKey = ""; -} diff --git a/backend/services/systemTaskWorker.js b/backend/services/systemTaskWorker.js index 376d6bc0c..bad7ac4ef 100644 --- a/backend/services/systemTaskWorker.js +++ b/backend/services/systemTaskWorker.js @@ -4,7 +4,6 @@ import { PLAYLIST_STARTUP_MIGRATION_SETTING, PLAYLIST_STARTUP_MIGRATION_VERSION, } from "./honkerDb.js"; -import { cleanExpiredSessions } from "../config/session-helpers.js"; import { dbOps } from "../db/helpers/index.js"; import { resolvePlaylistRoot } from "./playlistPaths.js"; @@ -16,9 +15,6 @@ export async function processSystemTask(payload = {}, job = null) { await runScheduledRefresh(); return; } - case "session-cleanup": - cleanExpiredSessions(); - return; case "weekly-flow-reuse-repair": { const { weeklyFlowWorker } = await import("./weeklyFlow/weeklyFlowWorker.js"); weeklyFlowWorker.scheduleReuseLinkRepair(false); diff --git a/backend/services/websocketService.js b/backend/services/websocketService.js index 1bc33d44e..3999bd5b2 100644 --- a/backend/services/websocketService.js +++ b/backend/services/websocketService.js @@ -5,6 +5,7 @@ import { getAuthPassword, isProxyAuthEnabled, resolveLocalNetworkBypassUser, + resolveSessionUserFromHeaders, resolveSessionUserFromToken, resolveProxyUser, } from "../middleware/auth.js"; @@ -33,20 +34,27 @@ class WebSocketService { }); this.wss.on('connection', (ws, req) => { - this.handleConnection(ws, req); + this.handleConnection(ws, req).catch((error) => { + logger.error("system", "Failed to authenticate WebSocket client", { + error: error.message, + }); + ws.close(1011, "Authentication failed"); + }); }); logger.info("system", "WebSocket server initialized on /ws"); return this; } - handleConnection(ws, req) { + async handleConnection(ws, req) { let sessionUser = null; let authSource = null; if (isAuthRequired()) { const requestUrl = new URL(req.url || "", "http://localhost"); const token = requestUrl.searchParams.get("token"); - sessionUser = resolveSessionUserFromToken(token); + sessionUser = token + ? await resolveSessionUserFromToken(token) + : await resolveSessionUserFromHeaders(req.headers); if (!sessionUser) { sessionUser = resolveProxyUser(req); } diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 1343e32bd..476550290 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -7,6 +7,8 @@ services: environment: - PUID=${PUID:-1000} - PGID=${PGID:-1000} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET in .env} + - BETTER_AUTH_URL=${BETTER_AUTH_URL:?Set BETTER_AUTH_URL in .env} volumes: - ${MEDIA_ROOT:-/srv/media}:/data - ./config:/config diff --git a/docs/architecture/0002-better-auth.md b/docs/architecture/0002-better-auth.md new file mode 100644 index 000000000..b7736ee6d --- /dev/null +++ b/docs/architecture/0002-better-auth.md @@ -0,0 +1,49 @@ +# Better Auth migration + +## Decision + +Aurral uses Better Auth for local credentials, sessions, bearer tokens, OIDC provider transactions, and account administration. The application keeps a thin adapter for behavior that belongs to Aurral's protocols or deployment boundary. + +Better Auth owns these SQLite tables: + +- `user` +- `session` +- `account` +- `verification` + +Aurral keeps application permissions, listening-history settings, Lidarr preferences, discovery layout, and other user-scoped data in its existing application tables. Existing numeric user IDs remain stable because application tables reference them. + +## Request flow + +The Express server mounts Better Auth under `/api/auth/*`. The frontend uses the Better Auth endpoints for email sign-in, session lookup, sign-out, password changes, OIDC sign-in, and administrator user management. + +The auth adapter resolves a Better Auth session into Aurral's request user shape. Permission middleware and route handlers continue to consume that shape. The adapter also handles trusted reverse-proxy identity, LAN auto-login, the instance API key, Subsonic authentication, media tokens, and WebSocket query tokens. + +Better Auth bearer tokens use the `Authorization` header for HTTP requests. Aurral's WebSocket adapter accepts the same session token as `/ws?token=SESSION_TOKEN` because the browser supplies the token during the WebSocket handshake. Media routes keep their short-lived query-token flow. + +## OIDC + +Better Auth starts the OIDC provider flow with `POST /api/auth/sign-in/social` and completes it at `/api/auth/callback/oidc`. Better Auth owns state, nonce, PKCE, provider account linking, and session creation. Aurral maps the provider profile to its application role and compatibility username data. + +Do not add a second callback or session exchange layer around Better Auth. + +## Migration + +The database migration creates the Better Auth tables and copies existing local users into Better Auth user and credential-account records. It preserves numeric IDs, password hashes, roles, permissions, and application foreign keys. Existing custom sessions are not copied. Users sign in again after the migration. + +`BETTER_AUTH_SECRET` must remain stable across restarts and upgrades. Operators must back up `/config` and the matching environment or secret store before migration. A rollback requires restoring the pre-migration database and configuration together. The previous application version must not open a migrated database. + +## Ownership boundary + +| Concern | Owner | +| --- | --- | +| Email/password credentials | Better Auth | +| Browser and bearer sessions | Better Auth | +| OIDC state, PKCE, callback, and provider account | Better Auth | +| User administration endpoints | Better Auth, with Aurral permission data | +| Aurral roles and feature permissions | Aurral adapter and permission checks | +| Trusted proxy headers and source IPs | Aurral adapter | +| LAN auto-login | Aurral adapter | +| Instance API key | Aurral adapter | +| Subsonic protocol credentials and tokens | Aurral adapter | +| Media and WebSocket transport tokens | Aurral adapter | diff --git a/docs/src/content/docs/admin/environment.mdx b/docs/src/content/docs/admin/environment.mdx index dc82fced3..317ee92a1 100644 --- a/docs/src/content/docs/admin/environment.mdx +++ b/docs/src/content/docs/admin/environment.mdx @@ -21,33 +21,41 @@ Use the web UI for most settings. Use these variables for Docker deployment sett ## Authentication +Set `BETTER_AUTH_SECRET` to a long, random, persistent value. Generate one with: + +```bash +openssl rand -hex 32 +``` + +Keep the value in your environment file or secret store. Do not commit it to a Compose file. Keep the same value across restarts. Changing it invalidates active authentication state and requires users to sign in again. + | Variable | Purpose | | -------- | ------- | +| `BETTER_AUTH_SECRET` | Secret used by Better Auth for authentication state. Set it explicitly in production. | +| `BETTER_AUTH_URL` | Public Aurral origin used to build Better Auth callback URLs, for example `https://aurral.example.com`. | +| `SESSION_EXPIRY_HOURS` | Better Auth session lifetime in hours. Default `720` (30 days). | +| `AURRAL_PUBLIC_URL` | Public Aurral origin used by non-authentication integration callbacks. Better Auth uses `BETTER_AUTH_URL` for authentication callbacks. | | `TRUST_PROXY` | Set when Aurral is behind a reverse proxy. | -| `AURRAL_PUBLIC_URL` | Canonical public origin for OAuth callbacks (for example, `https://aurral.example.com`). | | `AUTH_PROXY_ENABLED` | Enable reverse-proxy authentication. Default header `x-forwarded-user`. | -| `AUTH_PROXY_HEADER` | Custom header that contains the authenticated username. | -| `AUTH_PROXY_DOMAIN` | Origin of your forwardAuth login page (for example, `https://auth.example.com`). Aurral adds it to the `connect-src` content security policy so pages can reach your authentication origin. | -| `AUTH_PROXY_TRUSTED_IPS` | Comma-separated proxy IP allowlist for authentication headers. You must set it when you enable proxy authentication. Without it, a direct client can use the identity header to impersonate users. | -| `AUTH_PROXY_LOGOUT_URL` | Your proxy or IdP logout endpoint. For Authentik single-application forward auth, use `https://aurral.example.com/outpost.goauthentik.io/sign_out`. This value ends the proxy session when you log out of Aurral. Aurral hides its **Log out** control while proxy authentication is on and this value is unset. | -| `AUTH_PROXY_DEFAULT_ROLE` | Role for proxy-auth users who do not otherwise have the admin role: `user` or `admin`. Aurral evaluates the role on every request. | -| `AUTH_PROXY_ADMIN_USERS` | Comma-separated usernames that get the admin role. Aurral checks the list on each request. If you remove a username, Aurral changes that user on the next request. | -| `AUTH_PROXY_ROLE_HEADER` | Optional header that contains the user's group membership (for example, Authelia's `Remote-Groups`). Usually, this is a comma-separated list. Aurral compares it with `AUTH_PROXY_ADMIN_GROUPS` on every request. | -| `AUTH_PROXY_ADMIN_GROUPS` | Comma-separated group names that give the admin role. Aurral compares these names with `AUTH_PROXY_ROLE_HEADER`. A group named `admin` has no special function unless you list it here. | -| `OIDC_ENABLED` | Enable native OpenID Connect login. | -| `OIDC_ISSUER` | Identity provider issuer URL used for OIDC discovery. | +| `AUTH_PROXY_HEADER` | Custom header that contains the authenticated identity. | +| `AUTH_PROXY_DOMAIN` | Origin of your forwardAuth login page. Aurral adds it to the `connect-src` content security policy. | +| `AUTH_PROXY_TRUSTED_IPS` | Comma-separated proxy IP allowlist for authentication headers. Set it when you enable proxy authentication. | +| `AUTH_PROXY_LOGOUT_URL` | Proxy or identity-provider logout endpoint. Aurral redirects there after local logout. | +| `AUTH_PROXY_DEFAULT_ROLE` | Role for proxy identities that do not otherwise receive administrator access: `user` or `admin`. | +| `AUTH_PROXY_ADMIN_USERS` | Comma-separated identities that receive the `admin` role. | +| `AUTH_PROXY_ROLE_HEADER` | Header that contains group membership, such as Authelia's `Remote-Groups`. | +| `AUTH_PROXY_ADMIN_GROUPS` | Comma-separated groups that grant the `admin` role. | +| `OIDC_ENABLED` | Enable the Better Auth OIDC provider. | +| `OIDC_PROVIDER_ID` | Provider identifier used by Better Auth. Default `oidc`. | +| `OIDC_ISSUER` | Identity-provider issuer URL. | +| `OIDC_DISCOVERY_URL` | Optional discovery document URL when the provider does not use its standard location. | | `OIDC_CLIENT_ID` | OIDC client ID. | | `OIDC_CLIENT_SECRET` | OIDC client secret. | -| `OIDC_REDIRECT_URI` | Exact callback URL registered with your IdP. Must be `https:///sso/callback`. | -| `OIDC_SCOPES` | Space-separated scopes. Default `openid profile email`. | -| `OIDC_USERNAME_CLAIM` | Claim used as the Aurral username. Default `preferred_username`. Falls back to `email` when that claim is missing. | -| `OIDC_DEFAULT_ROLE` | Role for OIDC users who are not otherwise granted admin: `user` or `admin`. | -| `OIDC_ADMIN_USERS` | Comma-separated usernames that get the admin role at OIDC login. | -| `OIDC_GROUPS_CLAIM` | Optional ID-token claim that contains group membership. | -| `OIDC_ADMIN_GROUPS` | Comma-separated group names that grant the admin role when present in `OIDC_GROUPS_CLAIM`. | -| `OIDC_LOGOUT_URL` | Optional IdP logout URL. When set, Aurral redirects there after clearing the local session. | -| `OIDC_DOMAIN` | Optional IdP origin added to the `connect-src` content security policy. | -| `SESSION_EXPIRY_HOURS` | Session lifetime in hours for password login, proxy auth, and native OIDC. Default `720` (30 days). | +| `OIDC_REDIRECT_URI` | Exact callback URL registered with the provider. Use `https:///api/auth/callback/oidc`. | +| `OIDC_USERNAME_CLAIM` | Provider claim used for Aurral's compatibility username field. Default `preferred_username`. | +| `OIDC_ADMIN_USERS` | Comma-separated provider identities that receive the `admin` role. | + +Register only `/api/auth/callback/oidc` with the identity provider. Better Auth completes the provider transaction and creates the session. ## Cross-origin clients @@ -89,6 +97,8 @@ These variables change the shared Lidarr Spotify OAuth proxy. The default values environment: - PUID=1000 - PGID=1000 + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} + - BETTER_AUTH_URL=https://aurral.example.com - TRUST_PROXY=true ``` diff --git a/docs/src/content/docs/admin/storage.mdx b/docs/src/content/docs/admin/storage.mdx index c19ab0d03..9c2e18ada 100644 --- a/docs/src/content/docs/admin/storage.mdx +++ b/docs/src/content/docs/admin/storage.mdx @@ -11,7 +11,7 @@ For the complete mount model, follow [Filesystem and mounts](/getting-started/st | Path | Contains | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/config` | Database, settings, users, sessions, cache state, playlist jobs, and the default yt-dlp staging directory. Mount it with `./config:/config`. Do not share it with other apps. | +| `/config` | Database, settings, Better Auth users, accounts, sessions, verification data, cache state, playlist jobs, and the default yt-dlp staging directory. Mount it with `./config:/config`. Do not share it with other apps. | | `/data` (or Lidarr's media root) | Shared library and downloads. Mount the same host media root here in Aurral, Lidarr, download clients, and playback servers. | | Downloads Folder | Aurral-owned permanent tracks, temporary Flow tracks, imported playlists, and playlist artwork. Set in **Settings > Download clients > Downloads Folder > Path** (for example `/data/downloads/aurral`). | @@ -19,7 +19,11 @@ For the complete mount model, follow [Filesystem and mounts](/getting-started/st - Back up the mounted `/config` folder. - Back up the Downloads Folder if you want to keep generated playlists after a rebuild. -- Back up the Compose file and the environment file that contains your host paths. +- Back up the Compose file and the environment or secret store that contains your host paths and `BETTER_AUTH_SECRET`. + +Before an Aurral upgrade that changes the authentication schema, stop the container and make a complete copy of `/config` and the matching environment. The migration keeps users, passwords, permissions, and application data, but it invalidates existing sessions. Users must sign in again. + +If you roll back after the migration starts, stop Aurral and restore the pre-upgrade `/config` copy and its matching `BETTER_AUTH_SECRET` before starting the previous version. Do not run the previous version against the migrated database. Keep the media mount unchanged. yt-dlp stages downloads in `/config/_staging` by default. You can mount another persistent path and select it under **Settings > Download clients > yt-dlp > Staging path**. Keep it outside media-server libraries to avoid scanner activity while files are incomplete. diff --git a/docs/src/content/docs/admin/users.mdx b/docs/src/content/docs/admin/users.mdx index c6d1386ee..61dc138e3 100644 --- a/docs/src/content/docs/admin/users.mdx +++ b/docs/src/content/docs/admin/users.mdx @@ -1,12 +1,20 @@ --- title: Users and authentication -description: Local users, permissions, LAN auto-login, and reverse-proxy auth. +description: Manage local accounts, Better Auth sessions, OIDC, and Aurral authentication adapters. --- -Aurral supports multiple local users with individual permissions. +Aurral uses Better Auth for local credentials, browser sessions, bearer tokens, and OIDC. Aurral keeps the adapters that belong to its own protocols and deployment model. ![Aurral user settings](../../../assets/screenshots/settings-user.webp) +## Local accounts + +Each local account has a name, an email address, and a password. Use the email address to sign in. + +Admins create accounts from **Settings > Users**. The first account is created during onboarding. + +Passwords must contain at least eight characters. Better Auth stores the credential and owns password verification. Aurral stores application permissions and per-user settings alongside the Better Auth user record. + ## Permissions Admins can control whether users can: @@ -16,67 +24,61 @@ Admins can control whether users can: - Change monitoring - Delete artists or albums -## Authentication - -- Local username and password accounts -- Optional local-network auto-login for single-admin home setups -- Reverse-proxy authentication for SSO -- Native OpenID Connect login +The account role is `admin` or `user`. Aurral applies its permission checks to each API request after the authentication adapter resolves the Better Auth user. -## Native OIDC +## Sessions -Set `OIDC_ENABLED=true` and configure your identity provider as a confidential OIDC client. - -Required variables: - -- `OIDC_ISSUER` - discovery issuer URL -- `OIDC_CLIENT_ID` -- `OIDC_CLIENT_SECRET` -- `OIDC_REDIRECT_URI` - must be exactly `https://aurral.example.com/sso/callback` +Better Auth creates, reads, expires, and revokes sessions. The browser receives a bearer token in the `set-auth-token` response header after a successful sign-in and sends it as an `Authorization: Bearer` header. -Optional role mapping: +The public session endpoints are: -- `OIDC_DEFAULT_ROLE` - `user` (default) or `admin` -- `OIDC_ADMIN_USERS` - exact usernames promoted to admin at login -- `OIDC_GROUPS_CLAIM` - ID-token claim with group membership -- `OIDC_ADMIN_GROUPS` - groups from that claim that grant admin +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/auth/sign-in/email` | Sign in with an email address and password | +| `GET` | `/api/auth/get-session` | Read the current user and session | +| `POST` | `/api/auth/sign-out` | Revoke the current session | +| `POST` | `/api/auth/change-password` | Change the current user's password and revoke other sessions | -Aurral starts login at `/api/auth/oidc/login`, handles the IdP return at `/sso/callback`, then issues a normal Aurral session. Username comes from `OIDC_USERNAME_CLAIM` (default `preferred_username`), with a fallback to `email`. +Set `SESSION_EXPIRY_HOURS` to change the lifetime of a session. The default is `720` hours, or 30 days. A restart does not remove a valid session. A session expires when Better Auth reaches its stored expiry time or when the user signs out. -OIDC-created users cannot use local password login until an admin sets a password. Role changes from the IdP apply on the next OIDC login. +## Native OIDC -Set `OIDC_LOGOUT_URL` if you want **Log out** to end the identity-provider session as well. +Set `OIDC_ENABLED=true` and configure the identity provider as a confidential OIDC client. -Native OIDC and reverse-proxy auth can both stay configured. Use one as the primary browser login path for a given deployment. +Required variables: -## Reverse-proxy auth +- `BETTER_AUTH_URL`, the public Aurral origin +- `OIDC_ISSUER`, the provider issuer URL +- `OIDC_CLIENT_ID` +- `OIDC_CLIENT_SECRET` +- `OIDC_REDIRECT_URI`, set to `https://aurral.example.com/api/auth/callback/oidc` -Set `AUTH_PROXY_ENABLED=true` to use reverse-proxy authentication. Use `AUTH_PROXY_HEADER` if the proxy uses a custom header. +Set `OIDC_DISCOVERY_URL` when the provider's discovery document is not at its standard location. Set `OIDC_PROVIDER_ID` when the provider uses an identifier other than `oidc`. -The default username header is `x-forwarded-user`. +The browser starts OIDC with `POST /api/auth/sign-in/social` and the body `{ "provider": "oidc", "disableRedirect": true }`. Better Auth completes the provider callback at `/api/auth/callback/oidc`, validates the transaction, creates or links the account, and creates the session. -When a proxied username first reaches Aurral, Aurral creates a matching local user. The user role comes from one of these sources: +Use `OIDC_USERNAME_CLAIM` to choose the provider claim that fills Aurral's compatibility username field. The default is `preferred_username`. The account's Better Auth email and name remain the local identity fields. -- `AUTH_PROXY_DEFAULT_ROLE` - the default for anyone not otherwise granted admin -- An exact match in `AUTH_PROXY_ADMIN_USERS` -- Group membership from `AUTH_PROXY_ROLE_HEADER` (for example, Authelia's `Remote-Groups`), compared with `AUTH_PROXY_ADMIN_GROUPS` +Use `OIDC_ADMIN_USERS` for a comma-separated list of provider identities that receive the `admin` role. OIDC users without a local credential cannot sign in with a password until an administrator sets one. -Aurral evaluates the role on every request. A proxy or identity-provider role change takes effect on the next request. -You do not have to edit the account in Aurral. +Native OIDC and reverse-proxy authentication can both be configured. Choose one as the primary browser sign-in path for a deployment. -Set `AUTH_PROXY_TRUSTED_IPS` to your reverse proxy's address. Without it, Aurral accepts identity headers from any client that can reach Aurral. +## Reverse-proxy authentication -On the first request that your proxy authenticates, Aurral issues its own session and the browser uses it for later API calls. Use `SESSION_EXPIRY_HOURS` to change how long an Aurral session lasts. +Set `AUTH_PROXY_ENABLED=true` to use a reverse proxy as the identity provider. Use `AUTH_PROXY_HEADER` if the proxy uses a custom identity header. The default header is `x-forwarded-user`. -Most setups also protect `/api` with the proxy, so the proxy still decides every request. Page loads always go to the network rather than to Aurral's offline cache, so your proxy can redirect them to the identity provider whenever its session has ended. +Aurral accepts proxy identity headers only from `AUTH_PROXY_TRUSTED_IPS`. Set that allowlist before exposing Aurral through a proxy. `TRUST_PROXY` controls Express client-IP handling and does not replace the authentication allowlist. -A tab that is already open has no page load to redirect. When the proxy rejects one of its API calls, Aurral reloads that tab, which sends it back through the proxy. Aurral reloads at most once every 30 seconds, so a misconfigured proxy cannot cause a redirect loop. +Aurral resolves the trusted proxy identity on each request and applies the configured role rules: -When the proxy protects only page loads and leaves `/api` to Aurral's own session, the open page keeps working after the identity-provider session ends, until the Aurral session expires. +- `AUTH_PROXY_DEFAULT_ROLE` sets the default role. +- `AUTH_PROXY_ADMIN_USERS` promotes exact identity matches. +- `AUTH_PROXY_ROLE_HEADER` names the group header. +- `AUTH_PROXY_ADMIN_GROUPS` lists groups that grant the `admin` role. -For Authentik, route `/outpost.goauthentik.io` directly to the Authentik outpost. +Proxy authentication remains an Aurral adapter. It does not use Better Auth's email sign-in or create a Better Auth bearer session. Set `AUTH_PROXY_LOGOUT_URL` when **Log out** must also end the proxy or identity-provider session. -Do not protect that location with `auth_request`. Verify the route before you test Aurral: +For Authentik, route `/outpost.goauthentik.io` directly to the Authentik outpost. Do not protect that route with `auth_request`. Verify it with: ```bash curl -i https://aurral.example.com/outpost.goauthentik.io/ping @@ -84,26 +86,33 @@ curl -i https://aurral.example.com/outpost.goauthentik.io/ping The response must be `204`. -Set `AUTH_PROXY_LOGOUT_URL=https://aurral.example.com/outpost.goauthentik.io/sign_out` to end the Authentik session when you log out. +## LAN auto-login -Aurral hides its **Log out** control while proxy authentication is on and this variable is unset, because clearing an Aurral session cannot end the proxy session. Set the variable to get the control back. +Aurral can bypass the sign-in page for a single-admin installation when the request comes from a trusted local subnet. The setting is available during onboarding and under **Settings > System**. -Proxy-created users cannot use local password login until an admin sets a password. After creation, admins can manage the account from **Settings > Users**. +LAN auto-login is an Aurral request adapter. It does not create a Better Auth session. Disable it when untrusted clients can reach the local network or the Aurral port. -## Per-user Plex accounts +## Aurral authentication adapters -Each user can link their own flow and shared playlists to their own Plex account instead of the shared admin Plex connection. From a user's **Manage** dialog in **Settings > Users**, admins link a Plex Home managed user; anyone can also self-link their own invited/friend Plex account from their **Profile** page. See [Plex - Per-user Plex accounts](/integrations/plex/#per-user-plex-accounts) for the full setup steps. +Better Auth does not own these paths: -## Reset admin password +| Path or credential | Aurral behavior | +| --- | --- | +| `X-Api-Key` or `api_key` | The instance API key grants administrator access to the JSON API. It has no read-only scope and is not a Better Auth session. | +| `/rest/:method.view` | The Subsonic adapter handles the Subsonic account and token formats. Better Auth bearer tokens and the instance API key are not Subsonic credentials. | +| Media stream and artwork query tokens | Aurral issues short-lived media tokens so audio and image elements can load protected URLs. | +| `/ws?token=SESSION_TOKEN` | The WebSocket adapter resolves the Better Auth bearer session token from the query string. It also accepts trusted proxy identity and LAN auto-login. It does not accept the instance API key. | -From the repository root: +## Migrate an existing installation -```bash -npm run auth:reset-admin-password -- --password "new-password" -``` +The first start after the migration updates the existing `users` table, creates Better Auth credential accounts, and invalidates legacy sessions. New Better Auth sessions are created after users sign in again. -Generate a random password: +The migration keeps existing numeric user IDs, application permissions, profile settings, and rows that reference `users.id`. Existing password hashes are copied into Better Auth credential accounts, so users keep their passwords. Existing username data remains as compatibility data, but new sign-in uses an email address. Review accounts with identifiers that are not valid email addresses in **Settings > Users** after the upgrade. -```bash -npm run auth:reset-admin-password -- --generate -``` +Existing custom sessions are not portable to Better Auth. Users must sign in again after the migration. Do not delete or edit Better Auth tables by hand. + +Before upgrading, stop Aurral and back up the complete `/config` mount and the environment or secret store that contains `BETTER_AUTH_SECRET`. Keep the backup until every local account has signed in successfully. For rollback, stop the new version, restore the pre-migration `/config` backup and its matching environment, then start the previous version. Do not point the previous version at a database that has already been migrated. + +## Recover an account + +An administrator can set a user's password from **Settings > Users**. The user must then sign in with the account's email address and the new password. diff --git a/docs/src/content/docs/api/endpoints.mdx b/docs/src/content/docs/api/endpoints.mdx index b72d728b9..d14a2e1b9 100644 --- a/docs/src/content/docs/api/endpoints.mdx +++ b/docs/src/content/docs/api/endpoints.mdx @@ -25,13 +25,20 @@ can change Aurral, Lidarr, files, users, or external services. | `GET` | `/api/health` | Detailed application health | | `GET` | `/api/health/ws` | WebSocket connection statistics | | `POST` | `/api/health/stream-token` | Issue a short-lived token for media streams | -| `POST` | `/api/auth/login` | Create a password-login session | -| `POST` | `/api/auth/logout` | End a login session | -| `GET` | `/api/auth/me` | Current authenticated identity | -| `GET` | `/api/auth/api-key` | Read or create the instance API key | -| `POST` | `/api/auth/api-key/rotate` | Replace the instance API key | -| `GET` | `/api/auth/oidc/login` | Start an OpenID Connect login | -| `POST` | `/api/auth/oidc/exchange` | Exchange an OpenID Connect code for a session | +| `POST` | `/api/auth/sign-in/email` | Sign in with an email address and password | +| `GET` | `/api/auth/get-session` | Read the current Better Auth user and session | +| `POST` | `/api/auth/sign-out` | Revoke the current Better Auth session | +| `POST` | `/api/auth/change-password` | Change the current user's password and revoke other sessions | +| `POST` | `/api/auth/sign-in/social` | Start a Better Auth social or OIDC sign-in | +| `GET` | `/api/auth/callback/:provider` | Complete a Better Auth provider callback | +| `GET` | `/api/auth/admin/list-users` | List users for an administrator | +| `POST` | `/api/auth/admin/create-user` | Create a local user for an administrator | +| `POST` | `/api/auth/admin/update-user` | Update Better Auth user fields | +| `POST` | `/api/auth/admin/set-role` | Set a user's role | +| `POST` | `/api/auth/admin/set-user-password` | Set a user's password | +| `POST` | `/api/auth/admin/remove-user` | Delete a user | +| `GET` | `/api/aurral-auth/api-key` | Read or create the instance API key | +| `POST` | `/api/aurral-auth/api-key/rotate` | Replace the instance API key | The liveness, bootstrap, and health routes do not require authentication. Aurral returns detailed health fields only for a request with a valid @@ -53,10 +60,10 @@ Subscriptions use the `status`, `downloads`, `discovery`, `library`, `weekly-flow`, and `playlists` channels. The `library` channel sends a `library_scan_completed` message after Aurral refreshes its canonical library. -For local password authentication, connect with the session token as -`/ws?token=SESSION_TOKEN`. Aurral also accepts reverse-proxy identity and LAN -auto-login for WebSocket connections. Aurral does not currently accept the -instance API key for WebSocket connections. +For local or OIDC authentication, connect with the Better Auth bearer session +token as `/ws?token=SESSION_TOKEN`. Aurral also accepts trusted reverse-proxy +identity and LAN auto-login for WebSocket connections. Aurral does not accept +the instance API key for WebSocket connections. ## Incoming webhooks diff --git a/docs/src/content/docs/api/overview.mdx b/docs/src/content/docs/api/overview.mdx index 69560aa78..9ea1726b3 100644 --- a/docs/src/content/docs/api/overview.mdx +++ b/docs/src/content/docs/api/overview.mdx @@ -19,6 +19,30 @@ operate. ## Authentication +Better Auth owns local sign-in, sessions, and bearer tokens. Sign in with an +email address and password: + +```bash +curl --fail --include \ + --header "Content-Type: application/json" \ + --data '{"email":"admin@example.com","password":"YOUR_PASSWORD"}' \ + https://aurral.example.com/api/auth/sign-in/email +``` + +Read the `set-auth-token` response header and send the value as a bearer token +on later requests: + +```bash +curl --fail \ + --header "Authorization: Bearer YOUR_SESSION_TOKEN" \ + https://aurral.example.com/api/library/artists +``` + +Use `GET /api/auth/get-session` to check the current session. Use +`POST /api/auth/sign-out` to revoke it. Better Auth owns the session lifetime; +see [Users and authentication](/admin/users/) for the default and the +`SESSION_EXPIRY_HOURS` setting. + Open **Settings > System**. Copy the API key. Send the key in the `X-Api-Key` header: @@ -38,9 +62,9 @@ curl --fail \ Use the header. A browser can save URLs in its history. A reverse proxy can save URLs in its access logs. -The API key replaces a login session token. You do not need to call the login -endpoint first. The key currently gives administrator access. It does **not** -have read-only access. +The API key replaces a Better Auth session token. You do not need to call the +sign-in endpoint first. The key currently gives administrator access. It does +**not** have read-only access. Keep the key in a secret store. If your integration needs read access, make only `GET` requests. After you rotate the key, the old key does not operate. diff --git a/docs/src/content/docs/getting-started/docker.mdx b/docs/src/content/docs/getting-started/docker.mdx index 303af4ed7..af3a581b9 100644 --- a/docs/src/content/docs/getting-started/docker.mdx +++ b/docs/src/content/docs/getting-started/docker.mdx @@ -22,6 +22,8 @@ services: environment: - PUID=${PUID:-1000} - PGID=${PGID:-1000} + - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET in .env} + - BETTER_AUTH_URL=${BETTER_AUTH_URL:?Set BETTER_AUTH_URL in .env} volumes: - ${MEDIA_ROOT:-/srv/media}:/data - ./config:/config @@ -39,10 +41,12 @@ For example, the host folder `/srv/media/downloads/aurral` is `/data/downloads/a docker compose up -d ``` -1. Open `http://localhost:3001`. -2. Create the admin account. -3. Connect Lidarr. -4. Set the Downloads Folder path and run the storage checks. +1. Generate and save a persistent `BETTER_AUTH_SECRET`, for example with `openssl rand -hex 32`. +2. Set `BETTER_AUTH_URL` to the URL that users use to reach Aurral. +3. Open `http://localhost:3001`. +4. Create the admin account with a name, email address, and password. +5. Connect Lidarr. +6. Set the Downloads Folder path and run the storage checks. Continue with [First run](/getting-started/first-run/). diff --git a/docs/src/content/docs/getting-started/first-run.mdx b/docs/src/content/docs/getting-started/first-run.mdx index 6287e8523..6cd17d10a 100644 --- a/docs/src/content/docs/getting-started/first-run.mdx +++ b/docs/src/content/docs/getting-started/first-run.mdx @@ -17,7 +17,7 @@ Complete [Filesystem and mounts](/getting-started/storage/) first. The short ver ## First-run steps -1. Open Aurral and create your admin account. +1. Open Aurral and create your admin account with a name, email address, and password. 2. Open **Settings > Lidarr**. 3. Enter the Lidarr URL that Aurral can reach. In one Docker Compose network this is often `http://lidarr:8686`. 4. Enter the Lidarr API key and select **Test connection**. diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 640de4bf9..8c500f0ac 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -80,8 +80,10 @@ export const AuthProvider = ({ children }) => { if (token) { try { const me = await getMe(); - setUser(me.user || null); - setIsAuthenticated(!!me.user); + const sessionUser = me?.user || null; + setUser(sessionUser); + setIsAuthenticated(!!sessionUser); + if (!sessionUser) clearAuthStorage(); } catch { clearAuthStorage(); setUser(null); @@ -106,12 +108,12 @@ export const AuthProvider = ({ children }) => { checkAuthStatus(); }, [checkAuthStatus]); - const login = useCallback(async (password, username) => { - const normalizedUsername = String(username || "").trim(); - if (!normalizedUsername || !password) return false; + const login = useCallback(async (email, password) => { + const normalizedEmail = String(email || "").trim(); + if (!normalizedEmail || !password) return false; try { - const result = await loginApi(normalizedUsername, password); + const result = await loginApi(normalizedEmail, password); if (!result?.token) return false; authResolvedRef.current = true; clearLibraryFavoritesCache(); diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index fe6a7c5b6..2b3b63db5 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,11 +2,12 @@ import { useState } from "react"; import { useAuth } from "../contexts/AuthContext"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { getAppBasePath } from "../utils/basePath.js"; +import { startOidcLogin } from "../utils/api/endpoints/auth.js"; import { DotLoader } from "../components/DotLoader"; const Login = () => { useDocumentTitle("Sign in"); - const [username, setUsername] = useState(""); + const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -19,24 +20,31 @@ const Login = () => { if (submitting) return; setSubmitting(true); try { - const success = await login(password, username); + const success = await login(identifier, password); if (success) { setError(""); } else { - setError("Invalid username or password"); + setError("Invalid email, username, or password"); } } finally { setSubmitting(false); } }; - const handleOidcLogin = () => { + const handleOidcLogin = async () => { if (startingSso) return; setStartingSso(true); - const basePath = getAppBasePath(); - const prefix = basePath === "/" ? "" : basePath.replace(/\/$/, ""); - window.location.assign(`${prefix}/api/auth/oidc/login`); + setError(""); + try { + const basePath = getAppBasePath(); + const prefix = basePath === "/" ? "" : basePath.replace(/\/$/, ""); + const redirectUrl = await startOidcLogin(`${prefix}/sso/complete`); + window.location.assign(redirectUrl); + } catch { + setStartingSso(false); + setError("Unable to start SSO sign-in"); + } }; return ( @@ -64,21 +72,21 @@ const Login = () => { )} -
+
-