feat(backend): /api/protection/event akzeptiert Extension-Secret
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled

- Extension-Auth-Path via x-extension-secret Header.
- Ermittelt userId anhand deviceId aus UserDevice.
- EXTENSION_SECRET in runtimeConfig + Infisical staging.
This commit is contained in:
chahinebrini 2026-06-18 09:38:05 +02:00
parent a7ac5545ae
commit b0a7091ac7
2 changed files with 56 additions and 6 deletions

View File

@ -71,6 +71,9 @@ export default defineNitroConfig({
// ─── Admin / Cron ──────────────────────────────────────────────────── // ─── Admin / Cron ────────────────────────────────────────────────────
adminSecret: process.env.ADMIN_SECRET ?? "", adminSecret: process.env.ADMIN_SECRET ?? "",
cronSecret: process.env.CRON_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). // Shared secret for AdGuard→Backend DoH-handshake (POST /api/devices/protected/handshake).
// Set in Infisical as HANDSHAKE_SECRET before enabling AdGuard webhook. // Set in Infisical as HANDSHAKE_SECRET before enabling AdGuard webhook.
handshakeSecret: process.env.HANDSHAKE_SECRET ?? "", handshakeSecret: process.env.HANDSHAKE_SECRET ?? "",

View File

@ -4,8 +4,10 @@ import {
appendProtectionEventDeduped, appendProtectionEventDeduped,
type ProtectionSource, type ProtectionSource,
} from "../../db/protectionStateLog"; } from "../../db/protectionStateLog";
import { upsertDeviceProtectionState } from "../../db/device-protection"; import {
import type { ProtectionType } from "../../db/device-protection"; upsertDeviceProtectionState,
type ProtectionType,
} from "../../db/device-protection";
const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"]; const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"];
@ -14,6 +16,17 @@ function sourceToProtectionType(source: ProtectionSource): ProtectionType {
return "vpn"; return "vpn";
} }
async function resolveUserIdFromDeviceId(
deviceId: string,
): Promise<string | null> {
const db = usePrisma();
const row = await db.userDevice.findUnique({
where: { deviceId },
select: { userId: true },
});
return row?.userId ?? null;
}
/** /**
* POST /api/protection/event * POST /api/protection/event
* *
@ -23,6 +36,9 @@ function sourceToProtectionType(source: ProtectionSource): ProtectionType {
* deviceId?: string // optional, falls bekannt (z.B. native App) * deviceId?: string // optional, falls bekannt (z.B. native App)
* } * }
* *
* Auth: Bearer <jwt> (native app) OR x-extension-secret: <secret> (iOS
* Network-Extension when the container app is not running).
*
* Called from the native app (useProtectionState / lib/protection) when the * Called from the native app (useProtectionState / lib/protection) when the
* combined protection state transitions onoff. The client deduplicates * combined protection state transitions onoff. The client deduplicates
* locally (only fires on real transitions); the server deduplicates again * 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). * { success: true, written: false } if deduplicated (state unchanged).
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event); const body = await readBody(event);
if (typeof body?.active !== "boolean") { if (typeof body?.active !== "boolean") {
@ -47,13 +62,42 @@ export default defineEventHandler(async (event) => {
? (body.source as ProtectionSource) ? (body.source as ProtectionSource)
: "client"; : "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; const deviceId = body?.deviceId ?? getHeader(event, "x-device-id") ?? null;
if (deviceId && typeof deviceId === "string") { if (deviceId && typeof deviceId === "string") {
try { try {
await upsertDeviceProtectionState( await upsertDeviceProtectionState(
user.id, userId,
deviceId, deviceId,
"ios", "ios",
sourceToProtectionType(source), sourceToProtectionType(source),
@ -63,7 +107,10 @@ export default defineEventHandler(async (event) => {
"native-app", "native-app",
); );
} catch (err: any) { } 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,
);
} }
} }