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
+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 });
}