diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index 6e29833..5e1d423 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -71,6 +71,9 @@ export default defineNitroConfig({ // ─── Admin / Cron ──────────────────────────────────────────────────── adminSecret: process.env.ADMIN_SECRET ?? "", cronSecret: process.env.CRON_SECRET ?? "", + // Shared secret for iOS Network Extensions to report protection-state + // transitions when the container app is not running. + extensionSecret: process.env.EXTENSION_SECRET ?? "", // Shared secret for AdGuard→Backend DoH-handshake (POST /api/devices/protected/handshake). // Set in Infisical as HANDSHAKE_SECRET before enabling AdGuard webhook. handshakeSecret: process.env.HANDSHAKE_SECRET ?? "", diff --git a/backend/server/api/protection/event.post.ts b/backend/server/api/protection/event.post.ts index 48f2a67..9c733cc 100644 --- a/backend/server/api/protection/event.post.ts +++ b/backend/server/api/protection/event.post.ts @@ -4,8 +4,10 @@ import { appendProtectionEventDeduped, type ProtectionSource, } from "../../db/protectionStateLog"; -import { upsertDeviceProtectionState } from "../../db/device-protection"; -import type { ProtectionType } from "../../db/device-protection"; +import { + upsertDeviceProtectionState, + type ProtectionType, +} from "../../db/device-protection"; const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"]; @@ -14,6 +16,17 @@ function sourceToProtectionType(source: ProtectionSource): ProtectionType { return "vpn"; } +async function resolveUserIdFromDeviceId( + deviceId: string, +): Promise { + const db = usePrisma(); + const row = await db.userDevice.findUnique({ + where: { deviceId }, + select: { userId: true }, + }); + return row?.userId ?? null; +} + /** * POST /api/protection/event * @@ -23,6 +36,9 @@ function sourceToProtectionType(source: ProtectionSource): ProtectionType { * deviceId?: string // optional, falls bekannt (z.B. native App) * } * + * Auth: Bearer (native app) OR x-extension-secret: (iOS + * Network-Extension when the container app is not running). + * * Called from the native app (useProtectionState / lib/protection) when the * combined protection state transitions on↔off. The client deduplicates * locally (only fires on real transitions); the server deduplicates again @@ -36,7 +52,6 @@ function sourceToProtectionType(source: ProtectionSource): ProtectionType { * { success: true, written: false } if deduplicated (state unchanged). */ export default defineEventHandler(async (event) => { - const user = await requireUser(event); const body = await readBody(event); if (typeof body?.active !== "boolean") { @@ -47,13 +62,42 @@ export default defineEventHandler(async (event) => { ? (body.source as ProtectionSource) : "client"; - const row = await appendProtectionEventDeduped(user.id, body.active, source); + let userId: string; + + // ─── Extension auth path (iOS Network Extension) ──────────────────────────── + const extensionSecret = getHeader(event, "x-extension-secret"); + const config = useRuntimeConfig(event); + const expectedSecret = (config as any).extensionSecret as string | undefined; + + if (extensionSecret && extensionSecret === expectedSecret && expectedSecret) { + const deviceId = body?.deviceId; + if (!deviceId || typeof deviceId !== "string") { + throw createError({ + statusCode: 400, + message: "deviceId required for extension auth", + }); + } + const resolved = await resolveUserIdFromDeviceId(deviceId); + if (!resolved) { + throw createError({ + statusCode: 404, + message: "device not found", + }); + } + userId = resolved; + } else { + // ─── Standard auth path (container app with JWT) ────────────────────────── + const user = await requireUser(event); + userId = user.id; + } + + const row = await appendProtectionEventDeduped(userId, body.active, source); const deviceId = body?.deviceId ?? getHeader(event, "x-device-id") ?? null; if (deviceId && typeof deviceId === "string") { try { await upsertDeviceProtectionState( - user.id, + userId, deviceId, "ios", sourceToProtectionType(source), @@ -63,7 +107,10 @@ export default defineEventHandler(async (event) => { "native-app", ); } catch (err: any) { - console.error("[protection/event] device_protection_states upsert failed:", err?.message ?? err); + console.error( + "[protection/event] device_protection_states upsert failed:", + err?.message ?? err, + ); } }