From 2c2afa458e5e15d295e619b47953933f7b3059b5 Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Mon, 1 Jun 2026 10:35:41 +0200 Subject: [PATCH] feat(device): OAuth2 device-pairing flow for HA add-on (code/approve/token/tunnel + /activate) --- app/activate/activate-client.tsx | 224 ++++++++++++++++++++ app/activate/page.tsx | 41 ++++ app/api/device/approve/route.ts | 141 ++++++++++++ app/api/device/code/route.ts | 63 ++++++ app/api/device/token/route.ts | 106 +++++++++ app/api/device/tunnel/route.ts | 138 ++++++++++++ lib/device.ts | 58 +++++ middleware.ts | 2 +- supabase/migrations/0003_device_pairing.sql | 91 ++++++++ 9 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 app/activate/activate-client.tsx create mode 100644 app/activate/page.tsx create mode 100644 app/api/device/approve/route.ts create mode 100644 app/api/device/code/route.ts create mode 100644 app/api/device/token/route.ts create mode 100644 app/api/device/tunnel/route.ts create mode 100644 lib/device.ts create mode 100644 supabase/migrations/0003_device_pairing.sql diff --git a/app/activate/activate-client.tsx b/app/activate/activate-client.tsx new file mode 100644 index 0000000..dbf9aa5 --- /dev/null +++ b/app/activate/activate-client.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; +import { validateSubdomain } from '@/lib/validation'; + +type CheckState = + | { kind: 'idle' } + | { kind: 'checking' } + | { kind: 'ok' } + | { kind: 'taken' } + | { kind: 'invalid'; message: string }; + +const ERROR_TEXT: Record = { + invalid_user_code: 'That code is invalid. Double-check and try again.', + expired: 'That code has expired. Generate a new one in your add-on.', + 'subdomain taken': 'That subdomain is already taken.', + subdomain_required: 'Please choose a subdomain for your tunnel.', + aal2_required: 'Please finish 2FA, then try again.', + unauthorized: 'Your session expired. Please sign in again.', +}; + +function friendly(error: string | undefined, status: number): string { + if (error && ERROR_TEXT[error]) return ERROR_TEXT[error]; + return error ?? `Request failed (${status})`; +} + +/** Normalize a user-typed code to XXXX-XXXX for display (client-side mirror). */ +function normalizeCode(raw: string): string { + const cleaned = raw.toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (cleaned.length !== 8) return raw.toUpperCase(); + return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`; +} + +export function ActivateClient({ + prefillCode, + currentSubdomain, +}: { + prefillCode: string; + currentSubdomain: string | null; +}) { + const [userCode, setUserCode] = useState( + prefillCode ? normalizeCode(prefillCode) : '', + ); + // When the user has an existing subdomain, default to reusing it. + const [useExisting, setUseExisting] = useState(!!currentSubdomain); + const [subdomain, setSubdomain] = useState(''); + const [check, setCheck] = useState({ kind: 'idle' }); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + const [isPending, startTransition] = useTransition(); + + const needsSubdomain = !useExisting; + + useEffect(() => { + if (!needsSubdomain) { + setCheck({ kind: 'idle' }); + return; + } + const v = validateSubdomain(subdomain); + if (!subdomain) { + setCheck({ kind: 'idle' }); + return; + } + if (!v.ok) { + setCheck({ kind: 'invalid', message: v.error }); + return; + } + setCheck({ kind: 'checking' }); + const ctrl = new AbortController(); + const t = setTimeout(async () => { + try { + const res = await fetch( + `/api/tunnel/check?subdomain=${encodeURIComponent(v.value)}`, + { signal: ctrl.signal }, + ); + const body = (await res.json()) as { available?: boolean }; + setCheck(body.available ? { kind: 'ok' } : { kind: 'taken' }); + } catch (e) { + if ((e as { name?: string }).name !== 'AbortError') { + setCheck({ kind: 'idle' }); + } + } + }, 300); + return () => { + ctrl.abort(); + clearTimeout(t); + }; + }, [subdomain, needsSubdomain]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + const code = normalizeCode(userCode); + if (code.replace(/[^A-Z0-9]/g, '').length !== 8) { + setError(ERROR_TEXT.invalid_user_code); + return; + } + + const payload: { user_code: string; subdomain?: string } = { + user_code: code, + }; + if (needsSubdomain) { + const v = validateSubdomain(subdomain); + if (!v.ok) { + setError(v.error); + return; + } + payload.subdomain = v.value; + } + + startTransition(async () => { + const res = await fetch('/api/device/approve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + setError(friendly(body.error, res.status)); + return; + } + setDone(true); + }); + } + + if (done) { + return ( +

+ ✓ Device approved — return to your Home Assistant add-on. +

+ ); + } + + const submitDisabled = + isPending || + !userCode || + (needsSubdomain && + (check.kind === 'invalid' || + check.kind === 'taken' || + check.kind === 'checking' || + !subdomain)); + + return ( +
+ +
+ setUserCode(e.target.value.toUpperCase())} + onBlur={() => setUserCode((c) => normalizeCode(c))} + required + autoCapitalize="characters" + autoCorrect="off" + spellCheck={false} + placeholder="WXYZ-2345" + /> +
+ + {currentSubdomain && ( +
+ + +
+ )} + + {needsSubdomain && ( +
+ +
+ setSubdomain(e.target.value.toLowerCase())} + required + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + placeholder="smith" + /> + .linumiq.net +
+
+ {check.kind === 'checking' && ( + Checking availability… + )} + {check.kind === 'ok' && Available} + {check.kind === 'taken' && Taken} + {check.kind === 'invalid' && ( + {check.message} + )} +
+
+ )} + + {error &&

{error}

} + +
+ +
+
+ ); +} diff --git a/app/activate/page.tsx b/app/activate/page.tsx new file mode 100644 index 0000000..bee60d6 --- /dev/null +++ b/app/activate/page.tsx @@ -0,0 +1,41 @@ +import { redirect } from 'next/navigation'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { ActivateClient } from './activate-client'; + +export const dynamic = 'force-dynamic'; + +export default async function ActivatePage({ + searchParams, +}: { + searchParams: { code?: string }; +}) { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login?next=/activate'); + + const admin = getSupabaseAdmin(); + const { data: tunnel } = await admin + .from('tunnels') + .select('subdomain') + .eq('user_id', user.id) + .maybeSingle<{ subdomain: string }>(); + + return ( +
+

Activate device

+

+ Enter the code shown by your Home Assistant add-on to pair it with your + account. +

+
+ +
+
+ ); +} diff --git a/app/api/device/approve/route.ts b/app/api/device/approve/route.ts new file mode 100644 index 0000000..2b69c30 --- /dev/null +++ b/app/api/device/approve/route.ts @@ -0,0 +1,141 @@ +import { type NextRequest } from 'next/server'; +import { randomBytes } from 'node:crypto'; +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { jsonNoStore } from '@/lib/admin/response'; +import { validateSubdomain } from '@/lib/validation'; +import { setTunnelActive } from '@/lib/redis'; +import { normalizeUserCode } from '@/lib/device'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type ApproveBody = { user_code?: unknown; subdomain?: unknown }; + +type Pairing = { + id: number; + status: string; + expires_at: string; +}; + +/** + * Approve a device pairing (AUTHENTICATED + AAL2). The signed-in user binds a + * pending user_code to their account and chooses/reuses a subdomain. + */ +export async function POST(req: NextRequest) { + const supabase = createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (aal?.currentLevel !== 'aal2') { + return jsonNoStore({ error: 'aal2_required' }, { status: 403 }); + } + + let body: ApproveBody; + try { + body = (await req.json()) as ApproveBody; + } catch { + return jsonNoStore({ error: 'invalid json' }, { status: 400 }); + } + + const userCode = normalizeUserCode( + typeof body.user_code === 'string' ? body.user_code : '', + ); + if (!userCode) { + return jsonNoStore({ error: 'invalid_user_code' }, { status: 400 }); + } + + const admin = getSupabaseAdmin(); + + const { data: pairing, error: pairingErr } = await admin + .from('device_pairings') + .select('id, status, expires_at') + .eq('user_code', userCode) + .eq('status', 'pending') + .maybeSingle(); + if (pairingErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + if (!pairing) { + return jsonNoStore({ error: 'invalid_user_code' }, { status: 400 }); + } + if (new Date(pairing.expires_at).getTime() < Date.now()) { + await admin + .from('device_pairings') + .update({ status: 'expired' }) + .eq('id', pairing.id); + return jsonNoStore({ error: 'expired' }, { status: 400 }); + } + + let subdomain: string; + + if (body.subdomain !== undefined) { + const v = validateSubdomain(body.subdomain); + if (!v.ok) { + return jsonNoStore({ error: v.error }, { status: 400 }); + } + subdomain = v.value; + + // Reject if the subdomain is already owned by another user. + const { data: existing, error: existingErr } = await admin + .from('tunnels') + .select('user_id') + .eq('subdomain', subdomain) + .maybeSingle<{ user_id: string }>(); + if (existingErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + if (existing && existing.user_id !== user.id) { + return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); + } + + const token = randomBytes(32).toString('hex'); + const { error: upsertErr } = await admin + .from('tunnels') + .upsert( + { user_id: user.id, subdomain, token, is_active: true }, + { onConflict: 'user_id' }, + ) + .select('subdomain') + .single(); + if (upsertErr) { + if ((upsertErr as { code?: string }).code === '23505') { + return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); + } + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + + await setTunnelActive(subdomain, true); + } else { + // Reuse the user's existing subdomain without rotating the frp token. + const { data: tunnel, error: tunnelErr } = await admin + .from('tunnels') + .select('subdomain') + .eq('user_id', user.id) + .maybeSingle<{ subdomain: string }>(); + if (tunnelErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + if (!tunnel) { + return jsonNoStore({ error: 'subdomain_required' }, { status: 400 }); + } + subdomain = tunnel.subdomain; + } + + const { error: updateErr } = await admin + .from('device_pairings') + .update({ + user_id: user.id, + subdomain, + status: 'approved', + approved_at: new Date().toISOString(), + }) + .eq('id', pairing.id); + if (updateErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + + return jsonNoStore({ ok: true, subdomain }); +} diff --git a/app/api/device/code/route.ts b/app/api/device/code/route.ts new file mode 100644 index 0000000..dd3393c --- /dev/null +++ b/app/api/device/code/route.ts @@ -0,0 +1,63 @@ +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, + }); +} diff --git a/app/api/device/token/route.ts b/app/api/device/token/route.ts new file mode 100644 index 0000000..2414306 --- /dev/null +++ b/app/api/device/token/route.ts @@ -0,0 +1,106 @@ +import { type NextRequest } from 'next/server'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { jsonNoStore } from '@/lib/admin/response'; +import { + generateDeviceToken, + hashToken, +} from '@/lib/device'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type TokenBody = { device_code?: unknown }; + +type Pairing = { + id: number; + status: string; + user_id: string | null; + expires_at: string; + interval_seconds: number; + last_polled_at: string | null; +}; + +/** + * Device-token endpoint (PUBLIC). The device polls this with its device_code. + * Returns authorization_pending until the user approves, then mints a device + * token exactly once. + */ +export async function POST(req: NextRequest) { + let body: TokenBody; + try { + body = (await req.json()) as TokenBody; + } catch { + return jsonNoStore({ error: 'invalid_grant' }, { status: 400 }); + } + const deviceCode = + typeof body.device_code === 'string' ? body.device_code : ''; + if (!deviceCode) { + return jsonNoStore({ error: 'invalid_grant' }, { status: 400 }); + } + + const admin = getSupabaseAdmin(); + const { data: pairing, error: pairingErr } = await admin + .from('device_pairings') + .select('id, status, user_id, expires_at, interval_seconds, last_polled_at') + .eq('device_code_hash', hashToken(deviceCode)) + .maybeSingle(); + if (pairingErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + if (!pairing) { + return jsonNoStore({ error: 'invalid_grant' }, { status: 400 }); + } + + // Optional slow_down: reject when polled faster than the advertised interval. + const now = Date.now(); + if (pairing.last_polled_at) { + const sinceLast = now - new Date(pairing.last_polled_at).getTime(); + if (sinceLast < pairing.interval_seconds * 1000) { + await admin + .from('device_pairings') + .update({ last_polled_at: new Date(now).toISOString() }) + .eq('id', pairing.id); + return jsonNoStore({ error: 'slow_down' }, { status: 429 }); + } + } + await admin + .from('device_pairings') + .update({ last_polled_at: new Date(now).toISOString() }) + .eq('id', pairing.id); + + if (new Date(pairing.expires_at).getTime() < now) { + await admin + .from('device_pairings') + .update({ status: 'expired' }) + .eq('id', pairing.id); + return jsonNoStore({ error: 'expired_token' }, { status: 400 }); + } + + if (pairing.status === 'pending') { + return jsonNoStore({ error: 'authorization_pending' }, { status: 400 }); + } + + if (pairing.status === 'approved' && pairing.user_id) { + const deviceToken = generateDeviceToken(); + const { error: insErr } = await admin.from('device_tokens').insert({ + token_hash: hashToken(deviceToken), + user_id: pairing.user_id, + label: 'HA add-on', + }); + if (insErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + const { error: claimErr } = await admin + .from('device_pairings') + .update({ status: 'claimed', claimed_at: new Date(now).toISOString() }) + .eq('id', pairing.id) + .eq('status', 'approved'); + if (claimErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + return jsonNoStore({ device_token: deviceToken, token_type: 'Bearer' }); + } + + // claimed or expired. + return jsonNoStore({ error: 'expired_token' }, { status: 400 }); +} diff --git a/app/api/device/tunnel/route.ts b/app/api/device/tunnel/route.ts new file mode 100644 index 0000000..1eaca06 --- /dev/null +++ b/app/api/device/tunnel/route.ts @@ -0,0 +1,138 @@ +import { type NextRequest } from 'next/server'; +import { randomBytes } from 'node:crypto'; +import { getSupabaseAdmin } from '@/lib/supabase/admin'; +import { jsonNoStore } from '@/lib/admin/response'; +import { validateSubdomain } from '@/lib/validation'; +import { setTunnelActive } from '@/lib/redis'; +import { hashToken, FRP_SERVER_ADDR, FRP_SERVER_PORT } from '@/lib/device'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +type DeviceTokenRow = { id: number; user_id: string }; + +/** + * Authenticate a request by its `Authorization: Bearer ` header. + * Returns the matching, non-revoked device_tokens row (and bumps last_used_at), + * or null when the token is missing/invalid/revoked. + */ +async function authDevice(req: NextRequest): Promise { + const auth = req.headers.get('authorization') ?? ''; + const m = /^Bearer\s+(.+)$/i.exec(auth.trim()); + if (!m) return null; + + const admin = getSupabaseAdmin(); + const { data, error } = await admin + .from('device_tokens') + .select('id, user_id') + .eq('token_hash', hashToken(m[1])) + .is('revoked_at', null) + .maybeSingle(); + if (error || !data) return null; + + await admin + .from('device_tokens') + .update({ last_used_at: new Date().toISOString() }) + .eq('id', data.id); + + return data; +} + +type Tunnel = { subdomain: string; token: string }; + +/** GET — return the device's current tunnel config. */ +export async function GET(req: NextRequest) { + const device = await authDevice(req); + if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); + + const admin = getSupabaseAdmin(); + const { data: tunnel, error } = await admin + .from('tunnels') + .select('subdomain, token') + .eq('user_id', device.user_id) + .maybeSingle(); + if (error) return jsonNoStore({ error: 'failed' }, { status: 500 }); + if (!tunnel) return jsonNoStore({ error: 'no_tunnel' }, { status: 404 }); + + return jsonNoStore({ + subdomain: tunnel.subdomain, + token: tunnel.token, + server_addr: FRP_SERVER_ADDR, + server_port: FRP_SERVER_PORT, + }); +} + +type PostBody = { subdomain?: unknown }; + +/** POST — claim/change the subdomain (rotates the frp token). */ +export async function POST(req: NextRequest) { + const device = await authDevice(req); + if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); + + let body: PostBody; + try { + body = (await req.json()) as PostBody; + } catch { + return jsonNoStore({ error: 'invalid json' }, { status: 400 }); + } + + const v = validateSubdomain(body.subdomain); + if (!v.ok) { + return jsonNoStore({ error: v.error }, { status: 400 }); + } + const subdomain = v.value; + + const admin = getSupabaseAdmin(); + + const { data: existing, error: existingErr } = await admin + .from('tunnels') + .select('user_id') + .eq('subdomain', subdomain) + .maybeSingle<{ user_id: string }>(); + if (existingErr) { + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + if (existing && existing.user_id !== device.user_id) { + return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); + } + + const token = randomBytes(32).toString('hex'); + const { data, error } = await admin + .from('tunnels') + .upsert( + { user_id: device.user_id, subdomain, token, is_active: true }, + { onConflict: 'user_id' }, + ) + .select('subdomain, token') + .single(); + if (error) { + if ((error as { code?: string }).code === '23505') { + return jsonNoStore({ error: 'subdomain taken' }, { status: 409 }); + } + return jsonNoStore({ error: 'failed' }, { status: 500 }); + } + + await setTunnelActive(subdomain, true); + + return jsonNoStore({ + subdomain: data.subdomain, + token: data.token, + server_addr: FRP_SERVER_ADDR, + server_port: FRP_SERVER_PORT, + }); +} + +/** DELETE — unpair this device by revoking its token (tunnel is kept). */ +export async function DELETE(req: NextRequest) { + const device = await authDevice(req); + if (!device) return jsonNoStore({ error: 'unauthorized' }, { status: 401 }); + + const admin = getSupabaseAdmin(); + const { error } = await admin + .from('device_tokens') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', device.id); + if (error) return jsonNoStore({ error: 'failed' }, { status: 500 }); + + return jsonNoStore({ ok: true }); +} diff --git a/lib/device.ts b/lib/device.ts new file mode 100644 index 0000000..e1de6e7 --- /dev/null +++ b/lib/device.ts @@ -0,0 +1,58 @@ +import { createHash, randomBytes, randomInt } from 'node:crypto'; + +/** + * Server-only helpers for the OAuth2 device-authorization-grant style "device + * pairing" flow (Home Assistant add-on). Dependency-free (node:crypto only). + * + * Opaque secrets (device_code, device token) are high-entropy random values, + * so a fast SHA-256 hash is sufficient for at-rest storage — we never persist + * or log the plaintext. Mirrors the hashing style in lib/auth/recovery.ts. + */ + +/** Stable hash used both when storing and when looking up an opaque secret. */ +export function hashToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** Opaque device_code (the secret the device polls with). */ +export function generateDeviceCode(): string { + return randomBytes(32).toString('hex'); +} + +/** Opaque long-lived device bearer token. */ +export function generateDeviceToken(): string { + return randomBytes(32).toString('hex'); +} + +// Unambiguous alphabet: no 0/O/1/I to avoid transcription errors. +const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +/** Short human code, formatted `XXXX-XXXX` (uppercase, unbiased selection). */ +export function generateUserCode(): string { + let out = ''; + for (let i = 0; i < 8; i++) { + out += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)]; + } + return `${out.slice(0, 4)}-${out.slice(4, 8)}`; +} + +/** + * Normalise a user-typed code: uppercase, strip non-alphanumerics, re-insert + * the dash as `XXXX-XXXX`. Returns '' if the result is not exactly 8 alnum + * characters. + */ +export function normalizeUserCode(raw: string): string { + const cleaned = raw.toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (cleaned.length !== 8) return ''; + return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 8)}`; +} + +/** Device-code lifetime before it must be re-requested. */ +export const DEVICE_CODE_TTL_SECONDS = 600; + +/** Recommended polling interval the device should respect. */ +export const DEVICE_POLL_INTERVAL_SECONDS = 5; + +/** frp server the add-on connects back to. */ +export const FRP_SERVER_ADDR = 'linumiq.net'; +export const FRP_SERVER_PORT = 7000; diff --git a/middleware.ts b/middleware.ts index 180f5be..70057cb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -114,7 +114,7 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|api/tunnel/claim).*)', + '/((?!_next/static|_next/image|favicon.ico|api/tunnel/claim|api/device).*)', '/admin/:path*', '/api/admin/:path*', ], diff --git a/supabase/migrations/0003_device_pairing.sql b/supabase/migrations/0003_device_pairing.sql new file mode 100644 index 0000000..f500fe8 --- /dev/null +++ b/supabase/migrations/0003_device_pairing.sql @@ -0,0 +1,91 @@ +-- 0003_device_pairing.sql +-- Additive, idempotent migration for the OAuth2 device-authorization-grant +-- style "device pairing" flow used by the Home Assistant add-on. +-- Safe to run multiple times. + +-- 1. Device pairings ------------------------------------------------------ +-- One row per device-pairing attempt. The opaque device_code is only ever +-- stored as a SHA-256 hash; the short, human-typed user_code is stored in the +-- clear so it can be looked up when the user approves on the web. +create table if not exists public.device_pairings ( + id bigint generated always as identity primary key, + device_code_hash text not null, + user_code text not null, + user_id uuid, + subdomain citext, + status text not null default 'pending', + interval_seconds int not null default 5, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + approved_at timestamptz, + claimed_at timestamptz, + last_polled_at timestamptz +); + +create unique index if not exists device_pairings_device_code_hash_uidx + on public.device_pairings (device_code_hash); + +create unique index if not exists device_pairings_user_code_uidx + on public.device_pairings (user_code); + +create index if not exists device_pairings_expires_at_idx + on public.device_pairings (expires_at); + +-- Cascade-delete pairings when the owning auth user is removed. +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'device_pairings_user_id_fkey' + and conrelid = 'public.device_pairings'::regclass + ) then + alter table public.device_pairings + add constraint device_pairings_user_id_fkey + foreign key (user_id) references auth.users (id) on delete cascade; + end if; +end $$; + +-- RLS enabled with NO policies: service-role only. +alter table public.device_pairings enable row level security; + +comment on table public.device_pairings is + 'Device-authorization-grant pairings (device_code SHA-256 hashed). RLS enabled with no policies: service-role only.'; + +-- 2. Device tokens -------------------------------------------------------- +-- Long-lived bearer tokens minted to a paired device. Only the SHA-256 hash +-- is stored; the plaintext is returned to the device exactly once at mint. +create table if not exists public.device_tokens ( + id bigint generated always as identity primary key, + token_hash text not null, + user_id uuid not null, + label text, + created_at timestamptz not null default now(), + last_used_at timestamptz, + revoked_at timestamptz +); + +create unique index if not exists device_tokens_token_hash_uidx + on public.device_tokens (token_hash); + +create index if not exists device_tokens_user_id_idx + on public.device_tokens (user_id); + +-- Cascade-delete device tokens when the owning auth user is removed. +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conname = 'device_tokens_user_id_fkey' + and conrelid = 'public.device_tokens'::regclass + ) then + alter table public.device_tokens + add constraint device_tokens_user_id_fkey + foreign key (user_id) references auth.users (id) on delete cascade; + end if; +end $$; + +-- RLS enabled with NO policies: service-role only. +alter table public.device_tokens enable row level security; + +comment on table public.device_tokens is + 'Device bearer tokens (SHA-256 hashed). RLS enabled with no policies: service-role only.';