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 = {
|
||||
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*',
|
||||
],
|
||||
|
||||
@@ -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