428 lines
12 KiB
TypeScript
428 lines
12 KiB
TypeScript
import type { AuthContext } from "./auth.js";
|
|
import type { Env } from "./bindings.js";
|
|
import {
|
|
type DeviceApprovalDocument,
|
|
type DeviceApprovalRequest,
|
|
type DeviceApprovalRow,
|
|
type DeviceListDocument,
|
|
type DeviceRegistrationDocument,
|
|
type DeviceRevocationDocument,
|
|
type DeviceRevocationRequest,
|
|
type DeviceRevocationRow,
|
|
type DeviceRow,
|
|
DevicePermissionError,
|
|
DevicePersistenceError,
|
|
approvedDeviceDocument,
|
|
currentDeviceId,
|
|
deviceApprovalRequest,
|
|
deviceDocument,
|
|
deviceIdValue,
|
|
deviceRegistrationRequest,
|
|
deviceRevocationRequest,
|
|
revokedDeviceDocument,
|
|
timestamp,
|
|
} from "./device_schema.js";
|
|
|
|
export {
|
|
DevicePermissionError,
|
|
DevicePersistenceError,
|
|
DeviceSchemaError,
|
|
} from "./device_schema.js";
|
|
|
|
const DEVICE_LIST_QUERY = `
|
|
SELECT
|
|
device_id,
|
|
public_key,
|
|
device_name,
|
|
platform,
|
|
approval_status,
|
|
created_at,
|
|
approved_at,
|
|
last_active_at,
|
|
revoked_at
|
|
FROM user_devices
|
|
WHERE user_id = ?
|
|
ORDER BY
|
|
revoked_at IS NOT NULL,
|
|
COALESCE(last_active_at, approved_at, created_at) DESC,
|
|
device_id ASC
|
|
`;
|
|
const DEVICE_REGISTER_QUERY = `
|
|
INSERT INTO user_devices (
|
|
user_id,
|
|
device_id,
|
|
public_key,
|
|
device_name,
|
|
platform,
|
|
approval_status,
|
|
created_at,
|
|
approved_at,
|
|
last_active_at,
|
|
revoked_at,
|
|
idempotency_key
|
|
) VALUES (?, ?, ?, ?, ?, 'pending', ?, NULL, ?, NULL, ?)
|
|
ON CONFLICT(user_id, idempotency_key) DO NOTHING
|
|
`;
|
|
const DEVICE_BY_IDEMPOTENCY_KEY_QUERY = `
|
|
SELECT
|
|
device_id,
|
|
public_key,
|
|
device_name,
|
|
platform,
|
|
approval_status,
|
|
created_at,
|
|
approved_at,
|
|
last_active_at,
|
|
revoked_at
|
|
FROM user_devices
|
|
WHERE user_id = ? AND idempotency_key = ?
|
|
`;
|
|
const DEVICE_BY_ID_QUERY = `
|
|
SELECT
|
|
device_id,
|
|
public_key,
|
|
device_name,
|
|
platform,
|
|
approval_status,
|
|
created_at,
|
|
approved_at,
|
|
last_active_at,
|
|
revoked_at
|
|
FROM user_devices
|
|
WHERE user_id = ? AND device_id = ?
|
|
`;
|
|
const APPROVED_DEVICE_QUERY = `
|
|
SELECT
|
|
device_id,
|
|
public_key,
|
|
device_name,
|
|
platform,
|
|
approval_status,
|
|
created_at,
|
|
approved_at,
|
|
last_active_at,
|
|
revoked_at
|
|
FROM user_devices
|
|
WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
|
|
`;
|
|
const DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY = `
|
|
SELECT
|
|
device_id,
|
|
requester_device_id,
|
|
status,
|
|
decided_at
|
|
FROM device_approvals
|
|
WHERE user_id = ? AND idempotency_key = ?
|
|
`;
|
|
const DEVICE_APPROVAL_INSERT_QUERY = `
|
|
INSERT INTO device_approvals (
|
|
user_id,
|
|
approval_id,
|
|
device_id,
|
|
requester_device_id,
|
|
status,
|
|
requested_at,
|
|
decided_at,
|
|
expires_at,
|
|
idempotency_key
|
|
) VALUES (?, ?, ?, ?, 'approved', ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, idempotency_key) DO NOTHING
|
|
`;
|
|
const DEVICE_APPROVE_QUERY = `
|
|
UPDATE user_devices
|
|
SET
|
|
approval_status = 'approved',
|
|
approved_at = COALESCE(approved_at, ?),
|
|
last_active_at = ?
|
|
WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL
|
|
`;
|
|
const DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY = `
|
|
SELECT
|
|
actor_device_id,
|
|
subject_id,
|
|
outcome,
|
|
created_at
|
|
FROM audit_events
|
|
WHERE user_id = ? AND event_id = ? AND event_type = 'device.revoke'
|
|
`;
|
|
const DEVICE_REVOCATION_INSERT_QUERY = `
|
|
INSERT INTO audit_events (
|
|
event_id,
|
|
user_id,
|
|
actor_device_id,
|
|
event_type,
|
|
subject_type,
|
|
subject_id,
|
|
outcome,
|
|
metadata_hash,
|
|
created_at
|
|
) VALUES (?, ?, ?, 'device.revoke', 'device', ?, 'success', NULL, ?)
|
|
ON CONFLICT(event_id) DO NOTHING
|
|
`;
|
|
const DEVICE_REVOKE_QUERY = `
|
|
UPDATE user_devices
|
|
SET
|
|
approval_status = 'revoked',
|
|
revoked_at = COALESCE(revoked_at, ?)
|
|
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL
|
|
`;
|
|
const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = `
|
|
INSERT INTO better_auth_session_device_context (
|
|
session_id,
|
|
user_id,
|
|
device_id,
|
|
updated_at
|
|
)
|
|
SELECT ?, ?, ?, ?
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM better_auth_session
|
|
WHERE id = ? AND userId = ?
|
|
)
|
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
user_id = excluded.user_id,
|
|
device_id = excluded.device_id,
|
|
updated_at = excluded.updated_at
|
|
`;
|
|
|
|
export async function deviceListDocument(
|
|
env: Env,
|
|
context: AuthContext,
|
|
): Promise<DeviceListDocument> {
|
|
const result = await env.ELY_DB.prepare(DEVICE_LIST_QUERY).bind(context.userId).all<DeviceRow>();
|
|
return {
|
|
version: 1,
|
|
user_id: context.userId,
|
|
devices: result.results.map((row) => deviceDocument(row, context.deviceId)),
|
|
};
|
|
}
|
|
|
|
export async function registerDeviceDocument(
|
|
request: Request,
|
|
env: Env,
|
|
context: AuthContext,
|
|
nowSeconds = Math.floor(Date.now() / 1000),
|
|
): Promise<DeviceRegistrationDocument> {
|
|
const registration = await deviceRegistrationRequest(request);
|
|
if (context.deviceId !== undefined && context.deviceId !== registration.deviceId) {
|
|
throw new DevicePermissionError("device_context_mismatch");
|
|
}
|
|
|
|
await env.ELY_DB.prepare(DEVICE_REGISTER_QUERY)
|
|
.bind(
|
|
context.userId,
|
|
registration.deviceId,
|
|
registration.publicKey,
|
|
registration.deviceName,
|
|
registration.platform,
|
|
nowSeconds,
|
|
nowSeconds,
|
|
registration.idempotencyKey,
|
|
)
|
|
.run();
|
|
const row = await env.ELY_DB.prepare(DEVICE_BY_IDEMPOTENCY_KEY_QUERY)
|
|
.bind(context.userId, registration.idempotencyKey)
|
|
.first<DeviceRow>();
|
|
if (row === null) {
|
|
throw new DevicePersistenceError("device_registration_missing");
|
|
}
|
|
await bindSessionDeviceContext(env, context, registration.deviceId, nowSeconds);
|
|
|
|
return {
|
|
version: 1,
|
|
user_id: context.userId,
|
|
device: deviceDocument(row, registration.deviceId),
|
|
};
|
|
}
|
|
|
|
async function bindSessionDeviceContext(
|
|
env: Env,
|
|
context: AuthContext,
|
|
deviceId: string,
|
|
nowSeconds: number,
|
|
): Promise<void> {
|
|
await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY)
|
|
.bind(context.sessionId, context.userId, deviceId, nowSeconds, context.sessionId, context.userId)
|
|
.run();
|
|
}
|
|
|
|
export async function approveDeviceDocument(
|
|
request: Request,
|
|
env: Env,
|
|
context: AuthContext,
|
|
nowSeconds = Math.floor(Date.now() / 1000),
|
|
): Promise<DeviceApprovalDocument> {
|
|
const approval = await deviceApprovalRequest(request);
|
|
const requesterDeviceId = currentDeviceId(context);
|
|
if (requesterDeviceId === approval.deviceId) {
|
|
throw new DevicePermissionError("device_self_approval_forbidden");
|
|
}
|
|
|
|
await assertApprovedRequester(env, context.userId, requesterDeviceId);
|
|
const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY)
|
|
.bind(context.userId, approval.idempotencyKey)
|
|
.first<DeviceApprovalRow>();
|
|
if (existingApproval !== null) {
|
|
return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval);
|
|
}
|
|
|
|
const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId);
|
|
if (pendingDevice === null) {
|
|
throw new DevicePermissionError("device_not_found");
|
|
}
|
|
const pendingDocument = deviceDocument(pendingDevice, requesterDeviceId);
|
|
if (pendingDocument.approval_status !== "pending" || pendingDocument.revoked_at !== null) {
|
|
throw new DevicePermissionError("device_not_pending");
|
|
}
|
|
|
|
await env.ELY_DB.batch([
|
|
env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind(
|
|
context.userId,
|
|
approval.idempotencyKey,
|
|
approval.deviceId,
|
|
requesterDeviceId,
|
|
nowSeconds,
|
|
nowSeconds,
|
|
nowSeconds,
|
|
approval.idempotencyKey,
|
|
),
|
|
env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind(
|
|
nowSeconds,
|
|
nowSeconds,
|
|
context.userId,
|
|
approval.deviceId,
|
|
),
|
|
]);
|
|
|
|
const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId);
|
|
if (approvedDevice === null) {
|
|
throw new DevicePersistenceError("device_approval_missing");
|
|
}
|
|
return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice);
|
|
}
|
|
|
|
export async function revokeDeviceDocument(
|
|
request: Request,
|
|
env: Env,
|
|
context: AuthContext,
|
|
nowSeconds = Math.floor(Date.now() / 1000),
|
|
): Promise<DeviceRevocationDocument> {
|
|
const revocation = await deviceRevocationRequest(request);
|
|
const requesterDeviceId = currentDeviceId(context);
|
|
if (requesterDeviceId === revocation.deviceId) {
|
|
throw new DevicePermissionError("device_self_revocation_forbidden");
|
|
}
|
|
|
|
const revocationEventId = deviceRevocationEventId(context.userId, revocation.idempotencyKey);
|
|
await assertApprovedRequester(env, context.userId, requesterDeviceId);
|
|
const existingRevocation = await env.ELY_DB.prepare(DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY)
|
|
.bind(context.userId, revocationEventId)
|
|
.first<DeviceRevocationRow>();
|
|
if (existingRevocation !== null) {
|
|
return existingRevocationDocument(env, context, revocation, requesterDeviceId, existingRevocation);
|
|
}
|
|
|
|
const targetDevice = await deviceRowById(env, context.userId, revocation.deviceId);
|
|
if (targetDevice === null) {
|
|
throw new DevicePermissionError("device_not_found");
|
|
}
|
|
const targetDocument = deviceDocument(targetDevice, requesterDeviceId);
|
|
if (targetDocument.revoked_at !== null) {
|
|
throw new DevicePermissionError("device_already_revoked");
|
|
}
|
|
|
|
await env.ELY_DB.batch([
|
|
env.ELY_DB.prepare(DEVICE_REVOCATION_INSERT_QUERY).bind(
|
|
revocationEventId,
|
|
context.userId,
|
|
requesterDeviceId,
|
|
revocation.deviceId,
|
|
nowSeconds,
|
|
),
|
|
env.ELY_DB.prepare(DEVICE_REVOKE_QUERY).bind(nowSeconds, context.userId, revocation.deviceId),
|
|
]);
|
|
|
|
const revokedDevice = await deviceRowById(env, context.userId, revocation.deviceId);
|
|
if (revokedDevice === null) {
|
|
throw new DevicePersistenceError("device_revocation_missing");
|
|
}
|
|
return revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice);
|
|
}
|
|
|
|
function deviceRevocationEventId(userId: string, idempotencyKey: string): string {
|
|
return `device-revoke:${userId}:${idempotencyKey}`;
|
|
}
|
|
|
|
async function existingApprovalDocument(
|
|
env: Env,
|
|
context: AuthContext,
|
|
approval: DeviceApprovalRequest,
|
|
requesterDeviceId: string,
|
|
row: DeviceApprovalRow,
|
|
): Promise<DeviceApprovalDocument> {
|
|
const approvedDeviceId = deviceIdValue(row.device_id, "device_id");
|
|
const approvedByDeviceId = deviceIdValue(row.requester_device_id, "requester_device_id");
|
|
if (
|
|
approvedDeviceId !== approval.deviceId ||
|
|
approvedByDeviceId !== requesterDeviceId ||
|
|
row.status !== "approved"
|
|
) {
|
|
throw new DevicePermissionError("device_approval_replay_mismatch");
|
|
}
|
|
|
|
const approvedDevice = await deviceRowById(env, context.userId, approvedDeviceId);
|
|
if (approvedDevice === null) {
|
|
throw new DevicePersistenceError("device_approval_missing");
|
|
}
|
|
|
|
return {
|
|
...approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice),
|
|
approved_at: timestamp(row.decided_at, "decided_at"),
|
|
};
|
|
}
|
|
|
|
async function existingRevocationDocument(
|
|
env: Env,
|
|
context: AuthContext,
|
|
revocation: DeviceRevocationRequest,
|
|
requesterDeviceId: string,
|
|
row: DeviceRevocationRow,
|
|
): Promise<DeviceRevocationDocument> {
|
|
const revokedDeviceId = deviceIdValue(row.subject_id, "subject_id");
|
|
const revokedByDeviceId = deviceIdValue(row.actor_device_id, "actor_device_id");
|
|
if (
|
|
revokedDeviceId !== revocation.deviceId ||
|
|
revokedByDeviceId !== requesterDeviceId ||
|
|
row.outcome !== "success"
|
|
) {
|
|
throw new DevicePermissionError("device_revocation_replay_mismatch");
|
|
}
|
|
|
|
const revokedDevice = await deviceRowById(env, context.userId, revokedDeviceId);
|
|
if (revokedDevice === null) {
|
|
throw new DevicePersistenceError("device_revocation_missing");
|
|
}
|
|
|
|
return {
|
|
...revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice),
|
|
revoked_at: timestamp(row.created_at, "created_at"),
|
|
};
|
|
}
|
|
|
|
async function assertApprovedRequester(
|
|
env: Env,
|
|
userId: string,
|
|
requesterDeviceId: string,
|
|
): Promise<void> {
|
|
const requester = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY)
|
|
.bind(userId, requesterDeviceId)
|
|
.first<DeviceRow>();
|
|
if (requester === null) {
|
|
throw new DevicePermissionError("requester_device_unapproved");
|
|
}
|
|
}
|
|
|
|
async function deviceRowById(env: Env, userId: string, deviceId: string): Promise<DeviceRow | null> {
|
|
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
|
}
|