diff --git a/apps/device-manager/server/device-manager-auth.mjs b/apps/device-manager/server/device-manager-auth.mjs index 3b46aa2..e68cf63 100644 --- a/apps/device-manager/server/device-manager-auth.mjs +++ b/apps/device-manager/server/device-manager-auth.mjs @@ -8,6 +8,7 @@ export function createDeviceManagerAuth({ env = process.env, fetchImpl = fetch, now = Date.now, + internalToken: providedInternalToken, } = {}) { const authRequired = booleanValue( env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED, @@ -17,7 +18,9 @@ export function createDeviceManagerAuth({ const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173"); const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl); const internalToken = textValue( - env.NODEDC_INTERNAL_ACCESS_TOKEN || env.NODEDC_PLATFORM_SERVICE_TOKEN, + providedInternalToken + || env.NODEDC_INTERNAL_ACCESS_TOKEN + || env.NODEDC_PLATFORM_SERVICE_TOKEN, "", ); const sessionCookie = textValue( @@ -66,6 +69,7 @@ export function createDeviceManagerAuth({ sessions.set(id, { id, user: handoff.user, + access: handoff.access, launcherSessionId: handoff.launcherSessionId, expiresAt: createdAt + sessionTtlMs, validatedAt: createdAt, @@ -116,6 +120,7 @@ export function createDeviceManagerAuth({ } createSession(response, { user: result.body.user, + access: result.body.access, launcherSessionId: result.body.launcherSessionId ?? null, }); return redirect(response, nextPath); @@ -156,6 +161,7 @@ export function createDeviceManagerAuth({ const { response: upstream, body } = await session.validationInFlight; if (upstream.ok && body?.ok === true && body.active === true) { session.user = body.user || session.user; + session.access = body.access; session.validatedAt = now(); return attachSession(request, session); } @@ -199,7 +205,7 @@ export function createDeviceManagerAuth({ }); return true; } - const access = resolveAccess(session.user); + const access = resolveAccess(session.user, session.access, { allowLegacy: !authRequired }); if (!access.allowed) { sendJson(response, 403, { ok: false, @@ -222,7 +228,9 @@ export function createDeviceManagerAuth({ function currentContext(request) { const user = request.nodedcDeviceManagerSession?.user; - const access = request.nodedcDeviceManagerAccess ?? resolveAccess(user); + const trustedAccess = request.nodedcDeviceManagerSession?.access; + const access = request.nodedcDeviceManagerAccess + ?? resolveAccess(user, trustedAccess, { allowLegacy: !authRequired }); if (!user || !access.allowed) return null; const id = cleanOpaque(user.id || user.subject || user.sub); if (!id) return null; @@ -232,9 +240,6 @@ export function createDeviceManagerAuth({ .slice(0, 240); const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim(); const userRef = `user:${id}`; - const ownerScopes = ["admin", "owner"].includes(access.hubRole) - ? [{ scopeKind: "personal", ownerRef: userRef, displayName }] - : []; return { user: { id, @@ -247,7 +252,7 @@ export function createDeviceManagerAuth({ userRef, hubRole: access.hubRole, groupRefs: access.groups.map((group) => `group:${group}`), - ownerScopes, + ownerScopes: access.ownerScopes, }, profileUrl: new URL("/profile", launcherBaseUrl).toString(), }; @@ -266,6 +271,7 @@ export function createDeviceManagerAuth({ return { authRequired, + internalAccessConfigured: Boolean(internalToken), serviceSlug, authorize, currentContext, @@ -274,16 +280,19 @@ export function createDeviceManagerAuth({ }; } -function resolveAccess(user) { +function resolveAccess(user, trustedAccess, { allowLegacy = false } = {}) { if (!user || typeof user !== "object") { - return { allowed: false, blocked: false, hubRole: "viewer", groups: [] }; + return deniedAccess(); } const groups = normalizedGroups(user); if (groups.includes("nodedc:device-core:blocked")) { - return { allowed: false, blocked: true, hubRole: "viewer", groups }; + return { ...deniedAccess(groups), blocked: true }; } const id = cleanOpaque(user.id || user.subject || user.sub); - if (!id) return { allowed: false, blocked: false, hubRole: "viewer", groups }; + if (!id) return deniedAccess(groups); + const claims = normalizeTrustedAccess(trustedAccess, id); + if (claims) return { ...claims, blocked: false, groups }; + if (!allowLegacy) return deniedAccess(groups); const hubRole = id === "user_root" || groups.includes("nodedc:superadmin") ? "owner" : groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin") @@ -291,7 +300,57 @@ function resolveAccess(user) { : groups.includes("nodedc:device-core:viewer") ? "viewer" : "member"; - return { allowed: true, blocked: false, hubRole, groups }; + const ownerScopes = ["admin", "owner"].includes(hubRole) + ? [{ + scopeKind: "personal", + ownerRef: `user:${id}`, + displayName: String(user.name || user.displayName || user.email || id).trim().slice(0, 240), + }] + : []; + return { allowed: true, blocked: false, hubRole, groups, ownerScopes }; +} + +function normalizeTrustedAccess(input, userId) { + if (!input || typeof input !== "object" || input.allowed !== true) return null; + const hubRole = ["viewer", "member", "admin", "owner"].includes(input.hubRole) + ? input.hubRole + : null; + if (!hubRole || !Array.isArray(input.ownerScopes)) return null; + const ownerScopes = []; + for (const item of input.ownerScopes) { + if (!item || typeof item !== "object") return null; + const scopeKind = item.scopeKind === "company" || item.scopeKind === "personal" + ? item.scopeKind + : null; + const ownerRef = cleanOpaque(item.ownerRef); + const validOwner = scopeKind === "personal" + ? ownerRef === `user:${userId}` + : ownerRef?.startsWith("client:") && ownerRef.length > "client:".length; + if (!scopeKind || !validOwner) return null; + ownerScopes.push({ + scopeKind, + ownerRef, + displayName: String(item.displayName || ownerRef).trim().slice(0, 240), + }); + } + return { + allowed: true, + hubRole, + ownerScopes: [...new Map(ownerScopes.map((scope) => [ + `${scope.scopeKind}\0${scope.ownerRef}`, + scope, + ])).values()], + }; +} + +function deniedAccess(groups = []) { + return { + allowed: false, + blocked: false, + hubRole: "viewer", + groups, + ownerScopes: [], + }; } function normalizedGroups(user) { diff --git a/apps/device-manager/server/device-manager-auth.test.mjs b/apps/device-manager/server/device-manager-auth.test.mjs index f22434c..4ddd205 100644 --- a/apps/device-manager/server/device-manager-auth.test.mjs +++ b/apps/device-manager/server/device-manager-auth.test.mjs @@ -15,6 +15,22 @@ test("Launcher handoff becomes an opaque Device Manager session and trusted acto return jsonResponse(200, { ok: true, launcherSessionId, + access: { + allowed: true, + hubRole: "owner", + ownerScopes: [ + { + scopeKind: "company", + ownerRef: "client:client_dctouch", + displayName: "DC Touch", + }, + { + scopeKind: "personal", + ownerRef: "user:user_root", + displayName: "DC SUDO", + }, + ], + }, user: { id: "user_root", email: "root@example.test", @@ -53,11 +69,18 @@ test("Launcher handoff becomes an opaque Device Manager session and trusted acto const context = auth.currentContext(request); assert.equal(context.actor.userRef, "user:user_root"); assert.equal(context.actor.hubRole, "owner"); - assert.deepEqual(context.actor.ownerScopes, [{ - scopeKind: "personal", - ownerRef: "user:user_root", - displayName: "DC SUDO", - }]); + assert.deepEqual(context.actor.ownerScopes, [ + { + scopeKind: "company", + ownerRef: "client:client_dctouch", + displayName: "DC Touch", + }, + { + scopeKind: "personal", + ownerRef: "user:user_root", + displayName: "DC SUDO", + }, + ]); assert.deepEqual(context.actor.groupRefs, [ "group:nodedc:device-core:admin", "group:nodedc:superadmin", @@ -76,6 +99,11 @@ test("invalid identity and explicit Device Core block never produce an actor", a fetchImpl: async () => jsonResponse(200, { ok: true, launcherSessionId, + access: { + allowed: true, + hubRole: "admin", + ownerScopes: [], + }, user, }), }); @@ -97,6 +125,65 @@ test("invalid identity and explicit Device Core block never produce an actor", a } }); +test("production auth fails closed when Launcher omits trusted Device Core access", async () => { + const auth = createDeviceManagerAuth({ + env: productionEnv(), + fetchImpl: async () => jsonResponse(200, { + ok: true, + launcherSessionId, + user: { + id: "user_root", + email: "root@example.test", + name: "DC SUDO", + groups: ["nodedc:superadmin"], + }, + }), + }); + const handoff = mockResponse(); + await auth.handleHandoff( + { method: "GET", headers: {} }, + handoff, + new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"), + ); + const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0]; + const response = mockResponse(); + assert.equal(await auth.authorize( + { method: "GET", headers: { cookie, accept: "application/json" } }, + response, + new URL("https://device.example.test/api/device-manager/session"), + ), true); + assert.equal(response.statusCode, 403); + assert.equal(JSON.parse(response.body).error, "device_manager_access_denied"); +}); + +test("an injected file-backed token takes precedence over broad platform env tokens", async () => { + const calls = []; + const auth = createDeviceManagerAuth({ + env: { ...productionEnv(), NODEDC_INTERNAL_ACCESS_TOKEN: "broad-platform-token" }, + internalToken: "scoped-file-token", + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return jsonResponse(200, { + ok: true, + launcherSessionId, + access: { allowed: true, hubRole: "member", ownerScopes: [] }, + user: { + id: "device-member", + email: "member@example.test", + name: "Device Member", + groups: ["nodedc:device-core:access"], + }, + }); + }, + }); + await auth.handleHandoff( + { method: "GET", headers: {} }, + mockResponse(), + new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"), + ); + assert.equal(calls[0].init.headers.Authorization, "Bearer scoped-file-token"); +}); + function productionEnv() { return { NODE_ENV: "production", diff --git a/apps/device-manager/server/device-manager-server.mjs b/apps/device-manager/server/device-manager-server.mjs index 4d46fb6..9b96aa4 100644 --- a/apps/device-manager/server/device-manager-server.mjs +++ b/apps/device-manager/server/device-manager-server.mjs @@ -123,7 +123,14 @@ export function createDeviceManagerServer({ export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) { const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false); - const auth = createDeviceManagerAuth({ env }); + const launcherTokenFile = String(env.NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE || "").trim(); + const launcherInternalToken = launcherTokenFile + ? (await readFile(launcherTokenFile, "utf8")).trim() + : undefined; + const auth = createDeviceManagerAuth({ env, internalToken: launcherInternalToken }); + if (auth.authRequired && !auth.internalAccessConfigured) { + throw new Error("device_manager_auth_token_file_required"); + } let coreClient; if (localPreview) { if (String(env.NODE_ENV || "").toLowerCase() === "production") {