feat(device): OAuth2 device-pairing flow for HA add-on (code/approve/token/tunnel + /activate)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user