feat(device): OAuth2 device-pairing flow for HA add-on (code/approve/token/tunnel + /activate)

This commit is contained in:
Gerhard Scheikl
2026-06-01 10:35:41 +02:00
parent 2256f8359b
commit 2c2afa458e
9 changed files with 863 additions and 1 deletions
+224
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+141
View File
@@ -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 });
}
+63
View File
@@ -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,
});
}
+106
View File
@@ -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 });
}
+138
View File
@@ -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 });
}
+58
View File
@@ -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
View File
@@ -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.';