feat(device): OAuth2 device-pairing flow for HA add-on (code/approve/token/tunnel + /activate)
This commit is contained in:
@@ -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<string, string> = {
|
||||||
|
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<boolean>(!!currentSubdomain);
|
||||||
|
const [subdomain, setSubdomain] = useState('');
|
||||||
|
const [check, setCheck] = useState<CheckState>({ kind: 'idle' });
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<p className="success">
|
||||||
|
✓ Device approved — return to your Home Assistant add-on.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDisabled =
|
||||||
|
isPending ||
|
||||||
|
!userCode ||
|
||||||
|
(needsSubdomain &&
|
||||||
|
(check.kind === 'invalid' ||
|
||||||
|
check.kind === 'taken' ||
|
||||||
|
check.kind === 'checking' ||
|
||||||
|
!subdomain));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<label htmlFor="user_code">Pairing code</label>
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
id="user_code"
|
||||||
|
type="text"
|
||||||
|
value={userCode}
|
||||||
|
onChange={(e) => setUserCode(e.target.value.toUpperCase())}
|
||||||
|
onBlur={() => setUserCode((c) => normalizeCode(c))}
|
||||||
|
required
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="WXYZ-2345"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentSubdomain && (
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="subchoice"
|
||||||
|
checked={useExisting}
|
||||||
|
onChange={() => setUseExisting(true)}
|
||||||
|
/>{' '}
|
||||||
|
Use my current subdomain ({currentSubdomain}.linumiq.net)
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginTop: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="subchoice"
|
||||||
|
checked={!useExisting}
|
||||||
|
onChange={() => setUseExisting(false)}
|
||||||
|
/>{' '}
|
||||||
|
Use a different subdomain
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsSubdomain && (
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<label htmlFor="subdomain">Subdomain</label>
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
id="subdomain"
|
||||||
|
type="text"
|
||||||
|
value={subdomain}
|
||||||
|
onChange={(e) => setSubdomain(e.target.value.toLowerCase())}
|
||||||
|
required
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="smith"
|
||||||
|
/>
|
||||||
|
<span className="muted">.linumiq.net</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ minHeight: '1.5em', marginTop: '0.25rem' }}>
|
||||||
|
{check.kind === 'checking' && (
|
||||||
|
<span className="muted">Checking availability…</span>
|
||||||
|
)}
|
||||||
|
{check.kind === 'ok' && <span className="success">Available</span>}
|
||||||
|
{check.kind === 'taken' && <span className="error">Taken</span>}
|
||||||
|
{check.kind === 'invalid' && (
|
||||||
|
<span className="error">{check.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button type="submit" disabled={submitDisabled}>
|
||||||
|
{isPending ? 'Approving…' : 'Approve device'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1>Activate device</h1>
|
||||||
|
<p className="muted">
|
||||||
|
Enter the code shown by your Home Assistant add-on to pair it with your
|
||||||
|
account.
|
||||||
|
</p>
|
||||||
|
<div className="card">
|
||||||
|
<ActivateClient
|
||||||
|
prefillCode={searchParams.code ?? ''}
|
||||||
|
currentSubdomain={tunnel?.subdomain ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Pairing>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Pairing>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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 <device-token>` 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<DeviceTokenRow | null> {
|
||||||
|
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<DeviceTokenRow>();
|
||||||
|
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<Tunnel>();
|
||||||
|
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<Tunnel>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+1
-1
@@ -114,7 +114,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|favicon.ico|api/tunnel/claim).*)',
|
'/((?!_next/static|_next/image|favicon.ico|api/tunnel/claim|api/device).*)',
|
||||||
'/admin/:path*',
|
'/admin/:path*',
|
||||||
'/api/admin/:path*',
|
'/api/admin/:path*',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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.';
|
||||||
Reference in New Issue
Block a user