Files
linumiq_net-web_app/app/api/device/code/route.ts
T

64 lines
2.0 KiB
TypeScript

import { type NextRequest } from 'next/server';
import { getSupabaseAdmin } from '@/lib/supabase/admin';
import { jsonNoStore } from '@/lib/admin/response';
import { getAppOrigin } from '@/lib/auth/mfa';
import {
generateDeviceCode,
generateUserCode,
hashToken,
DEVICE_CODE_TTL_SECONDS,
DEVICE_POLL_INTERVAL_SECONDS,
} from '@/lib/device';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* Device-authorization endpoint (PUBLIC, no auth). The HA add-on calls this to
* obtain a device_code + a short user_code. The user then visits
* verification_uri and approves the pairing while signed in.
*/
export async function POST(_req: NextRequest) {
const admin = getSupabaseAdmin();
const deviceCode = generateDeviceCode();
const deviceCodeHash = hashToken(deviceCode);
const expiresAt = new Date(
Date.now() + DEVICE_CODE_TTL_SECONDS * 1000,
).toISOString();
// Retry on the (rare) user_code unique-violation with a fresh code.
let userCode = '';
let lastError: string | null = null;
for (let attempt = 0; attempt < 5; attempt++) {
userCode = generateUserCode();
const { error } = await admin.from('device_pairings').insert({
device_code_hash: deviceCodeHash,
user_code: userCode,
status: 'pending',
interval_seconds: DEVICE_POLL_INTERVAL_SECONDS,
expires_at: expiresAt,
});
if (!error) {
lastError = null;
break;
}
lastError = error.message;
if ((error as { code?: string }).code !== '23505') {
return jsonNoStore({ error: 'failed' }, { status: 500 });
}
}
if (lastError) {
return jsonNoStore({ error: 'failed' }, { status: 500 });
}
const appOrigin = getAppOrigin();
return jsonNoStore({
device_code: deviceCode,
user_code: userCode,
verification_uri: `${appOrigin}/activate`,
verification_uri_complete: `${appOrigin}/activate?code=${userCode}`,
interval: DEVICE_POLL_INTERVAL_SECONDS,
expires_in: DEVICE_CODE_TTL_SECONDS,
});
}