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 ────────────────────────────────────────────────────
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 ?? "",

View File

@ -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<string | null> {
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 <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
* combined protection state transitions onoff. 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,
);
}
}