diff --git a/.tests/auth/google-auth.test.js b/.tests/auth/google-auth.test.js new file mode 100644 index 000000000..bd16b3ca3 --- /dev/null +++ b/.tests/auth/google-auth.test.js @@ -0,0 +1,301 @@ +import test 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, sessionModule, googleModule] = await setupIsolatedBackend( + "google-auth", + "backend/config/db-sqlite.js", + "backend/db/helpers/index.js", + "backend/config/session-helpers.js", + "backend/services/googleAuth.js", +); + +const { dbOps, userOps, userIdentityOps } = dbHelpers; +const { getSessionByToken } = sessionModule; +const { + startGoogleAuth, + handleGoogleCallback, + exchangeGoogleCallback, + isGoogleLoginEnabled, + resetGoogleStateForTests, + setGoogleIssuerForTests, +} = googleModule; + +const completeOnboarding = () => dbOps.updateSettings({ onboardingComplete: true }); + +const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const googleKey = { ...publicKey.export({ format: "jwk" }), kid: "google-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: googleKey.kid, typ: "JWT" }); + const payload = encode({ + iss: issuer, + aud: "google-client-id", + sub: "google-subject", + email: "person@example.com", + 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 enableGoogleConfig(issuer) { + dbOps.updateSettings({ + integrations: { + google: { + enabled: true, + clientId: "google-client-id", + clientSecret: "google-client-secret", + redirectUri: `${issuer}callback`, + }, + }, + }); +} + +async function createPendingGoogleAuth(mode, claimOverrides = {}) { + let issuer; + let nonce; + const discoveryServer = await createMockHttpServer((request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + if (request.url === "/jwks") { + response.end(JSON.stringify({ keys: [googleKey] })); + return; + } + if (request.method === "POST" && request.url === "/token") { + response.end( + JSON.stringify({ + access_token: "access-token", + token_type: "Bearer", + id_token: createIdToken(issuer, nonce, claimOverrides), + }), + ); + return; + } + response.end( + JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}authorize`, + token_endpoint: `${issuer}token`, + jwks_uri: `${issuer}jwks`, + }), + ); + }); + issuer = `${discoveryServer.url}/`; + setGoogleIssuerForTests(issuer); + enableGoogleConfig(issuer); + + const response = { + headers: {}, + redirect(_status, location) { + this.location = location; + }, + setHeader(name, value) { + this.headers[name] = value; + }, + }; + await startGoogleAuth({ headers: {} }, response, mode); + const redirect = new URL(response.location); + const state = redirect.searchParams.get("state"); + nonce = redirect.searchParams.get("nonce"); + const cookie = response.headers["Set-Cookie"].split(";", 1)[0]; + return { state, cookie, close: discoveryServer.close }; +} + +async function completeGoogleAuth(pending) { + const callback = await handleGoogleCallback({ + query: { state: pending.state, code: "authorization-code" }, + headers: { cookie: pending.cookie }, + ip: "127.0.0.1", + }); + return exchangeGoogleCallback(callback.code, { + headers: { cookie: pending.cookie, "user-agent": "test-agent" }, + ip: "127.0.0.1", + }); +} + +test.beforeEach(() => { + resetDatabase(db); + resetGoogleStateForTests(); + dbOps.updateSettings({ onboardingComplete: false }); +}); + +test.after(async () => { + resetGoogleStateForTests(); + await cleanupIsolatedState(isolatedState); +}); + +test("Google login is disabled until enabled, clientId, clientSecret and redirectUri are all set", () => { + assert.equal(isGoogleLoginEnabled(), false); + dbOps.updateSettings({ + integrations: { google: { enabled: true, clientId: "id", clientSecret: "secret" } }, + }); + assert.equal(isGoogleLoginEnabled(), false, "missing redirectUri must keep it disabled"); + dbOps.updateSettings({ + integrations: { + google: { + enabled: true, + clientId: "id", + clientSecret: "secret", + redirectUri: "https://aurral.example.com/sso/google/callback", + }, + }, + }); + assert.equal(isGoogleLoginEnabled(), true); +}); + +test("logging in with an unrecognized Google identity is rejected and never provisions an account", async () => { + completeOnboarding(); + const pending = await createPendingGoogleAuth({ mode: "login" }); + try { + await assert.rejects(() => completeGoogleAuth(pending), { + status: 403, + }); + assert.equal(userOps.getAllUsers().length, 0); + assert.equal(userIdentityOps.findByProvider("google", "google", "google-subject"), null); + } finally { + await pending.close(); + } +}); + +test("linking attaches the identity to the authenticated user, and logging in afterward resolves it without touching role", async () => { + completeOnboarding(); + const user = userOps.createUser("gordon", "unused-hash", "user"); + + const linkPending = await createPendingGoogleAuth({ mode: "link", linkUserId: user.id }); + try { + const linkResult = await completeGoogleAuth(linkPending); + assert.equal(linkResult.linked, true); + assert.equal(linkResult.user.id, user.id); + } finally { + await linkPending.close(); + } + + const identity = userIdentityOps.findByProvider("google", "google", "google-subject"); + assert.equal(identity.userId, user.id); + + const loginPending = await createPendingGoogleAuth({ mode: "login" }); + try { + const loginResult = await completeGoogleAuth(loginPending); + assert.equal(loginResult.linked, false); + assert.ok(loginResult.token); + const sessionUser = getSessionByToken(loginResult.token)?.user; + assert.equal(sessionUser?.id, user.id); + assert.equal(sessionUser?.role, "user", "Google must never grant or change role"); + } finally { + await loginPending.close(); + } +}); + +test("linking a Google identity already claimed by another user is rejected with 409", async () => { + completeOnboarding(); + const userA = userOps.createUser("user-a", "unused-hash", "user"); + const userB = userOps.createUser("user-b", "unused-hash", "user"); + + const firstLink = await createPendingGoogleAuth({ mode: "link", linkUserId: userA.id }); + try { + await completeGoogleAuth(firstLink); + } finally { + await firstLink.close(); + } + + const secondLink = await createPendingGoogleAuth({ mode: "link", linkUserId: userB.id }); + try { + await assert.rejects( + () => + handleGoogleCallback({ + query: { state: secondLink.state, code: "authorization-code" }, + headers: { cookie: secondLink.cookie }, + ip: "127.0.0.1", + }), + { status: 409 }, + ); + } finally { + await secondLink.close(); + } + + const identity = userIdentityOps.findByProvider("google", "google", "google-subject"); + assert.equal(identity.userId, userA.id, "the conflicting link attempt must not steal the identity"); +}); + +test("a suspended user cannot log in via a linked Google identity", async () => { + completeOnboarding(); + const user = userOps.createUser("suspended-google-user", "unused-hash", "user"); + userIdentityOps.link(user.id, { + providerType: "google", + providerKey: "google", + subject: "google-subject", + }); + userOps.updateUser(user.id, { status: "suspended" }); + + const pending = await createPendingGoogleAuth({ mode: "login" }); + try { + await assert.rejects(() => completeGoogleAuth(pending), { + status: 403, + message: "This account has been suspended or disabled", + }); + } finally { + await pending.close(); + } +}); + +test("a suspended user cannot complete a Google link", async () => { + completeOnboarding(); + const user = userOps.createUser("suspended-linker", "unused-hash", "user"); + userOps.updateUser(user.id, { status: "suspended" }); + + const pending = await createPendingGoogleAuth({ mode: "link", linkUserId: user.id }); + try { + await assert.rejects( + () => + handleGoogleCallback({ + query: { state: pending.state, code: "authorization-code" }, + headers: { cookie: pending.cookie }, + ip: "127.0.0.1", + }), + { status: 403, message: "This account has been suspended or disabled" }, + ); + } finally { + await pending.close(); + } + assert.equal(userIdentityOps.findByProvider("google", "google", "google-subject"), null); +}); + +test("Google exchange never leaks passwordHash, for linking or login", async () => { + completeOnboarding(); + const user = userOps.createUser("gordon-sanitize", "unused-hash", "user"); + + const linkPending = await createPendingGoogleAuth({ mode: "link", linkUserId: user.id }); + try { + const linkResult = await completeGoogleAuth(linkPending); + assert.equal(linkResult.user.passwordHash, undefined); + } finally { + await linkPending.close(); + } + + const loginPending = await createPendingGoogleAuth({ mode: "login" }); + try { + const loginResult = await completeGoogleAuth(loginPending); + assert.equal(loginResult.user.passwordHash, undefined); + } finally { + await loginPending.close(); + } +}); + +test("exchangeGoogleCallback rejects a code that was never issued", () => { + assert.throws(() => exchangeGoogleCallback("bogus-code", { headers: {} }), { + status: 400, + message: "Google login session expired", + }); +}); diff --git a/.tests/auth/oidc-auth.test.js b/.tests/auth/oidc-auth.test.js index 197ce8660..f01381e9d 100644 --- a/.tests/auth/oidc-auth.test.js +++ b/.tests/auth/oidc-auth.test.js @@ -19,7 +19,7 @@ const [isolatedState, { db }, dbHelpers, authModule, sessionModule, oidcModule] "backend/services/oidcAuth.js", ); -const { dbOps, userOps } = dbHelpers; +const { dbOps, userOps, userIdentityOps } = dbHelpers; const { ensureExternalUser, isAuthRequiredByConfig, isOidcAuthEnabled } = authModule; const { createSession, getSessionByToken } = sessionModule; const { @@ -38,7 +38,7 @@ 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) => { +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({ @@ -49,6 +49,7 @@ const createIdToken = (issuer, nonce) => { 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"); @@ -68,6 +69,7 @@ function resetOidcEnv() { delete process.env.OIDC_GROUPS_CLAIM; delete process.env.OIDC_ADMIN_GROUPS; delete process.env.OIDC_LOGOUT_URL; + delete process.env.OIDC_TOKEN_ENDPOINT_AUTH_METHOD; delete process.env.AUTH_PROXY_ENABLED; delete process.env.AUTH_PROXY_HEADER; resetOidcStateForTests(); @@ -82,9 +84,11 @@ function enableOidcEnv(overrides = {}) { Object.assign(process.env, overrides); } -async function createPendingOidcLogin() { +async function createPendingOidcLogin(options = {}) { let issuer; let nonce; + const claimOverrides = options.claimOverrides || {}; + const capturedTokenRequest = {}; const discoveryServer = await createMockHttpServer((request, response) => { response.writeHead(200, { "content-type": "application/json" }); if (request.url === "/jwks") { @@ -92,13 +96,21 @@ async function createPendingOidcLogin() { return; } if (request.method === "POST" && request.url === "/token") { - response.end( - JSON.stringify({ - access_token: "access-token", - token_type: "Bearer", - id_token: createIdToken(issuer, nonce), - }), - ); + let body = ""; + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + capturedTokenRequest.authorizationHeader = request.headers.authorization || null; + capturedTokenRequest.body = body; + response.end( + JSON.stringify({ + access_token: "access-token", + token_type: "Bearer", + id_token: createIdToken(issuer, nonce, claimOverrides), + }), + ); + }); return; } response.end( @@ -114,6 +126,7 @@ async function createPendingOidcLogin() { enableOidcEnv({ OIDC_ISSUER: issuer, OIDC_REDIRECT_URI: `${issuer}callback`, + ...(options.envOverrides || {}), }); const response = { @@ -137,9 +150,22 @@ async function createPendingOidcLogin() { nonce, cookie: setCookie.split(";", 1)[0], close: discoveryServer.close, + capturedTokenRequest, }; } +async function completeOidcLogin(pending) { + const callback = await handleOidcCallback({ + query: { state: pending.state, code: "authorization-code" }, + headers: { cookie: pending.cookie }, + ip: "127.0.0.1", + }); + return exchangeOidcCallback(callback.code, { + headers: { cookie: pending.cookie, "user-agent": "test-agent" }, + ip: "127.0.0.1", + }); +} + test.beforeEach(() => { resetDatabase(db); resetOidcEnv(); @@ -259,6 +285,40 @@ test("OIDC callback issues a cookie-bound one-time session exchange", async () = } }); +test("OIDC exchange never leaks passwordHash, for new or returning users", async () => { + const first = await createPendingOidcLogin(); + try { + const callback = await handleOidcCallback({ + query: { state: first.state, code: "authorization-code" }, + headers: { cookie: first.cookie }, + ip: "127.0.0.1", + }); + const session = exchangeOidcCallback(callback.code, { + headers: { cookie: first.cookie, "user-agent": "test-agent" }, + ip: "127.0.0.1", + }); + assert.equal(session.user.passwordHash, undefined, "newly provisioned user must be sanitized"); + } finally { + await first.close(); + } + + const second = await createPendingOidcLogin(); + try { + const callback = await handleOidcCallback({ + query: { state: second.state, code: "authorization-code" }, + headers: { cookie: second.cookie }, + ip: "127.0.0.1", + }); + const session = exchangeOidcCallback(callback.code, { + headers: { cookie: second.cookie, "user-agent": "test-agent" }, + ip: "127.0.0.1", + }); + assert.equal(session.user.passwordHash, undefined, "returning user must also be sanitized"); + } finally { + await second.close(); + } +}); + test("OIDC callback rejects an expired state without creating a session", async () => { const pending = await createPendingOidcLogin(); const now = Date.now(); @@ -299,3 +359,441 @@ test("OIDC callback rejects a mismatched state without creating a session", asyn await pending.close(); } }); + +test("OIDC login falls back to the UserInfo endpoint when the ID token omits profile claims", async () => { + 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, { preferred_username: undefined, email: undefined }), + }), + ); + return; + } + if (request.url === "/userinfo") { + assert.equal(request.headers.authorization, "Bearer access-token"); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ sub: "oidc-subject", preferred_username: "authelia-user" })); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}authorize`, + token_endpoint: `${issuer}token`, + userinfo_endpoint: `${issuer}userinfo`, + jwks_uri: `${issuer}jwks`, + }), + ); + }); + + try { + 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); + nonce = redirect.searchParams.get("nonce"); + const state = redirect.searchParams.get("state"); + const cookie = response.headers["Set-Cookie"].split(";", 1)[0]; + const callback = await handleOidcCallback({ + query: { state, code: "authorization-code" }, + headers: { cookie }, + ip: "127.0.0.1", + }); + const session = exchangeOidcCallback(callback.code, { + headers: { cookie, "user-agent": "test-agent" }, + ip: "127.0.0.1", + }); + const loggedInUser = getSessionByToken(session.token)?.user; + assert.equal( + loggedInUser?.username, + "authelia-user", + "must resolve the username from UserInfo when the ID token carries none", + ); + } finally { + await discoveryServer.close(); + } +}); + +test("OIDC login resolves returning users by issuer+subject, not by username claim", async () => { + let issuer; + let nonce; + let claimOverrides = {}; + const discoveryServer = await createMockHttpServer((request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + if (request.url === "/jwks") { + response.end(JSON.stringify({ keys: [oidcKey] })); + return; + } + if (request.method === "POST" && request.url === "/token") { + response.end( + JSON.stringify({ + access_token: "access-token", + token_type: "Bearer", + id_token: createIdToken(issuer, nonce, claimOverrides), + }), + ); + return; + } + response.end( + JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}authorize`, + token_endpoint: `${issuer}token`, + jwks_uri: `${issuer}jwks`, + }), + ); + }); + + try { + issuer = `${discoveryServer.url}/`; + enableOidcEnv({ OIDC_ISSUER: issuer, OIDC_REDIRECT_URI: `${issuer}callback` }); + + const login = async () => { + 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); + nonce = redirect.searchParams.get("nonce"); + const state = redirect.searchParams.get("state"); + const cookie = response.headers["Set-Cookie"].split(";", 1)[0]; + const callback = await handleOidcCallback({ + query: { state, code: "authorization-code" }, + headers: { cookie }, + ip: "127.0.0.1", + }); + const session = exchangeOidcCallback(callback.code, { + headers: { cookie, "user-agent": "test-agent" }, + ip: "127.0.0.1", + }); + return getSessionByToken(session.token)?.user; + }; + + const firstUser = await login(); + assert.ok(firstUser?.id); + assert.equal(userOps.getAllUsers().length, 1); + + claimOverrides = { preferred_username: "renamed-user" }; + const secondUser = await login(); + assert.equal(secondUser?.id, firstUser.id, "same subject must resolve to the same user"); + assert.equal( + secondUser?.username, + "callback-user", + "username claim changing after first login must not rename or re-provision the user", + ); + assert.equal(userOps.getAllUsers().length, 1, "returning login must not create a second user"); + } finally { + await discoveryServer.close(); + } +}); + +test("OIDC provisioning auto-suffixes a colliding username instead of linking to the existing account", async () => { + const first = await createPendingOidcLogin({ claimOverrides: { sub: "subject-one" } }); + let firstUserId; + try { + const session = await completeOidcLogin(first); + firstUserId = getSessionByToken(session.token)?.user?.id; + } finally { + await first.close(); + } + + const second = await createPendingOidcLogin({ claimOverrides: { sub: "subject-two" } }); + try { + const session = await completeOidcLogin(second); + const loggedInUser = getSessionByToken(session.token)?.user; + assert.notEqual( + loggedInUser?.id, + firstUserId, + "a different subject with a colliding username must never log in as the existing account", + ); + assert.equal(loggedInUser?.username, "callback-user-2"); + } finally { + await second.close(); + } + + assert.equal(userOps.getAllUsers().length, 2); + assert.equal(userOps.getUserByUsername("callback-user")?.id, firstUserId); +}); + +test("OIDC login adopts a legacy account only once an admin has approved that specific account", async () => { + completeOnboarding(); + const legacy = userOps.createUser("callback-user", "random-unknown-hash", "user", null, false); + userOps.updateUser(legacy.id, { + needsIdentityMigration: true, + allowIdentityAdoption: true, + }); + + const pending = await createPendingOidcLogin(); + try { + const session = await completeOidcLogin(pending); + const loggedInUser = getSessionByToken(session.token)?.user; + assert.equal(loggedInUser?.id, legacy.id, "must adopt the legacy account, not provision a new one"); + assert.equal(loggedInUser?.username, "callback-user"); + } finally { + await pending.close(); + } + + assert.equal(userOps.getAllUsers().length, 1, "no duplicate account should be created"); + const identities = userIdentityOps.getForUser(legacy.id); + assert.equal(identities.length, 1); + assert.equal(identities[0].providerType, "oidc"); + const adopted = userOps.getUserById(legacy.id); + assert.equal(adopted.needsIdentityMigration, false); + assert.equal(adopted.roleSource, "oidc"); + assert.equal( + adopted.allowIdentityAdoption, + false, + "approval must be consumed so it authorizes exactly one adoption", + ); +}); + +test("OIDC login never adopts a legacy account the admin has not approved, even though the migration leaves has_local_password unset", async () => { + completeOnboarding(); + const legacyLocalAccount = userOps.createUser( + "callback-user", + "real-local-password-hash", + "user", + null, + false, + ); + userOps.updateUser(legacyLocalAccount.id, { needsIdentityMigration: true }); + + const pending = await createPendingOidcLogin(); + try { + const session = await completeOidcLogin(pending); + const loggedInUser = getSessionByToken(session.token)?.user; + assert.notEqual( + loggedInUser?.id, + legacyLocalAccount.id, + "a username match alone must never hand an OIDC identity someone else's account", + ); + assert.equal(loggedInUser?.username, "callback-user-2"); + } finally { + await pending.close(); + } + + assert.equal(userOps.getAllUsers().length, 2); + assert.equal(userIdentityOps.getForUser(legacyLocalAccount.id).length, 0); +}); + +test("OIDC login does not adopt an approved legacy account that is already linked to another identity", async () => { + completeOnboarding(); + const legacy = userOps.createUser("callback-user", "random-unknown-hash", "user", null, false); + userOps.updateUser(legacy.id, { + needsIdentityMigration: true, + allowIdentityAdoption: true, + }); + userIdentityOps.link(legacy.id, { + providerType: "oidc", + providerKey: "https://already-linked.example/", + subject: "some-other-subject", + }); + + const pending = await createPendingOidcLogin(); + try { + const session = await completeOidcLogin(pending); + const loggedInUser = getSessionByToken(session.token)?.user; + assert.notEqual(loggedInUser?.id, legacy.id); + assert.equal(loggedInUser?.username, "callback-user-2"); + } finally { + await pending.close(); + } +}); + +test("OIDC login does not adopt the protected bootstrap admin", async () => { + completeOnboarding(); + const legacy = userOps.createUser("callback-user", "random-unknown-hash", "admin", null, false); + userOps.updateUser(legacy.id, { + needsIdentityMigration: true, + allowIdentityAdoption: true, + }); + userOps.setProtected(legacy.id, true); + + const pending = await createPendingOidcLogin(); + try { + const session = await completeOidcLogin(pending); + const loggedInUser = getSessionByToken(session.token)?.user; + assert.notEqual(loggedInUser?.id, legacy.id); + } finally { + await pending.close(); + } +}); + +test("OIDC login rejects a suspended user and never overwrites a protected account's role", async () => { + let issuer; + let nonce; + const discoveryServer = await createMockHttpServer((request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + if (request.url === "/jwks") { + response.end(JSON.stringify({ keys: [oidcKey] })); + return; + } + if (request.method === "POST" && request.url === "/token") { + response.end( + JSON.stringify({ + access_token: "access-token", + token_type: "Bearer", + id_token: createIdToken(issuer, nonce), + }), + ); + return; + } + response.end( + JSON.stringify({ + issuer, + authorization_endpoint: `${issuer}authorize`, + token_endpoint: `${issuer}token`, + jwks_uri: `${issuer}jwks`, + }), + ); + }); + + try { + issuer = `${discoveryServer.url}/`; + enableOidcEnv({ OIDC_ISSUER: issuer, OIDC_REDIRECT_URI: `${issuer}callback` }); + + const login = async () => { + 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); + nonce = redirect.searchParams.get("nonce"); + const state = redirect.searchParams.get("state"); + const cookie = response.headers["Set-Cookie"].split(";", 1)[0]; + const callback = await handleOidcCallback({ + query: { state, code: "authorization-code" }, + headers: { cookie }, + ip: "127.0.0.1", + }); + const session = exchangeOidcCallback(callback.code, { + headers: { cookie, "user-agent": "test-agent" }, + ip: "127.0.0.1", + }); + return getSessionByToken(session.token)?.user; + }; + + const user = await login(); + assert.equal(user?.role, "user"); + assert.equal(userOps.getUserById(user.id)?.roleSource, "oidc"); + + process.env.OIDC_ADMIN_USERS = "callback-user"; + userOps.setProtected(user.id, true); + const loggedInAgain = await login(); + assert.equal( + loggedInAgain?.role, + "user", + "OIDC must never promote or otherwise change a protected account's role", + ); + delete process.env.OIDC_ADMIN_USERS; + + userOps.updateUser(user.id, { status: "suspended" }); + await assert.rejects(() => login(), { + status: 403, + message: "This account has been suspended or disabled", + }); + } finally { + await discoveryServer.close(); + } +}); + +test("OIDC token exchange defaults to client_secret_basic and honors OIDC_TOKEN_ENDPOINT_AUTH_METHOD", async () => { + const basicPending = await createPendingOidcLogin(); + try { + await completeOidcLogin(basicPending); + assert.ok( + basicPending.capturedTokenRequest.authorizationHeader?.startsWith("Basic "), + "default token endpoint auth method must be client_secret_basic", + ); + assert.ok( + !String(basicPending.capturedTokenRequest.body || "").includes("client_secret="), + "client_secret_basic must not put the secret in the request body", + ); + } finally { + await basicPending.close(); + } + + const postPending = await createPendingOidcLogin({ + claimOverrides: { sub: "subject-post" }, + envOverrides: { OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_post" }, + }); + try { + await completeOidcLogin(postPending); + assert.ok( + String(postPending.capturedTokenRequest.body || "").includes("client_secret="), + "client_secret_post must include the secret in the request body", + ); + } finally { + await postPending.close(); + } +}); + +test("OIDC token exchange supports the none auth method with an empty client secret", async () => { + const nonePending = await createPendingOidcLogin({ + claimOverrides: { sub: "subject-none" }, + envOverrides: { OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "none", OIDC_CLIENT_SECRET: "" }, + }); + try { + assert.equal(isOidcEnabled(), true, "OIDC must stay enabled with an empty secret for none"); + const session = await completeOidcLogin(nonePending); + assert.ok(session.token); + assert.equal(nonePending.capturedTokenRequest.authorizationHeader, null); + const tokenRequestParams = new URLSearchParams(nonePending.capturedTokenRequest.body || ""); + assert.equal(tokenRequestParams.get("client_secret"), null); + assert.equal( + tokenRequestParams.get("client_id"), + "aurral", + "a public client must still identify itself with client_id in the request body", + ); + } finally { + await nonePending.close(); + } +}); + +test("OIDC rejects an unrecognized token endpoint auth method instead of silently defaulting", async () => { + enableOidcEnv({ OIDC_TOKEN_ENDPOINT_AUTH_METHOD: "client_secert_basic" }); + const response = { + headers: {}, + redirect() {}, + setHeader() {}, + status() { + return this; + }, + json() {}, + }; + await assert.rejects(() => startOidcLogin({}, response), /Unsupported OIDC_TOKEN_ENDPOINT_AUTH_METHOD/); +}); diff --git a/.tests/auth/proxy-auth.test.js b/.tests/auth/proxy-auth.test.js index e6ab3e6bd..292d3ee22 100644 --- a/.tests/auth/proxy-auth.test.js +++ b/.tests/auth/proxy-auth.test.js @@ -182,3 +182,37 @@ test("proxy auth re-syncs role on every request instead of only at creation", () assert.equal(demoted.role, "user"); assert.equal(userOps.getUserByUsername("dave")?.role, "user"); }); + +test("proxy auth resolves no user for a suspended or disabled identity", () => { + const created = resolveProxyUser(proxyRequest({ "x-forwarded-user": "gina" })); + assert.ok(created); + + userOps.updateUser(created.id, { status: "suspended" }); + assert.equal(resolveProxyUser(proxyRequest({ "x-forwarded-user": "gina" })), null); + + userOps.updateUser(created.id, { status: "disabled" }); + assert.equal(resolveProxyUser(proxyRequest({ "x-forwarded-user": "gina" })), null); +}); + +test("an existing session is invalidated once its user is suspended", () => { + completeOnboarding(); + const issued = issueProxySession(proxyRequest({ "x-forwarded-user": "hank" })); + assert.ok(issued?.token); + assert.equal(getSessionByToken(issued.token)?.user?.username, "hank"); + + const user = userOps.getUserByUsername("hank"); + userOps.updateUser(user.id, { status: "suspended" }); + + assert.equal(getSessionByToken(issued.token), null); +}); + +test("proxy auth never overwrites a protected account's role", () => { + const created = resolveProxyUser(proxyRequest({ "x-forwarded-user": "admin" })); + assert.equal(created.role, "user"); + userOps.setProtected(created.id, true); + + process.env.AUTH_PROXY_ADMIN_USERS = "admin"; + const resolved = resolveProxyUser(proxyRequest({ "x-forwarded-user": "admin" })); + assert.equal(resolved.role, "user", "protected account role must not change via proxy auth"); + assert.equal(userOps.getUserByUsername("admin")?.role, "user"); +}); diff --git a/.tests/helpers/backendTestHarness.js b/.tests/helpers/backendTestHarness.js index cf2ba8e06..841df8c9d 100644 --- a/.tests/helpers/backendTestHarness.js +++ b/.tests/helpers/backendTestHarness.js @@ -10,6 +10,7 @@ const repoRoot = join(__dirname, "..", ".."); const RESET_TABLES = [ "sessions", + "user_identities", "honker_task_runs", "slskd_transfer_history", "playlist_download_jobs", diff --git a/.tests/migration/identity-migration-reconciliation.test.js b/.tests/migration/identity-migration-reconciliation.test.js new file mode 100644 index 000000000..2a60be2e6 --- /dev/null +++ b/.tests/migration/identity-migration-reconciliation.test.js @@ -0,0 +1,91 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { pathToFileURL } from "url"; +import { join } from "path"; + +import { + createIsolatedStateDir, + applyIsolatedBackendEnv, + cleanupIsolatedState, +} from "../helpers/backendTestHarness.js"; + +const dbModuleUrl = pathToFileURL( + join(process.cwd(), "backend/config/db-sqlite.js"), +).href; + +async function bootDb() { + return import(`${dbModuleUrl}?boot=${Date.now()}-${Math.random()}`); +} + +test("reboot clears needs_identity_migration for a user whose identity was already linked before the flag existed", async () => { + const paths = await createIsolatedStateDir("identity-migration-reconciliation"); + applyIsolatedBackendEnv(paths); + + const { db: firstBootDb } = await bootDb(); + const insertUser = firstBootDb.prepare( + "INSERT INTO users (username, password_hash, role, is_protected, role_source, has_local_password) VALUES (?, ?, ?, 0, 'oidc', 0)", + ); + const result = insertUser.run("gordon.may", "system-provisioned", "admin"); + const userId = result.lastInsertRowid; + + firstBootDb.prepare( + "INSERT INTO user_identities (user_id, provider_type, provider_key, subject, linked_at) VALUES (?, 'oidc', 'https://idp.example/', 'subject-1', ?)", + ).run(userId, Date.now()); + + firstBootDb.prepare( + "UPDATE users SET needs_identity_migration = 1, allow_identity_adoption = 1 WHERE id = ?", + ).run(userId); + firstBootDb.close(); + + const { db: secondBootDb } = await bootDb(); + const row = secondBootDb + .prepare( + "SELECT needs_identity_migration, allow_identity_adoption FROM users WHERE id = ?", + ) + .get(userId); + + assert.equal( + row.needs_identity_migration, + 0, + "an account that already has a linked identity must not stay flagged as needing SSO adoption", + ); + assert.equal( + row.allow_identity_adoption, + 0, + "adoption approval must be cleared once an identity is already linked, so it can't be reused unexpectedly", + ); + secondBootDb.close(); + + await cleanupIsolatedState(paths); +}); + +test("reboot leaves needs_identity_migration set for a legacy account with no linked identity yet", async () => { + const paths = await createIsolatedStateDir("identity-migration-reconciliation-legacy"); + applyIsolatedBackendEnv(paths); + + const { db: firstBootDb } = await bootDb(); + const insertUser = firstBootDb.prepare( + "INSERT INTO users (username, password_hash, role, is_protected, role_source, has_local_password) VALUES (?, ?, ?, 0, 'local', 0)", + ); + const result = insertUser.run("jody.may", "some-hash", "user"); + const userId = result.lastInsertRowid; + + firstBootDb.prepare( + "UPDATE users SET needs_identity_migration = 1 WHERE id = ?", + ).run(userId); + firstBootDb.close(); + + const { db: secondBootDb } = await bootDb(); + const row = secondBootDb + .prepare("SELECT needs_identity_migration FROM users WHERE id = ?") + .get(userId); + + assert.equal( + row.needs_identity_migration, + 1, + "an account with no linked identity yet must stay flagged so it can still be claimed", + ); + secondBootDb.close(); + + await cleanupIsolatedState(paths); +}); diff --git a/.tests/users/identity-link-routes.int.test.js b/.tests/users/identity-link-routes.int.test.js new file mode 100644 index 000000000..886bcd6ca --- /dev/null +++ b/.tests/users/identity-link-routes.int.test.js @@ -0,0 +1,233 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import bcrypt from "bcrypt"; + +import { + setupIsolatedBackend, + cleanupIsolatedState, + resetDatabase, + startServerProcess, +} from "../helpers/backendTestHarness.js"; + +const [isolatedState, { db }, { userOps, userIdentityOps, dbOps }, { createSession }] = + await setupIsolatedBackend( + "identity-link-routes", + "backend/config/db-sqlite.js", + "backend/db/helpers/index.js", + "backend/config/session-helpers.js", + ); + +let server = null; + +async function login(username, password) { + const response = await fetch(`http://127.0.0.1:${server.port}/api/auth/login`, { + 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; +} + +async function apiFetch(token, path, options = {}) { + const headers = { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(options.headers || {}), + }; + const response = await fetch(`http://127.0.0.1:${server.port}${path}`, { + ...options, + headers, + }); + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + return { response, payload }; +} + +function ageSessionByToken(token, ageMs) { + db.prepare("UPDATE sessions SET reauthenticated_at = ? WHERE token = ?").run( + Date.now() - ageMs, + token, + ); +} + +test.before(async () => { + resetDatabase(db); + dbOps.updateSettings({ integrations: {}, onboardingComplete: true }); + server = await startServerProcess(); +}); + +test.after(async () => { + await server?.stop(); + await cleanupIsolatedState(isolatedState); +}); + +test.beforeEach(() => { + resetDatabase(db); + dbOps.updateSettings({ integrations: {}, onboardingComplete: true }); +}); + +test("GET /me/identities requires authentication", async () => { + const { response } = await apiFetch(null, "/api/users/me/identities"); + assert.equal(response.status, 401); +}); + +test("GET /me/identities lists only the caller's own linked identities", async () => { + const userA = userOps.createUser("identity-user-a", bcrypt.hashSync("password123", 4), "user"); + const userB = userOps.createUser("identity-user-b", bcrypt.hashSync("password123", 4), "user"); + userIdentityOps.link(userA.id, { + providerType: "oidc", + providerKey: "https://issuer.example/", + subject: "sub-a", + displayName: "a@example.com", + }); + + const tokenA = await login("identity-user-a", "password123"); + const tokenB = await login("identity-user-b", "password123"); + + const { response: resA, payload: payloadA } = await apiFetch(tokenA, "/api/users/me/identities"); + assert.equal(resA.status, 200); + assert.equal(payloadA.hasLocalPassword, true); + assert.equal(payloadA.identities.length, 1); + assert.equal(payloadA.identities[0].providerType, "oidc"); + + const { payload: payloadB } = await apiFetch(tokenB, "/api/users/me/identities"); + assert.equal(payloadB.identities.length, 0); +}); + +test("reauth accepts the correct password and rejects an incorrect one", async () => { + userOps.createUser("reauth-user", bcrypt.hashSync("password123", 4), "user"); + const token = await login("reauth-user", "password123"); + + const { response: badResponse } = await apiFetch(token, "/api/auth/reauth", { + method: "POST", + body: JSON.stringify({ currentPassword: "wrong-password" }), + }); + assert.equal(badResponse.status, 400); + + const { response: goodResponse, payload } = await apiFetch(token, "/api/auth/reauth", { + method: "POST", + body: JSON.stringify({ currentPassword: "password123" }), + }); + assert.equal(goodResponse.status, 200); + assert.equal(payload.success, true); +}); + +test("unlinking an identity requires a recent reauth and succeeds after confirming it", async () => { + const user = userOps.createUser( + "stale-session-user", + bcrypt.hashSync("password123", 4), + "user", + ); + const identity = userIdentityOps.link(user.id, { + providerType: "oidc", + providerKey: "https://issuer.example/", + subject: "sub-stale", + }); + const token = await login("stale-session-user", "password123"); + + ageSessionByToken(token, 20 * 60 * 1000); + const { response: staleResponse } = await apiFetch( + token, + `/api/users/me/identities/${identity.id}`, + { method: "DELETE" }, + ); + assert.equal(staleResponse.status, 401); + + await apiFetch(token, "/api/auth/reauth", { + method: "POST", + body: JSON.stringify({ currentPassword: "password123" }), + }); + + const { response: freshResponse, payload } = await apiFetch( + token, + `/api/users/me/identities/${identity.id}`, + { method: "DELETE" }, + ); + assert.equal(freshResponse.status, 200); + assert.equal(payload.success, true); + assert.equal(userIdentityOps.countForUser(user.id), 0); +}); + +test("unlinking a nonexistent or another user's identity 404s", async () => { + const userA = userOps.createUser("owner-user", bcrypt.hashSync("password123", 4), "user"); + const userB = userOps.createUser("other-user", bcrypt.hashSync("password123", 4), "user"); + const identity = userIdentityOps.link(userA.id, { + providerType: "oidc", + providerKey: "https://issuer.example/", + subject: "sub-owner", + }); + const tokenB = await login("other-user", "password123"); + + const { response } = await apiFetch(tokenB, `/api/users/me/identities/${identity.id}`, { + method: "DELETE", + }); + assert.equal(response.status, 404); + assert.equal(userIdentityOps.countForUser(userA.id), 1); +}); + +test("a successful local login records that the account has a usable local password", async () => { + const legacy = userOps.createUser( + "legacy-local-user", + bcrypt.hashSync("password123", 4), + "user", + null, + false, + ); + assert.equal(userOps.getUserById(legacy.id).hasLocalPassword, false); + + await login("legacy-local-user", "password123"); + + assert.equal( + userOps.getUserById(legacy.id).hasLocalPassword, + true, + "logging in with a password proves one exists, which re-arms lockout and password-change checks", + ); +}); + +test("changing a password requires a recent auth, so a stale session cannot take the account over", async () => { + const user = userOps.createUser( + "stale-password-user", + bcrypt.hashSync("password123", 4), + "user", + ); + const token = await login("stale-password-user", "password123"); + + ageSessionByToken(token, 20 * 60 * 1000); + const { response: staleResponse } = await apiFetch(token, "/api/users/me/password", { + method: "POST", + body: JSON.stringify({ currentPassword: "password123", newPassword: "brand-new-password" }), + }); + assert.equal(staleResponse.status, 401); + + await apiFetch(token, "/api/auth/reauth", { + method: "POST", + body: JSON.stringify({ currentPassword: "password123" }), + }); + + const { response: freshResponse } = await apiFetch(token, "/api/users/me/password", { + method: "POST", + body: JSON.stringify({ currentPassword: "password123", newPassword: "brand-new-password" }), + }); + assert.equal(freshResponse.status, 200); +}); + +test("lockout protection blocks removing the last usable auth method", async () => { + const passwordHash = bcrypt.hashSync("unused-random-hash", 4); + const oidcOnlyUser = userOps.createUser("oidc-only-user", passwordHash, "user", null, false); + const identity = userIdentityOps.link(oidcOnlyUser.id, { + providerType: "oidc", + providerKey: "https://issuer.example/", + subject: "sub-only", + }); + + const session = createSession(oidcOnlyUser.id, "127.0.0.1", "test-agent"); + + const { response } = await apiFetch(session.token, `/api/users/me/identities/${identity.id}`, { + method: "DELETE", + }); + assert.equal(response.status, 400); + assert.equal(userIdentityOps.countForUser(oidcOnlyUser.id), 1); +}); diff --git a/.tests/users/plex-link-routes.int.test.js b/.tests/users/plex-link-routes.int.test.js index a17e50ff6..a32bca05e 100644 --- a/.tests/users/plex-link-routes.int.test.js +++ b/.tests/users/plex-link-routes.int.test.js @@ -10,13 +10,19 @@ import { startServerProcess, } from "../helpers/backendTestHarness.js"; -const [isolatedState, { db }, { userOps, dbOps }, { plexConnectionStore }] = - await setupIsolatedBackend( - "plex-link-routes", - "backend/config/db-sqlite.js", - "backend/db/helpers/index.js", - "backend/services/plex/plexConnectionStore.js", - ); +const [ + isolatedState, + { db }, + { userOps, userIdentityOps, dbOps }, + { plexConnectionStore }, + { createSession }, +] = await setupIsolatedBackend( + "plex-link-routes", + "backend/config/db-sqlite.js", + "backend/db/helpers/index.js", + "backend/services/plex/plexConnectionStore.js", + "backend/config/session-helpers.js", +); let server = null; let adminId = null; @@ -198,3 +204,313 @@ test("admin DELETE /:id/plex-link unlinks a managed user", async () => { assert.equal(response.status, 200); assert.equal(plexConnectionStore.getConnection(userBId), null); }); + +test("admin DELETE /:id/plex-link also removes the user's Plex login identity", async () => { + const target = userOps.createUser( + "plex-login-target", + bcrypt.hashSync("password123", 4), + "user", + ); + plexConnectionStore.saveConnection(target.id, { + linkType: "self", + token: "target-token", + clientId: "target-client", + plexAccountId: 555, + plexUsername: "targetPlex", + }); + const identity = userIdentityOps.link(target.id, { + providerType: "plex", + providerKey: "plex", + subject: "555", + displayName: "targetPlex", + }); + + const { response } = await apiFetch(adminToken, `/api/users/${target.id}/plex-link`, { + method: "DELETE", + }); + assert.equal(response.status, 200); + assert.equal(plexConnectionStore.getConnection(target.id), null); + assert.equal( + userIdentityOps.getById(identity.id), + null, + "admin unlink must remove the identity, not just the connection, so Plex sign-in stops working", + ); +}); + +test("Plex login routes are disabled unless integrations.plex.loginEnabled is set", async () => { + const { response: pinResponse } = await apiFetch(null, "/api/auth/plex/login/pin", { + method: "POST", + }); + assert.equal(pinResponse.status, 404); + + const { response: completeResponse } = await apiFetch(null, "/api/auth/plex/login/complete", { + method: "POST", + body: JSON.stringify({ pinId: "x", code: "x", clientId: "x" }), + }); + assert.equal(completeResponse.status, 404); +}); + +test("Plex login rejects an unsafe forwardUrl before ever contacting Plex", async () => { + const settingsSave = await apiFetch(adminToken, "/api/settings", { + method: "POST", + body: JSON.stringify({ + integrations: { + plex: { loginEnabled: true, url: "http://plex.example.com:32400", token: "fake-token" }, + }, + }), + }); + assert.equal(settingsSave.response.status, 200, JSON.stringify(settingsSave.payload)); + + const { response: absoluteResponse } = await apiFetch(null, "/api/auth/plex/login/pin", { + method: "POST", + body: JSON.stringify({ forwardUrl: "https://evil.example.com/steal" }), + }); + assert.equal(absoluteResponse.status, 400); + + const { response: protocolRelativeResponse } = await apiFetch( + null, + "/api/auth/plex/login/pin", + { + method: "POST", + body: JSON.stringify({ forwardUrl: "//evil.example.com/steal" }), + }, + ); + assert.equal(protocolRelativeResponse.status, 400); + + const { response: backslashResponse } = await apiFetch(null, "/api/auth/plex/login/pin", { + method: "POST", + body: JSON.stringify({ forwardUrl: "/\\evil.example.com/steal" }), + }); + assert.equal(backslashResponse.status, 400); + + const { response: controlCharResponse } = await apiFetch(null, "/api/auth/plex/login/pin", { + method: "POST", + body: JSON.stringify({ forwardUrl: "/\tevil.example.com/steal" }), + }); + assert.equal(controlCharResponse.status, 400); +}); + +test("Plex login complete rejects a request with no valid transaction cookie, even with a guessed pinId/code/clientId", async () => { + const settingsSave = await apiFetch(adminToken, "/api/settings", { + method: "POST", + body: JSON.stringify({ + integrations: { + plex: { loginEnabled: true, url: "http://plex.example.com:32400", token: "fake-token" }, + }, + }), + }); + assert.equal(settingsSave.response.status, 200, JSON.stringify(settingsSave.payload)); + + const { response } = await apiFetch(null, "/api/auth/plex/login/complete", { + method: "POST", + body: JSON.stringify({ pinId: "attacker-pin", code: "attacker-code", clientId: "attacker-client" }), + }); + assert.equal(response.status, 400); + assert.match(response.headers.get("content-type") || "", /json/); +}); + +test("disconnecting Plex is blocked when it is the account's only usable auth method", async () => { + const oidcOnlyUser = userOps.createUser( + "plex-only-user", + bcrypt.hashSync("unused-random-hash", 4), + "user", + null, + false, + ); + plexConnectionStore.saveConnection(oidcOnlyUser.id, { + linkType: "self", + token: "plex-only-token", + clientId: "plex-only-client", + plexAccountId: 333, + plexUsername: "plexOnly", + }); + const identity = userIdentityOps.link(oidcOnlyUser.id, { + providerType: "plex", + providerKey: "plex", + subject: "333", + displayName: "plexOnly", + }); + + const session = createSession(oidcOnlyUser.id, "127.0.0.1", "test-agent"); + + const { response } = await apiFetch(session.token, "/api/users/me/plex-link", { + method: "DELETE", + }); + assert.equal(response.status, 400); + assert.ok(plexConnectionStore.getConnection(oidcOnlyUser.id)); + assert.equal(userIdentityOps.getById(identity.id)?.id, identity.id); +}); + +test("admin can suspend a user, which immediately invalidates their existing session", async () => { + const target = userOps.createUser("suspendable-user", bcrypt.hashSync("password123", 4), "user"); + const targetToken = await login("suspendable-user", "password123"); + assert.equal((await apiFetch(targetToken, "/api/auth/me")).response.status, 200); + + const { response } = await apiFetch(adminToken, `/api/users/${target.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "suspended" }), + }); + assert.equal(response.status, 200); + + const { response: sessionCheck } = await apiFetch(targetToken, "/api/auth/me"); + assert.equal(sessionCheck.status, 401); + + const { response: loginAttempt } = await apiFetch(null, "/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: "suspendable-user", password: "password123" }), + }); + assert.equal(loginAttempt.status, 403); +}); + +test("a protected admin cannot suspend or disable their own account", async () => { + const protectedAdmin = userOps.createUser( + "protected-admin", + bcrypt.hashSync("password123", 4), + "admin", + ); + userOps.setProtected(protectedAdmin.id, true); + const protectedAdminToken = await login("protected-admin", "password123"); + + const { response } = await apiFetch(protectedAdminToken, `/api/users/${protectedAdmin.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "suspended" }), + }); + assert.equal(response.status, 400); + assert.equal(userOps.getUserById(protectedAdmin.id)?.status, "active"); +}); + +test("a different admin also cannot suspend or disable a protected recovery account", async () => { + const protectedAdmin = userOps.createUser( + "protected-admin-2", + bcrypt.hashSync("password123", 4), + "admin", + ); + userOps.setProtected(protectedAdmin.id, true); + + const { response } = await apiFetch(adminToken, `/api/users/${protectedAdmin.id}`, { + method: "PATCH", + body: JSON.stringify({ status: "disabled" }), + }); + assert.equal(response.status, 400); + assert.equal(userOps.getUserById(protectedAdmin.id)?.status, "active"); +}); + +test("disconnecting Plex succeeds and removes the login identity when a fallback method exists", async () => { + plexConnectionStore.saveConnection(userAId, { + linkType: "self", + token: "user-a-token-2", + clientId: "user-a-client-2", + plexAccountId: 444, + plexUsername: "friendA2", + }); + const identity = userIdentityOps.link(userAId, { + providerType: "plex", + providerKey: "plex", + subject: "444", + displayName: "friendA2", + }); + + const { response } = await apiFetch(userAToken, "/api/users/me/plex-link", { + method: "DELETE", + }); + assert.equal(response.status, 200); + assert.equal(plexConnectionStore.getConnection(userAId), null); + assert.equal(userIdentityOps.getById(identity.id), null); +}); + +test("only an admin can approve a legacy account for SSO adoption, and only an eligible one", async () => { + const legacy = userOps.createUser( + "adoption-candidate", + bcrypt.hashSync("password123", 4), + "user", + null, + false, + ); + userOps.updateUser(legacy.id, { needsIdentityMigration: true }); + + const legacyToken = await login("adoption-candidate", "password123"); + const { response: selfResponse } = await apiFetch(legacyToken, `/api/users/${legacy.id}`, { + method: "PATCH", + body: JSON.stringify({ allowIdentityAdoption: true }), + }); + assert.equal(selfResponse.status, 403, "a user must not be able to approve their own adoption"); + assert.equal(userOps.getUserById(legacy.id).allowIdentityAdoption, false); + + const { response: adminResponse } = await apiFetch(adminToken, `/api/users/${legacy.id}`, { + method: "PATCH", + body: JSON.stringify({ allowIdentityAdoption: true }), + }); + assert.equal(adminResponse.status, 200); + assert.equal(userOps.getUserById(legacy.id).allowIdentityAdoption, true); + + const modern = userOps.createUser("modern-user", bcrypt.hashSync("password123", 4), "user"); + const { response: modernResponse } = await apiFetch(adminToken, `/api/users/${modern.id}`, { + method: "PATCH", + body: JSON.stringify({ allowIdentityAdoption: true }), + }); + assert.equal(modernResponse.status, 400); + assert.equal(userOps.getUserById(modern.id).allowIdentityAdoption, false); +}); + +test("the protected recovery account can never be approved for SSO adoption", async () => { + const protectedLegacy = userOps.createUser( + "protected-legacy-admin", + bcrypt.hashSync("password123", 4), + "admin", + null, + false, + ); + userOps.updateUser(protectedLegacy.id, { needsIdentityMigration: true }); + userOps.setProtected(protectedLegacy.id, true); + + const { response } = await apiFetch(adminToken, `/api/users/${protectedLegacy.id}`, { + method: "PATCH", + body: JSON.stringify({ allowIdentityAdoption: true }), + }); + assert.equal(response.status, 400); + assert.equal(userOps.getUserById(protectedLegacy.id).allowIdentityAdoption, false); +}); + +test("disconnecting Plex requires a recent reauth, same as the generic identity-unlink route", async () => { + const target = userOps.createUser( + "plex-reauth-user", + bcrypt.hashSync("password123", 4), + "user", + ); + plexConnectionStore.saveConnection(target.id, { + linkType: "self", + token: "reauth-token", + clientId: "reauth-client", + plexAccountId: 666, + plexUsername: "reauthPlex", + }); + userIdentityOps.link(target.id, { + providerType: "plex", + providerKey: "plex", + subject: "666", + displayName: "reauthPlex", + }); + const targetToken = await login("plex-reauth-user", "password123"); + + db.prepare("UPDATE sessions SET reauthenticated_at = ? WHERE token = ?").run( + Date.now() - 20 * 60 * 1000, + targetToken, + ); + + const { response: staleResponse } = await apiFetch(targetToken, "/api/users/me/plex-link", { + method: "DELETE", + }); + assert.equal(staleResponse.status, 401); + assert.ok(plexConnectionStore.getConnection(target.id)); + + await apiFetch(targetToken, "/api/auth/reauth", { + method: "POST", + body: JSON.stringify({ currentPassword: "password123" }), + }); + + const { response: freshResponse } = await apiFetch(targetToken, "/api/users/me/plex-link", { + method: "DELETE", + }); + assert.equal(freshResponse.status, 200); + assert.equal(plexConnectionStore.getConnection(target.id), null); +}); diff --git a/backend/config/db-sqlite.js b/backend/config/db-sqlite.js index 49b9edcbc..1c49d5f55 100644 --- a/backend/config/db-sqlite.js +++ b/backend/config/db-sqlite.js @@ -73,6 +73,18 @@ db.exec(` expires_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT, + reauthenticated_at INTEGER, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + provider_type TEXT NOT NULL, + provider_key TEXT NOT NULL, + subject TEXT NOT NULL, + display_name TEXT, + linked_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); @@ -229,6 +241,8 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_identities_provider_subject ON user_identities(provider_type, provider_key, subject); + CREATE INDEX IF NOT EXISTS idx_user_identities_user_id ON user_identities(user_id); CREATE INDEX IF NOT EXISTS idx_aurral_history_created_at ON aurral_history(created_at DESC); CREATE INDEX IF NOT EXISTS idx_inbox_items_user_state ON inbox_items(user_id, is_dismissed, is_read, created_at DESC); CREATE INDEX IF NOT EXISTS idx_inbox_items_expiry ON inbox_items(expires_at, created_at DESC); @@ -325,6 +339,14 @@ for (const [name, type] of [ } } +const sessionColumns = db + .prepare("PRAGMA table_info(sessions)") + .all() + .map((column) => column.name); +if (!sessionColumns.includes("reauthenticated_at")) { + tryAddColumn("ALTER TABLE sessions ADD COLUMN reauthenticated_at INTEGER"); +} + const userColumns = db .prepare("PRAGMA table_info(users)") .all() @@ -351,6 +373,38 @@ if (!userColumns.includes("discover_layout")) { if (!userColumns.includes("listen_history_url")) { tryAddColumn("ALTER TABLE users ADD COLUMN listen_history_url TEXT"); } +if (!userColumns.includes("status")) { + tryAddColumn("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); +} +if (!userColumns.includes("is_protected")) { + tryAddColumn("ALTER TABLE users ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); +} +if (!userColumns.includes("role_source")) { + tryAddColumn("ALTER TABLE users ADD COLUMN role_source TEXT NOT NULL DEFAULT 'local'"); +} +if (!userColumns.includes("has_local_password")) { + tryAddColumn("ALTER TABLE users ADD COLUMN has_local_password INTEGER NOT NULL DEFAULT 0"); +} +if (!userColumns.includes("needs_identity_migration")) { + db.transaction(() => { + tryAddColumn( + "ALTER TABLE users ADD COLUMN needs_identity_migration INTEGER NOT NULL DEFAULT 0", + ); + db.exec(` + UPDATE users SET needs_identity_migration = 1 + WHERE id NOT IN (SELECT DISTINCT user_id FROM user_identities) + `); + })(); +} +if (!userColumns.includes("allow_identity_adoption")) { + tryAddColumn("ALTER TABLE users ADD COLUMN allow_identity_adoption INTEGER NOT NULL DEFAULT 0"); +} + +db.exec(` + UPDATE users SET needs_identity_migration = 0, allow_identity_adoption = 0 + WHERE needs_identity_migration = 1 + AND id IN (SELECT DISTINCT user_id FROM user_identities) +`); db.exec(` UPDATE users diff --git a/backend/config/encryption.js b/backend/config/encryption.js index a9ff1c58a..cfab1e14e 100644 --- a/backend/config/encryption.js +++ b/backend/config/encryption.js @@ -47,6 +47,7 @@ const SENSITIVE_PATHS = [ ["nzbget", "password"], ["gotify", "token"], ["lastfm", "apiKey"], + ["google", "clientSecret"], ]; function getAt(obj, path) { diff --git a/backend/config/session-helpers.js b/backend/config/session-helpers.js index 8e41a597d..4340447c6 100644 --- a/backend/config/session-helpers.js +++ b/backend/config/session-helpers.js @@ -5,12 +5,13 @@ 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 (?, ?, ?, ?, ?, ?)", + "INSERT INTO sessions (user_id, token, created_at, expires_at, ip_address, user_agent, reauthenticated_at) 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 touchReauthStmt = db.prepare("UPDATE sessions SET reauthenticated_at = ? WHERE token = ?"); const getSessionExpiryMs = () => { const hours = Number(process.env.SESSION_EXPIRY_HOURS); @@ -39,6 +40,7 @@ export const createSession = (userId, ipAddress = null, userAgent = null) => { expiresAt, ipAddress ? String(ipAddress).slice(0, 255) : null, userAgent ? String(userAgent).slice(0, 1024) : null, + now, ); return { token, @@ -46,6 +48,13 @@ export const createSession = (userId, ipAddress = null, userAgent = null) => { }; }; +export const touchReauth = (token) => { + const rawToken = String(token || "").trim(); + if (!rawToken) return false; + const result = touchReauthStmt.run(Date.now(), rawToken); + return result.changes > 0; +}; + export const getSessionByToken = (token) => { const rawToken = String(token || "").trim(); if (!rawToken) return null; @@ -56,7 +65,7 @@ export const getSessionByToken = (token) => { return null; } const user = userOps.getUserAuthById(row.user_id); - if (!user) { + if (!user || user.status !== "active") { deleteSessionByTokenStmt.run(rawToken); return null; } @@ -68,6 +77,7 @@ export const getSessionByToken = (token) => { expiresAt: row.expires_at, ipAddress: row.ip_address, userAgent: row.user_agent, + reauthenticatedAt: row.reauthenticated_at || row.created_at, user: toUserPayload(user), }; }; diff --git a/backend/db/helpers/index.js b/backend/db/helpers/index.js index bc7173726..216f136a6 100644 --- a/backend/db/helpers/index.js +++ b/backend/db/helpers/index.js @@ -4,6 +4,7 @@ */ import { dbOps } from "./settings.js"; import { userOps } from "./users.js"; +import { userIdentityOps } from "./userIdentities.js"; import registerCache from "./cache.js"; import registerDiscovery from "./discovery.js"; import registerOverrides from "./overrides.js"; @@ -18,4 +19,4 @@ registerLidarr(dbOps); registerHistory(dbOps); registerInbox(dbOps); -export { dbOps, userOps }; +export { dbOps, userOps, userIdentityOps }; diff --git a/backend/db/helpers/userIdentities.js b/backend/db/helpers/userIdentities.js new file mode 100644 index 000000000..04d70391a --- /dev/null +++ b/backend/db/helpers/userIdentities.js @@ -0,0 +1,67 @@ +import { db } from "../../config/db-sqlite.js"; + +const findByProviderStmt = db.prepare( + "SELECT * FROM user_identities WHERE provider_type = ? AND provider_key = ? AND subject = ?", +); +const getForUserStmt = db.prepare( + "SELECT * FROM user_identities WHERE user_id = ? ORDER BY linked_at ASC", +); +const getByIdStmt = db.prepare("SELECT * FROM user_identities WHERE id = ?"); +const countForUserStmt = db.prepare( + "SELECT COUNT(*) AS count FROM user_identities WHERE user_id = ?", +); +const insertStmt = db.prepare( + "INSERT INTO user_identities (user_id, provider_type, provider_key, subject, display_name, linked_at) VALUES (?, ?, ?, ?, ?, ?)", +); +const deleteByIdStmt = db.prepare("DELETE FROM user_identities WHERE id = ?"); + +const toIdentity = (row) => + row + ? { + id: row.id, + userId: row.user_id, + providerType: row.provider_type, + providerKey: row.provider_key, + subject: row.subject, + displayName: row.display_name, + linkedAt: row.linked_at, + } + : null; + +export const userIdentityOps = { + findByProvider(providerType, providerKey, subject) { + return toIdentity(findByProviderStmt.get(providerType, providerKey, subject)); + }, + getForUser(userId) { + return getForUserStmt.all(parseInt(userId, 10)).map(toIdentity); + }, + getById(id) { + return toIdentity(getByIdStmt.get(parseInt(id, 10))); + }, + countForUser(userId) { + return countForUserStmt.get(parseInt(userId, 10)).count; + }, + unlink(id) { + const result = deleteByIdStmt.run(parseInt(id, 10)); + return result.changes > 0; + }, + link(userId, { providerType, providerKey, subject, displayName = null }) { + const result = insertStmt.run( + parseInt(userId, 10), + providerType, + providerKey, + subject, + displayName, + Date.now(), + ); + return toIdentity({ + id: result.lastInsertRowid, + user_id: userId, + provider_type: providerType, + provider_key: providerKey, + subject, + display_name: displayName, + linked_at: Date.now(), + }); + }, +}; diff --git a/backend/db/helpers/users.js b/backend/db/helpers/users.js index 603539035..3d9fc6745 100644 --- a/backend/db/helpers/users.js +++ b/backend/db/helpers/users.js @@ -11,19 +11,20 @@ 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, role, permissions, lastfm_username, listen_history_provider, listen_history_username, listen_history_url, lidarr_root_folder_path, lidarr_quality_profile_id, status, is_protected, role_source, has_local_password, needs_identity_migration, allow_identity_adoption FROM users ORDER BY username" ); 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, role, permissions, status, is_protected, role_source FROM users WHERE id = ?" ); 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, password_hash, role, permissions, lidarr_root_folder_path, lidarr_quality_profile_id, has_local_password, is_protected) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ); 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 = ?, password_hash = ?, role = ?, permissions = ?, lastfm_username = ?, listen_history_provider = ?, listen_history_username = ?, listen_history_url = ?, lidarr_root_folder_path = ?, lidarr_quality_profile_id = ?, status = ?, role_source = ?, has_local_password = ?, needs_identity_migration = ?, allow_identity_adoption = ? WHERE id = ?" ); +const setProtectedStmt = db.prepare("UPDATE users SET is_protected = ? WHERE id = ?"); const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?"); const getAllListeningHistoryUsersStmt = db.prepare( "SELECT id, username, lastfm_username, listen_history_provider, listen_history_username, listen_history_url FROM users WHERE (listen_history_username IS NOT NULL AND TRIM(listen_history_username) != '') OR (listen_history_url IS NOT NULL AND TRIM(listen_history_url) != '')" @@ -61,6 +62,12 @@ export const userOps = { row.lidarr_quality_profile_id != null ? Number(row.lidarr_quality_profile_id) : null, + status: row.status || "active", + isProtected: !!row.is_protected, + roleSource: row.role_source || "local", + hasLocalPassword: !!row.has_local_password, + needsIdentityMigration: !!row.needs_identity_migration, + allowIdentityAdoption: !!row.allow_identity_adoption, ...history, }; }, @@ -81,6 +88,12 @@ export const userOps = { row.lidarr_quality_profile_id != null ? Number(row.lidarr_quality_profile_id) : null, + status: row.status || "active", + isProtected: !!row.is_protected, + roleSource: row.role_source || "local", + hasLocalPassword: !!row.has_local_password, + needsIdentityMigration: !!row.needs_identity_migration, + allowIdentityAdoption: !!row.allow_identity_adoption, ...history, }; }, @@ -94,6 +107,9 @@ export const userOps = { permissions: dbHelpers.parseJSON(row.permissions) || { ...DEFAULT_PERMISSIONS, }, + status: row.status || "active", + isProtected: !!row.is_protected, + roleSource: row.role_source || "local", }; }, countUsers() { @@ -114,9 +130,22 @@ export const userOps = { r.lidarr_quality_profile_id != null ? Number(r.lidarr_quality_profile_id) : null, + status: r.status || "active", + isProtected: !!r.is_protected, + roleSource: r.role_source || "local", + hasLocalPassword: !!r.has_local_password, + needsIdentityMigration: !!r.needs_identity_migration, + allowIdentityAdoption: !!r.allow_identity_adoption, })); }, - createUser(username, passwordHash, role = "user", permissions = null) { + createUser( + username, + passwordHash, + role = "user", + permissions = null, + hasLocalPassword = true, + isProtected = false, + ) { const un = String(username).trim(); if (!un) return null; const perms = permissions @@ -130,6 +159,8 @@ export const userOps = { dbHelpers.stringifyJSON(perms), null, null, + hasLocalPassword ? 1 : 0, + isProtected ? 1 : 0, ); return { id: result.lastInsertRowid, @@ -142,6 +173,12 @@ export const userOps = { lastfmUsername: null, lidarrRootFolderPath: null, lidarrQualityProfileId: null, + status: "active", + isProtected: !!isProtected, + roleSource: "local", + hasLocalPassword: !!hasLocalPassword, + needsIdentityMigration: false, + allowIdentityAdoption: false, }; } catch (e) { return null; @@ -208,6 +245,18 @@ export const userOps = { : parsedLidarrQualityProfileId === null ? null : existing.lidarrQualityProfileId; + const status = data.status !== undefined ? data.status : existing.status; + const roleSource = data.roleSource !== undefined ? data.roleSource : existing.roleSource; + const hasLocalPassword = + data.hasLocalPassword !== undefined ? !!data.hasLocalPassword : existing.hasLocalPassword; + const needsIdentityMigration = + data.needsIdentityMigration !== undefined + ? !!data.needsIdentityMigration + : existing.needsIdentityMigration; + const allowIdentityAdoption = + data.allowIdentityAdoption !== undefined + ? !!data.allowIdentityAdoption + : existing.allowIdentityAdoption; try { updateUserStmt.run( username.toLowerCase(), @@ -220,6 +269,11 @@ export const userOps = { resolvedUrl, lidarrRootFolderPath, lidarrQualityProfileId, + status, + roleSource, + hasLocalPassword ? 1 : 0, + needsIdentityMigration ? 1 : 0, + allowIdentityAdoption ? 1 : 0, parseInt(id, 10) ); return { @@ -233,11 +287,25 @@ export const userOps = { lastfmUsername, lidarrRootFolderPath, lidarrQualityProfileId, + status, + isProtected: existing.isProtected, + roleSource, + hasLocalPassword, + needsIdentityMigration, + allowIdentityAdoption, }; } catch (e) { return null; } }, + setProtected(id, isProtected) { + try { + setProtectedStmt.run(isProtected ? 1 : 0, parseInt(id, 10)); + return true; + } catch (e) { + return false; + } + }, deleteUser(id) { try { deleteUserStmt.run(parseInt(id, 10)); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 86a1c5854..06ebc89b9 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -254,13 +254,16 @@ function buildPermissions(role, permissions) { }; } -function toResolvedUser(user) { +export function toResolvedUser(user) { if (!user) return null; return { id: user.id, username: user.username, role: user.role, permissions: buildPermissions(user.role, user.permissions), + status: user.status || "active", + isProtected: !!user.isProtected, + roleSource: user.roleSource || "local", }; } @@ -440,20 +443,27 @@ export function reconcileLocalNetworkBypassSetting() { }; } +export function createSystemProvisionedUser(username, role) { + const passwordHash = hashPassword(crypto.randomBytes(32).toString("hex")); + const created = userOps.createUser(username, passwordHash, role, null, false); + return created + ? toResolvedUser(userOps.getUserByUsername(created.username) || created) + : toResolvedUser(userOps.getUserByUsername(username)); +} + export function ensureExternalUser(username, role) { const existing = userOps.getUserByUsername(username); if (existing) { + if (existing.isProtected) { + return toResolvedUser(existing); + } if (existing.role !== role) { - const updated = userOps.updateUser(existing.id, { role }); + const updated = userOps.updateUser(existing.id, { role, roleSource: "local" }); return toResolvedUser(updated || existing); } return toResolvedUser(existing); } - const passwordHash = hashPassword(crypto.randomBytes(32).toString("hex")); - const created = userOps.createUser(username, passwordHash, role, null); - return created - ? toResolvedUser(userOps.getUserByUsername(created.username) || created) - : toResolvedUser(userOps.getUserByUsername(username)); + return createSystemProvisionedUser(username, role); } function isProxyAdmin(req, username) { @@ -491,7 +501,8 @@ export function resolveProxyUser(req) { if (!username) return null; const role = resolveProxyRole(req, username); - return ensureExternalUser(username, role); + const user = ensureExternalUser(username, role); + return user?.status === "active" ? user : null; } export function issueProxySession(req) { @@ -662,7 +673,11 @@ export const authMiddleware = (req, res, next) => { if ( req.path === "/api/auth/login" || req.path === "/api/auth/oidc/login" || - req.path === "/api/auth/oidc/exchange" + req.path === "/api/auth/oidc/exchange" || + req.path === "/api/auth/google/login" || + req.path === "/api/auth/google/exchange" || + req.path === "/api/auth/plex/login/pin" || + req.path === "/api/auth/plex/login/complete" ) { return next(); } diff --git a/backend/middleware/requirePermission.js b/backend/middleware/requirePermission.js index 051b9c833..84bcf14ff 100644 --- a/backend/middleware/requirePermission.js +++ b/backend/middleware/requirePermission.js @@ -1,4 +1,13 @@ import { hasPermission, sendUnauthorizedResponse } from "./auth.js"; +import { getSessionByToken } from "../config/session-helpers.js"; + +const DEFAULT_REAUTH_MAX_AGE_MINUTES = 15; + +function getBearerToken(req) { + const authHeader = String(req.headers?.authorization || ""); + if (!authHeader.startsWith("Bearer ")) return null; + return authHeader.slice(7).trim(); +} export function requireAuth(req, res, next) { if (!req.user) { @@ -14,6 +23,30 @@ export function requireAdmin(req, res, next) { next(); } +export function isRecentlyAuthenticated(req, maxAgeMinutes = DEFAULT_REAUTH_MAX_AGE_MINUTES) { + const token = getBearerToken(req); + if (!token) return true; + const session = getSessionByToken(token); + if (!session) return false; + const ageMs = Date.now() - session.reauthenticatedAt; + return ageMs <= maxAgeMinutes * 60 * 1000; +} + +export function requireRecentAuth(maxAgeMinutes = DEFAULT_REAUTH_MAX_AGE_MINUTES) { + return (req, res, next) => { + if (!req.user) { + return sendUnauthorizedResponse(req, res); + } + if (!isRecentlyAuthenticated(req, maxAgeMinutes)) { + return res.status(401).json({ + error: "reauth_required", + message: "Please confirm your credentials to continue", + }); + } + next(); + }; +} + export function requirePermission(permission) { return (req, res, next) => { if (!req.user) { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 31cb342bd..c0e5ef011 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,10 +1,21 @@ 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 { + createSession, + deleteSession, + getSessionByToken, + touchReauth, +} from "../config/session-helpers.js"; +import { requireAuth, requireRecentAuth } 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 { + clearGoogleTransactionCookie, + exchangeGoogleCallback, + startGoogleAuth, +} from "../services/googleAuth.js"; +import { startPlexLogin, completePlexLogin } from "../services/plexLoginAuth.js"; import { logger } from "../services/logger.js"; const router = express.Router(); @@ -28,9 +39,15 @@ router.post("/login", async (req, res) => { if (!user || !verifyPassword(password, user.passwordHash)) { return res.status(401).json({ error: "Invalid username or password" }); } + if (user.status !== "active") { + return res.status(403).json({ error: "This account has been suspended or disabled" }); + } if (needsRehash(user.passwordHash)) { userOps.updateUser(user.id, { passwordHash: hashPassword(password) }); } + if (!user.hasLocalPassword) { + userOps.updateUser(user.id, { hasLocalPassword: true }); + } const session = createSession(user.id, req.ip || null, req.headers["user-agent"] || null); res.json({ token: session.token, @@ -76,6 +93,29 @@ router.get("/me", requireAuth, (req, res) => { }); }); +router.post("/reauth", requireAuth, (req, res) => { + const token = getBearerToken(req); + if (!token) { + return res.status(400).json({ error: "Reauthentication requires an active session" }); + } + const user = userOps.getUserById(req.user.id); + if (!user) { + return res.status(400).json({ error: "Current password is incorrect" }); + } + if (!user.hasLocalPassword) { + return res.status(400).json({ + error: "no_local_password", + message: "This account has no local password. Sign out and back in to refresh your session.", + }); + } + const { currentPassword } = req.body || {}; + if (!verifyPassword(currentPassword || "", user.passwordHash)) { + return res.status(400).json({ error: "Current password is incorrect" }); + } + touchReauth(token); + res.json({ success: true }); +}); + router.get("/api-key", requireAuth, (req, res) => { res.json({ apiKey: getApiKey() }); }); @@ -106,4 +146,58 @@ router.post("/oidc/exchange", (req, res) => { } }); +router.get("/google/login", async (req, res) => { + try { + await startGoogleAuth(req, res, { mode: "login" }); + } catch (error) { + logger.error("auth", "Google login start failed:", { message: error.message }); + if (!res.headersSent) { + res.status(500).json({ error: "Google login failed" }); + } + } +}); + +router.get("/google/link", requireAuth, requireRecentAuth(), async (req, res) => { + try { + await startGoogleAuth(req, res, { mode: "link", linkUserId: req.user.id }); + } catch (error) { + logger.error("auth", "Google link start failed:", { message: error.message }); + if (!res.headersSent) { + res.status(500).json({ error: "Google link failed" }); + } + } +}); + +router.post("/google/exchange", (req, res) => { + try { + const result = exchangeGoogleCallback(req.body?.code, req); + clearGoogleTransactionCookie(req, res); + res.json(result); + } catch (error) { + res.status(error.status || 500).json({ error: error.message || "Google exchange failed" }); + } +}); + +router.post("/plex/login/pin", async (req, res) => { + try { + await startPlexLogin(req, res); + } catch (error) { + logger.error("auth", "Plex login PIN generation failed:", error.message); + if (!res.headersSent) { + res.status(500).json({ error: "Failed to start Plex login", message: error.message }); + } + } +}); + +router.post("/plex/login/complete", async (req, res) => { + try { + await completePlexLogin(req, res); + } catch (error) { + logger.error("auth", "Plex login completion failed:", error.message); + if (!res.headersSent) { + res.status(500).json({ error: "Plex login failed", message: error.message }); + } + } +}); + export default router; diff --git a/backend/routes/health.js b/backend/routes/health.js index 507d36d81..9e96aeece 100644 --- a/backend/routes/health.js +++ b/backend/routes/health.js @@ -18,6 +18,8 @@ import { getLocalNetworkBypassStatus, } from "../middleware/auth.js"; import { getOidcBootstrapInfo } from "../services/oidcAuth.js"; +import { isGoogleLoginEnabled } from "../services/googleAuth.js"; +import { isPlexLoginEnabled } from "./users/plexLinkHandlers.js"; import { lidarrClient } from "../services/lidarrClient.js"; import { getDiscoveryCache, @@ -252,6 +254,9 @@ function buildBootstrapPayload(req) { proxyAuthEnabled: isProxyAuthEnabled(), oidcEnabled: oidcInfo.oidcEnabled, oidcLogoutUrl: oidcInfo.oidcLogoutUrl, + googleLoginEnabled: isGoogleLoginEnabled(), + plexLoginEnabled: isPlexLoginEnabled(), + ssoOnly: settings?.security?.ssoOnly === true, onboardingRequired: !onboardingDone, dateTimeFormat: settings.dateTimeFormat, timestamp: new Date().toISOString(), diff --git a/backend/routes/onboarding.js b/backend/routes/onboarding.js index f8d82934f..3e035bf39 100644 --- a/backend/routes/onboarding.js +++ b/backend/routes/onboarding.js @@ -180,7 +180,7 @@ router.post("/complete", async (req, res) => { const authPasswordFinal = integrations?.general?.authPassword || ""; if (authPasswordFinal && userOps.getAllUsers().length === 0) { const hash = hashPassword(authPasswordFinal); - const created = userOps.createUser(authUserFinal, hash, "admin", null); + const created = userOps.createUser(authUserFinal, hash, "admin", null, true, true); const initialListenHistory = getDefaultListenHistoryProfile(nextSettings); if (created && initialListenHistory) { userOps.updateUser(created.id, initialListenHistory); diff --git a/backend/routes/settings/handlers/general.js b/backend/routes/settings/handlers/general.js index 746fa59e7..0395cdf4e 100644 --- a/backend/routes/settings/handlers/general.js +++ b/backend/routes/settings/handlers/general.js @@ -304,7 +304,22 @@ export function registerGeneral(router) { integrations.news = nextNews; } - const INTEGRATION_KEYS = ["lidarr", "navidrome", "slskd", "prowlarr", "nzbget", "ytdlp", "lastfm", "ticketmaster", "news", "metadata", "general", "gotify", "webhookEvents"]; + if (integrations?.google?.redirectUri !== undefined) { + const trimmedRedirectUri = String(integrations.google.redirectUri).trim(); + if (trimmedRedirectUri) { + const redirectValidation = validateExternalUrl(trimmedRedirectUri); + if (!redirectValidation.valid) { + return res.status(400).json({ + error: `Invalid Google redirect URI: ${redirectValidation.error}`, + }); + } + integrations.google.redirectUri = redirectValidation.url; + } else { + integrations.google.redirectUri = ""; + } + } + + const INTEGRATION_KEYS = ["lidarr", "navidrome", "slskd", "prowlarr", "nzbget", "ytdlp", "lastfm", "ticketmaster", "news", "metadata", "general", "gotify", "webhookEvents", "google"]; let mergedIntegrations = currentSettings.integrations || defaultData.settings.integrations || {}; if (integrations) { diff --git a/backend/routes/users.js b/backend/routes/users.js index abd6b956c..f48d4c4e7 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,7 +1,12 @@ 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 { + requireAuth, + requireAdmin, + requireRecentAuth, + isRecentlyAuthenticated, +} from "../middleware/requirePermission.js"; import { reconcileLocalNetworkBypassSetting } from "../middleware/auth.js"; import { requirePasswordStrength } from "../middleware/auth.js"; import { deleteSessionsByUserId } from "../config/session-helpers.js"; @@ -19,6 +24,7 @@ import { import { validateExternalUrl } from "../middleware/urlValidator.js"; import { normalizeKoitoBaseUrl } from "../services/koitoClient.js"; import { registerPlexLink, resolveGlobalPlexAccount } from "./users/plexLinkHandlers.js"; +import { registerIdentityLink } from "./users/identityLinkHandlers.js"; import { plexConnectionStore } from "../services/plex/plexConnectionStore.js"; const buildListenHistoryUpdates = (body, existing) => { @@ -196,7 +202,7 @@ router.post("/", requireAuth, requireAdmin, async (req, res) => { } const hash = hashPassword(password); const perms = permissions ? { ...userOps.getDefaultPermissions(), ...permissions } : null; - const created = userOps.createUser(un, hash, role, perms); + const created = userOps.createUser(un, hash, role, perms, true); if (!created) { return res.status(500).json({ error: "Failed to create user" }); } @@ -226,7 +232,11 @@ router.patch("/:id", requireAuth, async (req, res) => { if (!existing) { return res.status(404).json({ error: "User not found" }); } - const { password, permissions, role } = req.body; + const { password, permissions, role, status, allowIdentityAdoption } = req.body; + const VALID_STATUSES = ["active", "suspended", "disabled"]; + if (status !== undefined && !VALID_STATUSES.includes(status)) { + return res.status(400).json({ error: "Invalid status" }); + } const existingProfile = getListenHistoryProfile(existing); let listenHistoryUpdates = null; try { @@ -242,7 +252,12 @@ router.patch("/:id", requireAuth, async (req, res) => { }); clearOrphanedDiscoveryCache(id, existingProfile, requestedProfile); if (isSelf && !isAdmin) { - if (permissions !== undefined || role !== undefined) { + if ( + permissions !== undefined || + role !== undefined || + status !== undefined || + allowIdentityAdoption !== undefined + ) { return res.status(403).json({ error: "Forbidden" }); } const updates = {}; @@ -262,6 +277,7 @@ router.patch("/:id", requireAuth, async (req, res) => { return res.status(400).json({ error: passwordValidation.error }); } updates.passwordHash = hashPassword(password); + updates.hasLocalPassword = true; } if (Object.keys(updates).length === 0) { return res.json({ @@ -284,14 +300,48 @@ router.patch("/:id", requireAuth, async (req, res) => { } const updates = {}; if (password) { + if (!isSelf && !isRecentlyAuthenticated(req)) { + return res.status(401).json({ + error: "reauth_required", + message: "Please confirm your credentials to continue", + }); + } const passwordValidation = requirePasswordStrength(password); if (!passwordValidation.valid) { return res.status(400).json({ error: passwordValidation.error }); } updates.passwordHash = hashPassword(password); + updates.hasLocalPassword = true; } if (permissions !== undefined) updates.permissions = permissions; - if (role !== undefined) updates.role = role; + if (role !== undefined) { + updates.role = role; + updates.roleSource = "local"; + } + if (status !== undefined) { + if (existing.isProtected && status !== "active") { + return res.status(400).json({ + error: "You cannot suspend or disable a protected recovery account", + }); + } + updates.status = status; + if (status !== "active") { + deleteSessionsByUserId(id); + } + } + if (allowIdentityAdoption !== undefined) { + if (allowIdentityAdoption && !existing.needsIdentityMigration) { + return res.status(400).json({ + error: "Only accounts that predate identity linking can be approved for adoption", + }); + } + if (allowIdentityAdoption && existing.isProtected) { + return res.status(400).json({ + error: "The protected recovery account cannot be approved for adoption", + }); + } + updates.allowIdentityAdoption = !!allowIdentityAdoption; + } if (listenHistoryUpdates) { Object.assign(updates, listenHistoryUpdates); } @@ -506,7 +556,7 @@ router.patch("/me/lidarr-preferences", requireAuth, async (req, res) => { } }); -router.post("/me/password", requireAuth, async (req, res) => { +router.post("/me/password", requireAuth, requireRecentAuth(), async (req, res) => { try { const { currentPassword, newPassword } = req.body; if (!newPassword) { @@ -517,11 +567,14 @@ router.post("/me/password", requireAuth, async (req, res) => { return res.status(400).json({ error: passwordValidation.error }); } const u = userOps.getUserById(req.user.id); - if (!u || !verifyPassword(currentPassword || "", u.passwordHash)) { + if (!u) { + return res.status(400).json({ error: "Current password is incorrect" }); + } + if (u.hasLocalPassword && !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 }); + userOps.updateUser(req.user.id, { passwordHash: hash, hasLocalPassword: true }); deleteSessionsByUserId(req.user.id); res.json({ success: true }); } catch (e) { @@ -549,5 +602,6 @@ router.delete("/:id", requireAuth, requireAdmin, (req, res) => { }); registerPlexLink(router); +registerIdentityLink(router); export default router; diff --git a/backend/routes/users/identityLinkHandlers.js b/backend/routes/users/identityLinkHandlers.js new file mode 100644 index 000000000..c326c85f1 --- /dev/null +++ b/backend/routes/users/identityLinkHandlers.js @@ -0,0 +1,42 @@ +import { userOps, userIdentityOps } from "../../db/helpers/index.js"; +import { requireAuth, requireRecentAuth } from "../../middleware/requirePermission.js"; + +export function registerIdentityLink(router) { + router.get("/me/identities", requireAuth, (req, res) => { + const user = userOps.getUserById(req.user.id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + res.json({ + hasLocalPassword: user.hasLocalPassword, + identities: userIdentityOps.getForUser(req.user.id).map((identity) => ({ + id: identity.id, + providerType: identity.providerType, + providerKey: identity.providerKey, + displayName: identity.displayName, + linkedAt: identity.linkedAt, + })), + }); + }); + + router.delete("/me/identities/:id", requireAuth, requireRecentAuth(), (req, res) => { + const identityId = parseInt(req.params.id, 10); + const identity = userIdentityOps.getById(identityId); + if (!identity || identity.userId !== req.user.id) { + return res.status(404).json({ error: "Identity not found" }); + } + + const user = userOps.getUserById(req.user.id); + const remaining = userIdentityOps.countForUser(req.user.id) - 1; + if (remaining <= 0 && !user?.hasLocalPassword) { + return res.status(400).json({ + error: "last_auth_method", + message: + "This is your only way to sign in. Set a local password or link another account before removing it.", + }); + } + + userIdentityOps.unlink(identityId); + res.json({ success: true }); + }); +} diff --git a/backend/routes/users/plexLinkHandlers.js b/backend/routes/users/plexLinkHandlers.js index 450f20c54..f70f801f0 100644 --- a/backend/routes/users/plexLinkHandlers.js +++ b/backend/routes/users/plexLinkHandlers.js @@ -1,5 +1,5 @@ -import { dbOps, userOps } from "../../db/helpers/index.js"; -import { requireAuth, requireAdmin } from "../../middleware/requirePermission.js"; +import { dbOps, userOps, userIdentityOps } from "../../db/helpers/index.js"; +import { requireAuth, requireAdmin, requireRecentAuth } from "../../middleware/requirePermission.js"; import { plexConnectionStore } from "../../services/plex/plexConnectionStore.js"; import { playlistManager } from "../../services/weeklyFlow/weeklyFlowPlaylistManager.js"; import { logger } from "../../services/logger.js"; @@ -8,6 +8,11 @@ function getGlobalPlexConfig() { return dbOps.getSettings()?.integrations?.plex || {}; } +export function isPlexLoginEnabled() { + const plex = getGlobalPlexConfig(); + return plex.loginEnabled === true && !!plex.url && !!plex.token; +} + async function cleanupUserPlexPlaylistsSafely(userId, context) { try { await playlistManager.cleanupUserPlexPlaylists(userId); @@ -118,7 +123,7 @@ export function registerPlexLink(router) { } }); - router.post("/me/plex-link/oauth/complete", requireAuth, async (req, res) => { + router.post("/me/plex-link/oauth/complete", requireAuth, requireRecentAuth(), async (req, res) => { try { const { PlexClient } = await import("../../services/plex.js"); const { pinId, code, clientId } = req.body || {}; @@ -147,6 +152,16 @@ export function registerPlexLink(router) { } const serverToken = tokenResult.serverToken; + const subject = identity.id != null ? String(identity.id) : null; + if (subject) { + const existingIdentity = userIdentityOps.findByProvider("plex", "plex", subject); + if (existingIdentity && existingIdentity.userId !== req.user.id) { + return res.status(409).json({ + error: "This Plex account is already linked to another Aurral account", + }); + } + } + await cleanupPlexPlaylistsIfIdentityChanged(req.user.id, "self", identity.id); const saved = plexConnectionStore.saveConnection(req.user.id, { @@ -157,6 +172,16 @@ export function registerPlexLink(router) { plexUuid: identity.uuid || null, plexUsername: identity.username || identity.title || null, }); + + if (subject && !userIdentityOps.findByProvider("plex", "plex", subject)) { + userIdentityOps.link(req.user.id, { + providerType: "plex", + providerKey: "plex", + subject, + displayName: identity.username || identity.title || null, + }); + } + res.json({ connected: true, linkType: saved.linkType, @@ -172,10 +197,28 @@ export function registerPlexLink(router) { } }); - router.delete("/me/plex-link", requireAuth, async (req, res) => { + router.delete("/me/plex-link", requireAuth, requireRecentAuth(), async (req, res) => { try { + const plexIdentity = userIdentityOps + .getForUser(req.user.id) + .find((identity) => identity.providerType === "plex"); + if (plexIdentity) { + const user = userOps.getUserById(req.user.id); + const remaining = userIdentityOps.countForUser(req.user.id) - 1; + if (remaining <= 0 && !user?.hasLocalPassword) { + return res.status(400).json({ + error: "last_auth_method", + message: + "This is your only way to sign in. Set a local password or link another account before removing it.", + }); + } + } + await cleanupUserPlexPlaylistsSafely(req.user.id, "on unlink"); plexConnectionStore.clearConnection(req.user.id); + if (plexIdentity) { + userIdentityOps.unlink(plexIdentity.id); + } res.json({ connected: false }); } catch (e) { res.status(500).json({ error: "Failed to disconnect Plex", message: e.message }); @@ -303,6 +346,12 @@ export function registerPlexLink(router) { if (!target) return res.status(404).json({ error: "User not found" }); await cleanupUserPlexPlaylistsSafely(id, "on unlink"); plexConnectionStore.clearConnection(id); + const adminPlexIdentity = userIdentityOps + .getForUser(id) + .find((identity) => identity.providerType === "plex"); + if (adminPlexIdentity) { + userIdentityOps.unlink(adminPlexIdentity.id); + } res.json({ connected: false }); } catch (e) { res.status(500).json({ error: "Failed to unlink Plex", message: e.message }); diff --git a/backend/server.js b/backend/server.js index a101c030a..d212e1d1f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,6 +11,7 @@ dns.setDefaultResultOrder("ipv4first"); import { authMiddleware, isProxyAuthEnabled } from "./middleware/auth.js"; import { handleOidcCallback, isOidcEnabled } from "./services/oidcAuth.js"; +import { handleGoogleCallback } from "./services/googleAuth.js"; import { logger } from "./services/logger.js"; import { websocketService } from "./services/websocketService.js"; import { getAllDownloadStatuses } from "./routes/library/handlers/downloads.js"; @@ -209,6 +210,18 @@ app.get("/sso/callback", async (req, res) => { } }); +app.get("/sso/google/callback", async (req, res) => { + try { + const result = await handleGoogleCallback(req); + const code = encodeURIComponent(result.code); + res.redirect(302, `/sso/complete#code=${code}&provider=google`); + } catch (error) { + logger.error("auth", "Google callback failed:", { message: error.message }); + const message = encodeURIComponent(error.message || "Google login failed"); + res.redirect(302, `/sso/complete#error=${message}&provider=google`); + } +}); + const frontendDist = path.join(__dirname, "..", "frontend", "dist"); const frontendFallbackRoute = /.*/; diff --git a/backend/services/googleAuth.js b/backend/services/googleAuth.js new file mode 100644 index 000000000..b8f7c492b --- /dev/null +++ b/backend/services/googleAuth.js @@ -0,0 +1,273 @@ +import * as client from "openid-client"; +import { createSession } from "../config/session-helpers.js"; +import { toResolvedUser } from "../middleware/auth.js"; +import { dbOps, userOps, userIdentityOps } from "../db/helpers/index.js"; + +const REAL_GOOGLE_ISSUER = "https://accounts.google.com"; +const STATE_TTL_MS = 10 * 60 * 1000; +const EXCHANGE_TTL_MS = 60 * 1000; +const TRANSACTION_COOKIE = "aurral_google_transaction"; +const pendingLogins = new Map(); +const pendingExchanges = new Map(); +let discoveryConfig = null; +let discoveryKey = ""; +let issuerOverride = null; + +export function setGoogleIssuerForTests(issuerUrl) { + issuerOverride = issuerUrl || null; + discoveryConfig = null; + discoveryKey = ""; +} + +function getGoogleIssuer() { + return issuerOverride || REAL_GOOGLE_ISSUER; +} + +function getGoogleConfig() { + const google = dbOps.getSettings()?.integrations?.google || {}; + const enabled = google.enabled === true; + const clientId = String(google.clientId || "").trim(); + const clientSecret = String(google.clientSecret || "").trim(); + const redirectUri = String(google.redirectUri || "").trim(); + if (!enabled || !clientId || !clientSecret || !redirectUri) return null; + return { clientId, clientSecret, redirectUri }; +} + +export function isGoogleLoginEnabled() { + return !!getGoogleConfig(); +} + +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 !== 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 = [ + `${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("; ")); +} + +async function getDiscoveryConfig(config) { + const key = `${config.clientId}|${config.clientSecret}`; + if (discoveryConfig && discoveryKey === key) return discoveryConfig; + const issuerUrl = new URL(getGoogleIssuer()); + const discoveryOptions = + issuerUrl.protocol === "http:" ? { execute: [client.allowInsecureRequests] } : undefined; + discoveryConfig = await client.discovery( + issuerUrl, + config.clientId, + config.clientSecret, + client.ClientSecretBasic(config.clientSecret), + discoveryOptions, + ); + discoveryKey = key; + return discoveryConfig; +} + +function buildCallbackUrl(req, redirectUri) { + 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 startGoogleAuth(req, res, mode) { + const config = getGoogleConfig(); + if (!config) { + res.status(404).json({ error: "Google login is not enabled" }); + return; + } + + const oidc = await getDiscoveryConfig(config); + 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, + mode: mode.mode, + linkUserId: mode.linkUserId || null, + }); + + const parameters = { + redirect_uri: config.redirectUri, + scope: "openid email profile", + 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 handleGoogleCallback(req) { + const config = getGoogleConfig(); + if (!config) { + throw Object.assign(new Error("Google login 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("Google login session expired"), { status: 400 }); + } + + const oidc = await getDiscoveryConfig(config); + const tokens = await client.authorizationCodeGrant(oidc, buildCallbackUrl(req, config.redirectUri), { + pkceCodeVerifier: pending.codeVerifier, + expectedState: state, + expectedNonce: pending.nonce, + idTokenExpected: true, + }); + + const claims = tokens.claims() || {}; + const subject = String(claims.sub || "").trim(); + if (!subject) { + throw Object.assign(new Error("Google identity did not include a usable subject"), { + status: 400, + }); + } + const displayName = String(claims.email || claims.name || "").trim() || null; + + let result; + if (pending.mode === "link") { + const linkUser = userOps.getUserById(pending.linkUserId); + if (!linkUser) { + throw Object.assign(new Error("Linking user no longer exists"), { status: 400 }); + } + if (linkUser.status !== "active") { + throw Object.assign(new Error("This account has been suspended or disabled"), { + status: 403, + }); + } + const existingIdentity = userIdentityOps.findByProvider("google", "google", subject); + if (existingIdentity && existingIdentity.userId !== linkUser.id) { + throw Object.assign(new Error("This Google account is already linked to another user"), { + status: 409, + }); + } + if (!existingIdentity) { + userIdentityOps.link(linkUser.id, { + providerType: "google", + providerKey: "google", + subject, + displayName, + }); + } + result = { linked: true, user: toResolvedUser(linkUser) }; + } else { + const identity = userIdentityOps.findByProvider("google", "google", subject); + if (!identity) { + throw Object.assign( + new Error("This Google account isn't linked to an Aurral account yet. Sign in another way and link it in Settings."), + { status: 403 }, + ); + } + const user = userOps.getUserById(identity.userId); + if (!user) { + throw Object.assign(new Error("Linked Google identity has no matching user"), { + status: 500, + }); + } + if (user.status !== "active") { + throw Object.assign(new Error("This account has been suspended or disabled"), { + status: 403, + }); + } + result = { linked: false, user: toResolvedUser(user) }; + } + + const code = client.randomState(); + prunePendingExchanges(); + pendingExchanges.set(code, { + expiresAt: Date.now() + EXCHANGE_TTL_MS, + transactionId, + result, + }); + return { code, ...result }; +} + +export function exchangeGoogleCallback(code, req) { + 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("Google login session expired"), { status: 400 }); + } + pendingExchanges.delete(exchangeCode); + + if (pending.result.linked) { + return { linked: true, user: pending.result.user }; + } + const session = createSession( + pending.result.user.id, + req.ip || null, + req.headers["user-agent"] || null, + ); + return { + linked: false, + token: session.token, + expiresAt: session.expiresAt, + user: pending.result.user, + }; +} + +export function clearGoogleTransactionCookie(req, res) { + setTransactionCookie(req, res, "", 0); +} + +export function resetGoogleStateForTests() { + pendingLogins.clear(); + pendingExchanges.clear(); + discoveryConfig = null; + discoveryKey = ""; + issuerOverride = null; +} diff --git a/backend/services/inboxService.js b/backend/services/inboxService.js index 9ba40a68c..a1d9070e1 100644 --- a/backend/services/inboxService.js +++ b/backend/services/inboxService.js @@ -296,6 +296,7 @@ export async function refreshInboxForUser(userId, { req = null, zipCode = "", fo export async function refreshInboxForAllUsers() { for (const user of userOps.getAllUsers()) { + if (user.status !== "active") continue; await refreshInboxForUser(user.id); } } diff --git a/backend/services/oidcAuth.js b/backend/services/oidcAuth.js index dd766549e..1a417af0c 100644 --- a/backend/services/oidcAuth.js +++ b/backend/services/oidcAuth.js @@ -1,6 +1,8 @@ import * as client from "openid-client"; +import { db } from "../config/db-sqlite.js"; import { createSession } from "../config/session-helpers.js"; -import { ensureExternalUser } from "../middleware/auth.js"; +import { createSystemProvisionedUser, toResolvedUser } from "../middleware/auth.js"; +import { userOps, userIdentityOps } from "../db/helpers/index.js"; const STATE_TTL_MS = 10 * 60 * 1000; const EXCHANGE_TTL_MS = 60 * 1000; @@ -62,7 +64,8 @@ function getRequiredConfig() { 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; + const secretRequired = getTokenEndpointAuthMethod() !== "none"; + if (!issuer || !clientId || (secretRequired && !clientSecret) || !redirectUri) return null; return { issuer, clientId, clientSecret, redirectUri }; } @@ -99,6 +102,39 @@ function getGroupsClaim() { return String(process.env.OIDC_GROUPS_CLAIM || "").trim(); } +function getTokenEndpointAuthMethod() { + const method = String(process.env.OIDC_TOKEN_ENDPOINT_AUTH_METHOD || "client_secret_basic") + .trim() + .toLowerCase(); + return method || "client_secret_basic"; +} + +function buildClientAuthentication(method, clientSecret) { + switch (method) { + case "client_secret_post": + return client.ClientSecretPost(clientSecret); + case "none": + return client.None(); + case "client_secret_basic": + return client.ClientSecretBasic(clientSecret); + default: + throw new Error( + `Unsupported OIDC_TOKEN_ENDPOINT_AUTH_METHOD "${method}" (expected client_secret_basic, client_secret_post, or none)`, + ); + } +} + +function generateUniqueUsername(base) { + const trimmed = String(base || "").trim().toLowerCase(); + if (!trimmed) return trimmed; + if (!userOps.getUserByUsername(trimmed)) return trimmed; + for (let suffix = 2; suffix < 1000; suffix += 1) { + const candidate = `${trimmed}-${suffix}`; + if (!userOps.getUserByUsername(candidate)) return candidate; + } + throw new Error("Could not generate a unique username for OIDC provisioning"); +} + function normalizeGroups(value) { if (Array.isArray(value)) { return value.map((item) => String(item || "").trim().toLowerCase()).filter(Boolean); @@ -140,7 +176,8 @@ async function getDiscoveryConfig() { if (!config) { throw new Error("OIDC is not configured"); } - const key = `${config.issuer}|${config.clientId}|${config.clientSecret}|${config.redirectUri}`; + const authMethod = getTokenEndpointAuthMethod(); + const key = `${config.issuer}|${config.clientId}|${config.clientSecret}|${config.redirectUri}|${authMethod}`; if (discoveryConfig && discoveryKey === key) return { config, oidc: discoveryConfig }; const issuerUrl = new URL(config.issuer); const discoveryOptions = @@ -149,13 +186,114 @@ async function getDiscoveryConfig() { issuerUrl, config.clientId, config.clientSecret, - undefined, + buildClientAuthentication(authMethod, config.clientSecret), discoveryOptions, ); discoveryKey = key; return { config, oidc: discoveryConfig }; } +async function fetchEffectiveClaims(oidc, tokens, claims) { + const subject = claims.sub; + if (!subject || !tokens.access_token) return claims; + try { + const userInfo = await client.fetchUserInfo(oidc, tokens.access_token, subject); + return { ...claims, ...userInfo }; + } catch { + return claims; + } +} + +function toDisplayName(claims) { + return resolveOidcUsername(claims) || String(claims.email || "").trim() || null; +} + +function resolveOidcSessionUser(config, claims) { + const subject = String(claims.sub || "").trim(); + if (!subject) { + throw Object.assign(new Error("OIDC identity did not include a usable subject"), { + status: 400, + }); + } + + const existingIdentity = userIdentityOps.findByProvider("oidc", config.issuer, subject); + if (existingIdentity) { + const user = userOps.getUserById(existingIdentity.userId); + if (!user) { + throw Object.assign(new Error("Linked OIDC identity has no matching user"), { + status: 500, + }); + } + if (user.status !== "active") { + throw Object.assign(new Error("This account has been suspended or disabled"), { + status: 403, + }); + } + if (user.isProtected) { + return toResolvedUser(user); + } + const role = resolveOidcRole(resolveOidcUsername(claims) || user.username, claims); + if (role !== user.role || user.roleSource !== "oidc") { + userOps.updateUser(user.id, { role, roleSource: "oidc" }); + return toResolvedUser(userOps.getUserById(user.id)); + } + return toResolvedUser(user); + } + + const baseUsername = resolveOidcUsername(claims); + if (!baseUsername) { + throw Object.assign(new Error("OIDC identity did not include a usable username"), { + status: 400, + }); + } + + const role = resolveOidcRole(baseUsername, claims); + + const legacyMatch = userOps.getUserByUsername(baseUsername); + if ( + legacyMatch && + legacyMatch.allowIdentityAdoption && + legacyMatch.needsIdentityMigration && + legacyMatch.status === "active" && + !legacyMatch.isProtected && + userIdentityOps.countForUser(legacyMatch.id) === 0 + ) { + const adoptUser = db.transaction(() => { + userOps.updateUser(legacyMatch.id, { + role, + roleSource: "oidc", + needsIdentityMigration: false, + allowIdentityAdoption: false, + }); + userIdentityOps.link(legacyMatch.id, { + providerType: "oidc", + providerKey: config.issuer, + subject, + displayName: toDisplayName(claims), + }); + return userOps.getUserById(legacyMatch.id); + }); + return toResolvedUser(adoptUser()); + } + + const uniqueUsername = generateUniqueUsername(baseUsername); + const provisionUser = db.transaction(() => { + const created = createSystemProvisionedUser(uniqueUsername, role); + if (!created?.id || created.id < 0) { + throw Object.assign(new Error("Failed to provision OIDC user"), { status: 500 }); + } + userOps.updateUser(created.id, { roleSource: "oidc" }); + userIdentityOps.link(created.id, { + providerType: "oidc", + providerKey: config.issuer, + subject, + displayName: toDisplayName(claims), + }); + return userOps.getUserById(created.id); + }); + return toResolvedUser(provisionUser()); +} + function buildCallbackUrl(req) { const redirectUri = getRequiredConfig()?.redirectUri; if (!redirectUri) throw new Error("OIDC_REDIRECT_URI is required"); @@ -220,7 +358,7 @@ export async function handleOidcCallback(req) { throw Object.assign(new Error("OIDC login session expired"), { status: 400 }); } - const { oidc } = await getDiscoveryConfig(); + const { config, oidc } = await getDiscoveryConfig(); const tokens = await client.authorizationCodeGrant(oidc, buildCallbackUrl(req), { pkceCodeVerifier: pending.codeVerifier, expectedState: state, @@ -229,18 +367,7 @@ export async function handleOidcCallback(req) { }); const claims = 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 user = resolveOidcSessionUser(config, await fetchEffectiveClaims(oidc, tokens, claims)); const code = client.randomState(); prunePendingExchanges(); diff --git a/backend/services/plexLoginAuth.js b/backend/services/plexLoginAuth.js new file mode 100644 index 000000000..b6025c429 --- /dev/null +++ b/backend/services/plexLoginAuth.js @@ -0,0 +1,153 @@ +import crypto from "crypto"; +import { createSession } from "../config/session-helpers.js"; +import { userOps, userIdentityOps } from "../db/helpers/index.js"; +import { isPlexLoginEnabled } from "../routes/users/plexLinkHandlers.js"; + +const STATE_TTL_MS = 10 * 60 * 1000; +const TRANSACTION_COOKIE = "aurral_plex_login_transaction"; +const pendingLogins = new Map(); + +function prunePendingLogins(now = Date.now()) { + for (const [transactionId, entry] of pendingLogins) { + if (!entry || entry.expiresAt <= now) pendingLogins.delete(transactionId); + } +} + +function getTransactionCookie(req) { + const cookies = String(req.headers?.cookie || "").split(";"); + for (const cookie of cookies) { + const [name, ...parts] = cookie.trim().split("="); + if (name !== 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 = [ + `${TRANSACTION_COOKIE}=${encodeURIComponent(value)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + ]; + if (secure) attributes.push("Secure"); + if (maxAge != null) attributes.push(`Max-Age=${maxAge}`); + res.append("Set-Cookie", attributes.join("; ")); +} + +function clearTransactionCookie(req, res) { + setTransactionCookie(req, res, "", 0); +} + +function isSafeForwardUrl(forwardUrl) { + const value = String(forwardUrl || ""); + if (!value.startsWith("/")) return false; + if (value.startsWith("//")) return false; + if (/[\\\u0000-\u001f\u007f]/.test(value)) return false; + try { + const base = "https://aurral.invalid"; + return new URL(value, base).origin === base; + } catch { + return false; + } +} + +export async function startPlexLogin(req, res) { + if (!isPlexLoginEnabled()) { + res.status(404).json({ error: "Plex login is not enabled" }); + return; + } + const forwardUrl = req.body?.forwardUrl; + if (forwardUrl != null && !isSafeForwardUrl(forwardUrl)) { + res.status(400).json({ error: "Invalid forward URL" }); + return; + } + + const { PlexClient } = await import("./plex.js"); + const clientId = PlexClient.generateClientId(); + const { id: pinId, code } = await PlexClient.generatePin(clientId); + const transactionId = crypto.randomBytes(24).toString("hex"); + + prunePendingLogins(); + pendingLogins.set(transactionId, { + pinId, + code, + clientId, + expiresAt: Date.now() + STATE_TTL_MS, + }); + + setTransactionCookie(req, res, transactionId, Math.floor(STATE_TTL_MS / 1000)); + res.json({ authUrl: PlexClient.buildAuthUrl(clientId, code, forwardUrl) }); +} + +export async function completePlexLogin(req, res) { + if (!isPlexLoginEnabled()) { + res.status(404).json({ error: "Plex login is not enabled" }); + return; + } + + const transactionId = getTransactionCookie(req); + prunePendingLogins(); + const pending = transactionId ? pendingLogins.get(transactionId) : null; + if (!pending || pending.expiresAt <= Date.now()) { + res.status(400).json({ error: "Plex login session expired" }); + return; + } + + const { PlexClient } = await import("./plex.js"); + const token = await PlexClient.checkPin(pending.pinId, pending.code, pending.clientId); + if (!token) { + res.json({ pending: true }); + return; + } + + pendingLogins.delete(transactionId); + clearTransactionCookie(req, res); + + const identity = await PlexClient.validateToken(token, pending.clientId); + const subject = identity?.id != null ? String(identity.id) : null; + if (!subject) { + res.status(400).json({ error: "Could not verify the Plex account" }); + return; + } + + const linked = userIdentityOps.findByProvider("plex", "plex", subject); + if (!linked) { + res.status(403).json({ + error: "not_linked", + message: + "This Plex account isn't linked to an Aurral account yet. Sign in another way and link it in Settings.", + }); + return; + } + const user = userOps.getUserById(linked.userId); + if (!user) { + res.status(500).json({ error: "Linked Plex identity has no matching user" }); + return; + } + if (user.status !== "active") { + res.status(403).json({ error: "This account has been suspended or disabled" }); + return; + } + + 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, + }, + }); +} + +export function resetPlexLoginStateForTests() { + pendingLogins.clear(); +} diff --git a/backend/services/weeklyFlow/weeklyFlowScheduler.js b/backend/services/weeklyFlow/weeklyFlowScheduler.js index 2ba122319..c3d540c59 100644 --- a/backend/services/weeklyFlow/weeklyFlowScheduler.js +++ b/backend/services/weeklyFlow/weeklyFlowScheduler.js @@ -3,11 +3,19 @@ import { weeklyFlowWorker } from "./weeklyFlowWorker.js"; import { flowPlaylistConfig } from "./weeklyFlowPlaylistConfig.js"; import { isAnyDownloadSourceConfigured } from "../downloadSourceService.js"; import { weeklyFlowOperationQueue } from "./weeklyFlowOperationQueue.js"; +import { userOps } from "../../db/helpers/index.js"; import { createWeeklyFlowOperationToken, markLatestWeeklyFlowOperationToken, } from "./weeklyFlowOperations.js"; +function isFlowOwnerActive(flow) { + const ownerUserId = Number(flow?.ownerUserId); + if (!Number.isFinite(ownerUserId)) return true; + const owner = userOps.getUserById(ownerUserId); + return !owner || owner.status === "active"; +} + export async function runScheduledRefresh() { if (!isAnyDownloadSourceConfigured()) return; @@ -15,6 +23,7 @@ export async function runScheduledRefresh() { if (due.length === 0) return; for (const flow of due) { + if (!isFlowOwnerActive(flow)) continue; try { const token = createWeeklyFlowOperationToken(); const tokenScope = `flow:${flow.id}:scheduled`; diff --git a/docs/src/content/docs/admin/environment.mdx b/docs/src/content/docs/admin/environment.mdx index 728cf5f52..988a95941 100644 --- a/docs/src/content/docs/admin/environment.mdx +++ b/docs/src/content/docs/admin/environment.mdx @@ -38,7 +38,8 @@ Use the web UI for most settings. Use these variables for Docker deployment sett | `OIDC_ENABLED` | Enable native OpenID Connect login. | | `OIDC_ISSUER` | Identity provider issuer URL used for OIDC discovery. | | `OIDC_CLIENT_ID` | OIDC client ID. | -| `OIDC_CLIENT_SECRET` | OIDC client secret. | +| `OIDC_CLIENT_SECRET` | OIDC client secret. Not required when `OIDC_TOKEN_ENDPOINT_AUTH_METHOD` is `none`. | +| `OIDC_TOKEN_ENDPOINT_AUTH_METHOD` | How Aurral authenticates to the token endpoint: `client_secret_basic` (default), `client_secret_post`, or `none`. Must match the method registered with your IdP. | | `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. | diff --git a/docs/src/content/docs/admin/users.mdx b/docs/src/content/docs/admin/users.mdx index c6d1386ee..1f86e4e8d 100644 --- a/docs/src/content/docs/admin/users.mdx +++ b/docs/src/content/docs/admin/users.mdx @@ -49,6 +49,24 @@ Set `OIDC_LOGOUT_URL` if you want **Log out** to end the identity-provider sessi Native OIDC and reverse-proxy auth can both stay configured. Use one as the primary browser login path for a given deployment. +### Claiming existing accounts after upgrading + +Aurral now identifies an OIDC user by the issuer and subject in their token rather than by username, so accounts created before this change have no linked identity yet. Until one is linked, an SSO sign-in does not recognise the old account and creates a separate new one instead. + +Because a matching username is not proof of ownership, an admin has to approve each account: + +1. In **Settings > Users**, accounts that predate identity linking show a **no SSO identity** badge. +2. Open **Manage** on an account you know belongs to an SSO user and turn on **Claim by SSO sign-in**. The badge changes to **awaiting SSO claim**. +3. That user signs in with SSO once. Their identity is linked to the existing account, keeping its flows, history, settings, and permissions. + +The approval is used up by that first sign-in, and the badge clears. + +Approve accounts before telling users the upgrade is live. If someone signs in with SSO before their account is approved, their identity is bound to the new empty account and later approval has no effect. + +To recover, delete the new duplicate account (Aurral appends the first available numeric suffix, for example `-2`, `-3`, and so on, to the username), approve the original, and have the user sign in again. + +The protected recovery admin can never be claimed this way. + ## Reverse-proxy auth Set `AUTH_PROXY_ENABLED=true` to use reverse-proxy authentication. Use `AUTH_PROXY_HEADER` if the proxy uses a custom header. diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 8531992e4..9ea86ca67 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -40,7 +40,7 @@ export const AuthProvider = ({ children }) => { setIsAuthenticated(false); setUser(null); setIsLoading(false); - return; + return true; } const isRequired = bootstrap.authRequired; @@ -50,7 +50,7 @@ export const AuthProvider = ({ children }) => { setUser(bootstrap.user); setIsAuthenticated(true); setIsLoading(false); - return; + return true; } if (!isRequired) { @@ -70,7 +70,7 @@ export const AuthProvider = ({ children }) => { ); setIsAuthenticated(true); setIsLoading(false); - return; + return true; } const { token } = getStoredAuth(); @@ -88,12 +88,14 @@ export const AuthProvider = ({ children }) => { setUser(null); setIsAuthenticated(false); } + return true; } catch { if (shouldResetAuthAfterBootstrapFailure(authResolvedRef.current)) { setBootstrap(null); setUser(null); setIsAuthenticated(false); } + return false; } finally { setIsLoading(false); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 81e0833a4..a0a4d3969 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -7967,6 +7967,24 @@ textarea { background: color-mix(in srgb, var(--aurral-text-muted) 35%, transparent); } +.login-local-toggle { + display: block; + width: 100%; + margin-top: 0.5rem; + padding: 0; + border: none; + background: none; + color: var(--aurral-text-muted); + font-size: 0.8125rem; + text-align: center; + text-decoration: underline; + cursor: pointer; +} + +.login-local-toggle:hover { + color: var(--aurral-text); +} + .login-submit { min-height: 2.75rem; border-color: var(--aurral-border-strong); @@ -11992,6 +12010,23 @@ textarea { gap: 0.5rem; } +.connected-account-list { + display: grid; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.connected-account-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0.75rem; + border-radius: var(--aurral-radius-sm); + background: var(--aurral-surface-hover); + font-size: 0.875rem; +} + .settings-page__section-intro { margin-bottom: 0.5rem; } diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 3d5202c86..50921cd7e 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,14 +2,29 @@ import { useState } from "react"; import { useAuth } from "../contexts/AuthContext"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { getAppBasePath } from "../utils/basePath.js"; +import { clearAuthStorage, setStoredAuth } from "../utils/api/core.js"; +import { startPlexLoginPin, completePlexLogin } from "../utils/api/endpoints/auth.js"; + +const buildApiUrl = (path) => { + const basePath = getAppBasePath(); + const prefix = basePath === "/" ? "" : basePath.replace(/\/$/, ""); + return `${prefix}${path}`; +}; const Login = () => { useDocumentTitle("Sign in"); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); - const { login, bootstrap } = useAuth(); + const [showLocalForm, setShowLocalForm] = useState(false); + const [plexConnecting, setPlexConnecting] = useState(false); + const { login, refreshAuth, bootstrap } = useAuth(); const oidcEnabled = !!bootstrap?.oidcEnabled; + const googleEnabled = !!bootstrap?.googleLoginEnabled; + const plexEnabled = !!bootstrap?.plexLoginEnabled; + const ssoOnly = !!bootstrap?.ssoOnly; + const hasSsoOption = oidcEnabled || googleEnabled || plexEnabled; + const localFormVisible = !ssoOnly || !hasSsoOption || showLocalForm; const handleSubmit = async (e) => { e.preventDefault(); @@ -23,9 +38,66 @@ const Login = () => { }; const handleOidcLogin = () => { - const basePath = getAppBasePath(); - const prefix = basePath === "/" ? "" : basePath.replace(/\/$/, ""); - window.location.assign(`${prefix}/api/auth/oidc/login`); + window.location.assign(buildApiUrl("/api/auth/oidc/login")); + }; + + const handleGoogleLogin = () => { + window.location.assign(buildApiUrl("/api/auth/google/login")); + }; + + const handlePlexLogin = async () => { + setError(""); + const popup = window.open("about:blank", "plex-login", "width=600,height=700"); + if (!popup) { + setError("Your browser blocked the Plex sign-in popup. Please allow popups for this site and try again."); + return; + } + setPlexConnecting(true); + try { + let pin; + try { + pin = await startPlexLoginPin(); + } catch (err) { + if (popup && !popup.closed) popup.close(); + setError( + err.response?.data?.message || err.response?.data?.error || "Failed to start Plex sign-in", + ); + return; + } + popup.location.href = pin.authUrl; + const deadline = Date.now() + 3 * 60 * 1000; + let result = null; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + try { + const res = await completePlexLogin(); + if (res.pending) continue; + result = res; + break; + } catch (err) { + if (popup && !popup.closed) popup.close(); + setError( + err.response?.data?.message || + err.response?.data?.error || + "Plex sign-in failed", + ); + return; + } + } + if (popup && !popup.closed) popup.close(); + if (!result?.token) { + setError("Plex sign-in timed out. Please try again."); + return; + } + setStoredAuth({ token: result.token }); + const refreshed = await refreshAuth(); + if (!refreshed) { + clearAuthStorage(); + setError("Signed in with Plex, but couldn't load your account. Please try again."); + } + } finally { + setPlexConnecting(false); + } }; return ( @@ -37,63 +109,99 @@ const Login = () => {

Enter your credentials to access Aurral

- {oidcEnabled && ( + {hasSsoOption && (
- -
- or -
+ {oidcEnabled && ( + + )} + {googleEnabled && ( + + )} + {plexEnabled && ( + + )} + {localFormVisible && ( +
+ or +
+ )}
)} -
-
-
- - setUsername(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> + {localFormVisible ? ( + +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
-
- {error &&

{error}

} + {error &&

{error}

} - - + + + ) : ( + <> + {error &&

{error}

} + + + )}
); diff --git a/frontend/src/pages/Settings/SettingsPage.jsx b/frontend/src/pages/Settings/SettingsPage.jsx index abb4ca321..b0738e0ff 100644 --- a/frontend/src/pages/Settings/SettingsPage.jsx +++ b/frontend/src/pages/Settings/SettingsPage.jsx @@ -241,6 +241,10 @@ function SettingsPage() { setEditCurrentPassword={users.setEditCurrentPassword} editPermissions={users.editPermissions} setEditPermissions={users.setEditPermissions} + editStatus={users.editStatus} + setEditStatus={users.setEditStatus} + editAllowAdoption={users.editAllowAdoption} + setEditAllowAdoption={users.setEditAllowAdoption} savingEdit={users.savingEdit} setSavingEdit={users.setSavingEdit} changePwCurrent={users.changePwCurrent} diff --git a/frontend/src/pages/Settings/components/ConnectedAccountsSection.jsx b/frontend/src/pages/Settings/components/ConnectedAccountsSection.jsx new file mode 100644 index 000000000..805a615e8 --- /dev/null +++ b/frontend/src/pages/Settings/components/ConnectedAccountsSection.jsx @@ -0,0 +1,137 @@ +import { useEffect, useState } from "react"; +import { getMyIdentities, unlinkMyIdentity } from "../../../utils/api/endpoints/auth.js"; +import { isReauthRequiredError, promptReauth } from "../../../utils/reauth.js"; +import { useAuth } from "../../../contexts/AuthContext"; +import { getAppBasePath } from "../../../utils/basePath.js"; + +const PROVIDER_LABELS = { + oidc: "Single sign-on", + google: "Google", + plex: "Plex", +}; + +const buildApiUrl = (path) => { + const basePath = getAppBasePath(); + const prefix = basePath === "/" ? "" : basePath.replace(/\/$/, ""); + return `${prefix}${path}`; +}; + +export function ConnectedAccountsSection({ showSuccess, showError, className = "" }) { + const { bootstrap } = useAuth(); + const [identities, setIdentities] = useState([]); + const [hasLocalPassword, setHasLocalPassword] = useState(true); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [unlinkingId, setUnlinkingId] = useState(null); + + const load = () => { + setLoading(true); + return getMyIdentities() + .then((data) => { + setIdentities(data?.identities || []); + setHasLocalPassword(data?.hasLocalPassword !== false); + setLoadError(false); + }) + .catch(() => setLoadError(true)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + load(); + }, []); + + const handleUnlink = async (identity) => { + setUnlinkingId(identity.id); + try { + await unlinkMyIdentity(identity.id); + showSuccess?.(`Disconnected ${PROVIDER_LABELS[identity.providerType] || identity.providerType}.`); + await load(); + } catch (err) { + if (isReauthRequiredError(err)) { + const shouldRetry = await promptReauth(); + if (shouldRetry) { + try { + await unlinkMyIdentity(identity.id); + showSuccess?.( + `Disconnected ${PROVIDER_LABELS[identity.providerType] || identity.providerType}.`, + ); + await load(); + } catch (retryErr) { + showError?.(retryErr.response?.data?.message || "Failed to disconnect"); + } + } + } else { + showError?.( + err.response?.data?.message || err.response?.data?.error || "Failed to disconnect", + ); + } + } finally { + setUnlinkingId(null); + } + }; + + const handleConnectGoogle = async () => { + const shouldProceed = await promptReauth(); + if (!shouldProceed) return; + window.location.assign(buildApiUrl("/api/auth/google/link")); + }; + + if (loading) return null; + + const hasGoogle = identities.some((identity) => identity.providerType === "google"); + const googleAvailable = !!bootstrap?.googleLoginEnabled; + + return ( +
+
+

Connected Accounts

+

+ Other ways you can sign in to Aurral. You can always sign in with your local password + {hasLocalPassword ? "" : " once you set one below"}. +

+
+ + {loadError ? ( +

+ Failed to load your connected accounts.{" "} + +

+ ) : identities.length === 0 ? ( +

No other sign-in methods connected.

+ ) : ( +
+ {identities.map((identity) => ( +
+ + {PROVIDER_LABELS[identity.providerType] || identity.providerType} + {identity.displayName ? ` — ${identity.displayName}` : ""} + + +
+ ))} +
+ )} + + {!loadError && googleAvailable && !hasGoogle && ( + + )} + {!loadError && !hasLocalPassword && ( +

+ This account has no local password set. Set one below so you always have a way to sign + in, even if a connected provider becomes unavailable. +

+ )} +
+ ); +} diff --git a/frontend/src/pages/Settings/components/PlexSelfLinkSection.jsx b/frontend/src/pages/Settings/components/PlexSelfLinkSection.jsx index c40fe43f0..ec8e52ed8 100644 --- a/frontend/src/pages/Settings/components/PlexSelfLinkSection.jsx +++ b/frontend/src/pages/Settings/components/PlexSelfLinkSection.jsx @@ -5,6 +5,7 @@ import { completeMyPlexLink, disconnectMyPlex, } from "../../../utils/api/endpoints/auth.js"; +import { isReauthRequiredError, promptReauth } from "../../../utils/reauth.js"; const EMPTY_STATUS = { connected: false, @@ -51,6 +52,7 @@ export function PlexSelfLinkSection({ showSuccess, showError, className = "" }) const popup = window.open(authUrl, "plex-self-link", "width=600,height=700"); const deadline = Date.now() + 3 * 60 * 1000; let linked = null; + let reauthPrompted = false; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)); try { @@ -58,7 +60,18 @@ export function PlexSelfLinkSection({ showSuccess, showError, className = "" }) if (res.pending) continue; linked = res; break; - } catch {} + } catch (pollErr) { + if (isReauthRequiredError(pollErr) && !reauthPrompted) { + reauthPrompted = true; + const shouldRetry = await promptReauth(); + if (shouldRetry) continue; + } + if (popup && !popup.closed) popup.close(); + const message = + pollErr.response?.data?.message || pollErr.response?.data?.error || pollErr.message; + showError?.(`Plex sign-in failed: ${message}`); + return; + } } if (popup && !popup.closed) popup.close(); if (!linked) { @@ -93,7 +106,24 @@ export function PlexSelfLinkSection({ showSuccess, showError, className = "" }) })); showSuccess?.("Disconnected your Plex account."); } catch (err) { - showError?.(err.response?.data?.message || "Failed to disconnect Plex"); + if (isReauthRequiredError(err)) { + const shouldRetry = await promptReauth(); + if (shouldRetry) { + try { + await disconnectMyPlex(); + setStatus((prev) => ({ + ...EMPTY_STATUS, + globalAccount: prev.globalAccount, + isGlobalAccountOwner: prev.isGlobalAccountOwner, + })); + showSuccess?.("Disconnected your Plex account."); + } catch (retryErr) { + showError?.(retryErr.response?.data?.message || "Failed to disconnect Plex"); + } + } + } else { + showError?.(err.response?.data?.message || "Failed to disconnect Plex"); + } } finally { setDisconnecting(false); } diff --git a/frontend/src/pages/Settings/components/SettingsAccountTab.jsx b/frontend/src/pages/Settings/components/SettingsAccountTab.jsx index 89b10e77a..d125445f8 100644 --- a/frontend/src/pages/Settings/components/SettingsAccountTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsAccountTab.jsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { resetDiscoveryFeedback } from "../../../utils/api/endpoints/discovery.js"; import { getThemePreference, @@ -6,6 +6,7 @@ import { } from "../../../utils/theme.js"; import { SettingsInput, SettingsSelect } from "./SettingsField"; import { PlexSelfLinkSection } from "./PlexSelfLinkSection"; +import { ConnectedAccountsSection } from "./ConnectedAccountsSection"; import { Link } from "react-router-dom"; import { RotateCcw } from "lucide-react"; @@ -33,6 +34,21 @@ export function SettingsAccountTab({ const [resettingTastes, setResettingTastes] = useState(false); const [theme, setTheme] = useState(getThemePreference); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("connected") === "google") { + showSuccess?.("Connected your Google account."); + params.delete("connected"); + const query = params.toString(); + window.history.replaceState( + null, + "", + `${window.location.pathname}${query ? `?${query}` : ""}`, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleResetDiscoveryTastes = async () => { if (resettingTastes) return; const confirmed = window.confirm( @@ -186,6 +202,12 @@ export function SettingsAccountTab({ + + + updateSettings({ + ...settings, + integrations: { + ...settings.integrations, + google: { ...google, ...patch }, + }, + }); + const updateInbox = (patch) => updateSettings({ ...settings, @@ -204,6 +218,13 @@ export function SettingsConnectTab({ meta={`${ticketmaster.searchRadiusMiles ?? 250} mi radius`} onClick={() => setActiveModal("ticketmaster")} /> + setActiveModal("google")} + /> @@ -640,6 +661,65 @@ export function SettingsConnectTab({ )} + {activeModal === "google" && ( + setActiveModal(null)}> + + Let users who already linked their Google account (from their profile) sign in with + it. Google can never create new Aurral accounts or change anyone's role — it's + only usable once an authenticated user has explicitly connected it. + + + Create an OAuth client at{" "} + + Google Cloud Console + {" "} + and register the redirect URI below as an authorized redirect URI. + + + updateGoogle({ enabled: e.target.checked })} + /> + + updateGoogle({ clientId: e.target.value })} + /> + + + updateGoogle({ clientSecret: e.target.value })} + /> + + + updateGoogle({ redirectUri: e.target.value })} + /> + + + + )} + ); } diff --git a/frontend/src/pages/Settings/components/SettingsPlaybackSection.jsx b/frontend/src/pages/Settings/components/SettingsPlaybackSection.jsx index b0a081ed6..19eaf4244 100644 --- a/frontend/src/pages/Settings/components/SettingsPlaybackSection.jsx +++ b/frontend/src/pages/Settings/components/SettingsPlaybackSection.jsx @@ -601,6 +601,21 @@ export function SettingsPlaybackSection({ + + + updatePlex({ loginEnabled: event.target.checked })} + aria-label="Allow signing in to Aurral with Plex" + /> + + + {plex.token && ( diff --git a/frontend/src/pages/Settings/components/SettingsUsersTab.jsx b/frontend/src/pages/Settings/components/SettingsUsersTab.jsx index cc73769be..5cdfec40e 100644 --- a/frontend/src/pages/Settings/components/SettingsUsersTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsUsersTab.jsx @@ -1,7 +1,7 @@ import { loginApi } from "../../../utils/api/endpoints/auth.js"; import { setStoredAuth } from "../../../utils/api/core.js"; import PillToggle from "../../../components/PillToggle"; -import { SettingsInput } from "./SettingsField"; +import { SettingsInput, SettingsSelect } from "./SettingsField"; import { SettingsArrFieldSet, SettingsArrFormGroup } from "./arr/SettingsArrLayout"; import { createPortal } from "react-dom"; @@ -10,6 +10,8 @@ import { GRANULAR_PERMISSIONS, granularPerms } from "../constants"; import { useModalDialog } from "../../../hooks/useModalDialog.js"; import { AdminPlexLinkField } from "./AdminPlexLinkField"; import { PlexSelfLinkSection } from "./PlexSelfLinkSection"; +import { isReauthRequiredError, promptReauth } from "../../../utils/reauth.js"; +import { useAuth } from "../../../contexts/AuthContext"; function getLocalBypassStatus(status) { if (!status) { return { @@ -152,6 +154,10 @@ export function SettingsUsersTab({ setEditCurrentPassword, editPermissions, setEditPermissions, + editStatus, + setEditStatus, + editAllowAdoption, + setEditAllowAdoption, savingEdit, setSavingEdit, changePwCurrent, @@ -179,6 +185,8 @@ export function SettingsUsersTab({ showSuccess, showError, }) { + const { bootstrap } = useAuth(); + const ssoEnabled = !!bootstrap?.oidcEnabled; const isSelfEdit = editUser && editUser.id === authUser?.id; const localBypassStatus = getLocalBypassStatus(health?.localNetworkBypass); const localBypassEnabled = settings?.security?.localNetworkBypass?.enabled === true; @@ -218,7 +226,7 @@ export function SettingsUsersTab({ return; } setChangingPassword(true); - try { + const applyPasswordChange = async () => { await changeMyPassword(changePwCurrent, changePwNew); const result = await loginApi(authUser?.username, changePwNew); if (result?.token) { @@ -228,8 +236,23 @@ export function SettingsUsersTab({ setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm(""); + }; + try { + await applyPasswordChange(); } catch (err) { - showError(err.response?.data?.error || err.message || "Failed to change password"); + if (isReauthRequiredError(err) && (await promptReauth())) { + try { + await applyPasswordChange(); + } catch (retryErr) { + showError( + retryErr.response?.data?.error || + retryErr.message || + "Failed to change password", + ); + } + } else if (!isReauthRequiredError(err)) { + showError(err.response?.data?.error || err.message || "Failed to change password"); + } } finally { setChangingPassword(false); } @@ -318,6 +341,39 @@ export function SettingsUsersTab({ + + +
+ {settings?.security?.ssoOnly === true ? "Enabled" : "Disabled"} + { + const previousSettings = settings; + const nextSettings = { + ...settings, + security: { + ...(settings.security || {}), + ssoOnly: event.target.checked, + }, + }; + updateSettings(nextSettings); + try { + await handleSaveSettings(null, nextSettings); + } catch (err) { + updateSettings(previousSettings); + showError(err.response?.data?.message || "Failed to save sign-in mode"); + } + }} + aria-label="SSO-only sign-in mode" + /> +
+
+
+ Username Role + Status Listening history Plex @@ -343,16 +400,34 @@ export function SettingsUsersTab({ {loadingUsers ? ( - Loading users… + Loading users… ) : usersList.length === 0 ? ( - No users configured. + No users configured. ) : ( usersList.map((user) => ( - {user.username} + + + {user.username} + {ssoEnabled && user.needsIdentityMigration && !user.isProtected ? ( + + {user.allowIdentityAdoption ? "awaiting SSO claim" : "no SSO identity"} + + ) : null} + + + + {user.status && user.status !== "active" ? ( + {user.status} + ) : ( + active + )} + {formatListenHistory(user)} @@ -378,6 +460,8 @@ export function SettingsUsersTab({ setEditUser(user); setEditPassword(""); setEditCurrentPassword(""); + setEditStatus(user.status || "active"); + setEditAllowAdoption(!!user.allowIdentityAdoption); setEditPermissions( user.permissions ? { @@ -656,19 +740,32 @@ export function SettingsUsersTab({ return; } setSavingEdit(true); - try { - await updateUser(editUser.id, { + const applyEdit = () => + updateUser(editUser.id, { ...(editPassword ? { password: editPassword } : {}), permissions: editPermissions, + status: editStatus, + ...(ssoEnabled && editUser.needsIdentityMigration && !editUser.isProtected + ? { allowIdentityAdoption: editAllowAdoption } + : {}), }); + try { + try { + await applyEdit(); + } catch (err) { + if (!isReauthRequiredError(err) || !(await promptReauth())) throw err; + await applyEdit(); + } showSuccess("User updated"); setEditUser(null); await refreshUsers(); await refreshSettingsData(); } catch (err) { - showError( - err.response?.data?.error || err.message || "Failed to update", - ); + if (!isReauthRequiredError(err)) { + showError( + err.response?.data?.error || err.message || "Failed to update", + ); + } } finally { setSavingEdit(false); } @@ -727,6 +824,43 @@ export function SettingsUsersTab({ onChange={setEditPermissions} /> + + setEditStatus(event.target.value)} + > + + + + + + {ssoEnabled && editUser.needsIdentityMigration && !editUser.isProtected ? ( + +
+ {editAllowAdoption ? "Allowed" : "Not allowed"} + + setEditAllowAdoption(event.target.checked) + } + aria-label="Allow the next matching SSO sign-in to claim this account" + /> +
+
+ ) : null} { @@ -17,7 +17,8 @@ const consumeSsoParams = () => { const params = readHashParams(); const code = params.get("code"); const error = params.get("error"); - consumedSsoParams = { code, error }; + const provider = params.get("provider") || "oidc"; + consumedSsoParams = { code, error, provider }; if (code || error) { window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}`); } @@ -32,7 +33,7 @@ const SsoComplete = () => { useEffect(() => { let cancelled = false; - const { code, error: hashError } = consumeSsoParams(); + const { code, error: hashError, provider } = consumeSsoParams(); if (hashError) { setError(hashError); @@ -44,15 +45,26 @@ const SsoComplete = () => { return undefined; } - exchangeOidcCode(code) - .then(({ token }) => { - if (!token) throw new Error("Missing SSO session token"); - setStoredAuth({ token }); + const exchange = provider === "google" ? exchangeGoogleCode : exchangeOidcCode; + + exchange(code) + .then((result) => { + if (result?.linked) { + if (!cancelled) { + navigate("/settings/account?connected=google", { replace: true }); + } + return null; + } + if (!result?.token) throw new Error("Missing SSO session token"); + setStoredAuth({ token: result.token }); return refreshAuth(); }) - .then(() => { - if (!cancelled) { + .then((refreshed) => { + if (cancelled || refreshed === null) return; + if (refreshed) { navigate("/", { replace: true }); + } else { + setError("Signed in, but couldn't load your account. Please try again."); } }) .catch(() => { diff --git a/frontend/src/utils/api/endpoints/auth.js b/frontend/src/utils/api/endpoints/auth.js index d88484f6c..82da9de47 100644 --- a/frontend/src/utils/api/endpoints/auth.js +++ b/frontend/src/utils/api/endpoints/auth.js @@ -42,6 +42,14 @@ export const loginApi = async (username, password) => { export const exchangeOidcCode = (code) => postData("/auth/oidc/exchange", { code }); +export const exchangeGoogleCode = (code) => postData("/auth/google/exchange", { code }); + +export const reauthApi = (currentPassword) => postData("/auth/reauth", { currentPassword }); + +export const startPlexLoginPin = (forwardUrl) => postData("/auth/plex/login/pin", { forwardUrl }); + +export const completePlexLogin = () => postData("/auth/plex/login/complete"); + export const logoutApi = async () => { const result = await postData("/auth/logout"); invalidateBootstrapCache(); @@ -136,3 +144,9 @@ export const linkManagedPlexUser = (userId, plexUserId, { plexUsername, plexUuid export const adminUnlinkPlex = async (userId) => { await deleteData(`/users/${userId}/plex-link`); }; + +export const getMyIdentities = () => getData("/users/me/identities"); + +export const unlinkMyIdentity = async (identityId) => { + await deleteData(`/users/me/identities/${identityId}`); +}; diff --git a/frontend/src/utils/reauth.js b/frontend/src/utils/reauth.js new file mode 100644 index 000000000..9bbceb171 --- /dev/null +++ b/frontend/src/utils/reauth.js @@ -0,0 +1,21 @@ +import { reauthApi } from "./api/endpoints/auth.js"; + +export const isReauthRequiredError = (err) => + err?.response?.status === 401 && err?.response?.data?.error === "reauth_required"; + +export async function promptReauth() { + const password = window.prompt("Please re-enter your password to continue:"); + if (!password) return false; + try { + await reauthApi(password); + return true; + } catch (err) { + window.alert( + err?.response?.data?.error === "no_local_password" + ? err.response.data.message || + "This account has no local password. Sign out and back in to continue." + : "That password wasn't correct.", + ); + return false; + } +}