Repository navigation
feat(device-pairing): pair phones and browsers with a QR code #461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f8e4cfe
106e8f5
b9d8d18
cbe4f7e
6e398c4
a769d3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import { createHash, randomBytes } from 'node:crypto' | ||
| import type { BetterAuthPlugin } from 'better-auth' | ||
| import { APIError, createAuthEndpoint, sessionMiddleware } from 'better-auth/api' | ||
| import { setSessionCookie } from 'better-auth/cookies' | ||
| import { z } from 'zod' | ||
| import { | ||
| DEVICE_PAIRING_CODE_TTL_SECONDS, | ||
| DEVICE_PAIRING_ERROR_CODES, | ||
| PAIRING_CODE_INVALID_MESSAGE, | ||
| RedeemDevicePairingCodeRequestSchema, | ||
| type PairedDevice, | ||
| } from '@opencode-manager/shared/schemas' | ||
| import { logger } from '../utils/logger' | ||
|
|
||
| const PAIRING_IDENTIFIER_PREFIX = 'device-pairing:' | ||
| const REDEEM_PATH = '/device-pairing/redeem' | ||
| const DEVICES_PATH = '/device-pairing/devices' | ||
| const REVOKE_PATH = '/device-pairing/devices/revoke' | ||
|
|
||
| type SessionRecord = { | ||
| id: string | ||
| createdAt: Date | ||
| updatedAt: Date | ||
| ipAddress?: string | null | ||
| userAgent?: string | null | ||
| deviceName?: string | null | ||
| } | ||
|
|
||
| function toPairedDevice(session: SessionRecord, currentSessionId: string): PairedDevice { | ||
| return { | ||
| id: session.id, | ||
| deviceName: session.deviceName ?? null, | ||
| userAgent: session.userAgent || null, | ||
| ipAddress: session.ipAddress || null, | ||
| createdAt: new Date(session.createdAt).toISOString(), | ||
| updatedAt: new Date(session.updatedAt).toISOString(), | ||
| isCurrent: session.id === currentSessionId, | ||
| } | ||
| } | ||
|
|
||
| function pairingIdentifier(code: string): string { | ||
| return `${PAIRING_IDENTIFIER_PREFIX}${createHash('sha256').update(code).digest('base64url')}` | ||
| } | ||
|
|
||
| function invalidPairingCodeError(): APIError { | ||
| return new APIError('BAD_REQUEST', { | ||
| message: PAIRING_CODE_INVALID_MESSAGE, | ||
| code: DEVICE_PAIRING_ERROR_CODES.PAIRING_CODE_INVALID, | ||
| }) | ||
| } | ||
|
|
||
| export interface DevicePairingOptions { | ||
| onDeviceRevoked?: (sessionId: string) => void | ||
| } | ||
|
|
||
| /** Adds pairing code, paired device, and revoke endpoints to Better Auth. */ | ||
| export function devicePairing(options: DevicePairingOptions = {}) { | ||
| const { onDeviceRevoked } = options | ||
| return { | ||
| id: 'device-pairing', | ||
| schema: { | ||
| session: { | ||
| fields: { | ||
| deviceName: { type: 'string', required: false, input: false }, | ||
| }, | ||
| }, | ||
| }, | ||
| endpoints: { | ||
| createDevicePairingCode: createAuthEndpoint.serverOnly( | ||
| { method: 'POST', use: [sessionMiddleware], requireHeaders: true }, | ||
| async (ctx) => { | ||
| const code = randomBytes(32).toString('base64url') | ||
| const expiresAt = new Date(Date.now() + DEVICE_PAIRING_CODE_TTL_SECONDS * 1000) | ||
|
|
||
| await ctx.context.adapter.deleteMany({ | ||
| model: 'verification', | ||
| where: [ | ||
| { field: 'identifier', value: PAIRING_IDENTIFIER_PREFIX, operator: 'starts_with' }, | ||
| { field: 'expiresAt', value: new Date(), operator: 'lt' }, | ||
| ], | ||
| }) | ||
| await ctx.context.internalAdapter.createVerificationValue({ | ||
| identifier: pairingIdentifier(code), | ||
| value: ctx.context.session.user.id, | ||
| expiresAt, | ||
| }) | ||
|
|
||
| return ctx.json({ code, expiresAt }) | ||
| }, | ||
| ), | ||
| redeemDevicePairingCode: createAuthEndpoint( | ||
| REDEEM_PATH, | ||
| { | ||
| method: 'POST', | ||
| body: RedeemDevicePairingCodeRequestSchema, | ||
| metadata: { noStore: true }, | ||
| }, | ||
| async (ctx) => { | ||
| const { code, deviceName } = ctx.body | ||
| const record = await ctx.context.internalAdapter.consumeVerificationValue(pairingIdentifier(code)) | ||
| if (!record) throw invalidPairingCodeError() | ||
|
|
||
| const user = await ctx.context.internalAdapter.findUserById(record.value) | ||
| if (!user) throw invalidPairingCodeError() | ||
|
|
||
| const session = await ctx.context.internalAdapter.createSession( | ||
| user.id, | ||
| false, | ||
| deviceName ? { deviceName } : undefined, | ||
| ) | ||
| await setSessionCookie(ctx, { session, user }) | ||
|
|
||
| return ctx.json({ user: { id: user.id, name: user.name, email: user.email } }) | ||
| }, | ||
| ), | ||
| listPairedDevices: createAuthEndpoint( | ||
| DEVICES_PATH, | ||
| { method: 'GET', use: [sessionMiddleware], requireHeaders: true }, | ||
| async (ctx) => { | ||
| const currentSessionId = ctx.context.session.session.id | ||
| const sessions = await ctx.context.internalAdapter.listSessions(ctx.context.session.user.id, { | ||
| onlyActiveSessions: true, | ||
| }) | ||
| const devices = sessions | ||
| .map((session) => toPairedDevice(session, currentSessionId)) | ||
| .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) | ||
|
|
||
| return ctx.json({ devices }) | ||
| }, | ||
| ), | ||
| revokePairedDevice: createAuthEndpoint( | ||
| REVOKE_PATH, | ||
| { | ||
| method: 'POST', | ||
| body: z.object({ id: z.string().min(1) }), | ||
| use: [sessionMiddleware], | ||
| requireHeaders: true, | ||
| }, | ||
| async (ctx) => { | ||
| const sessions = await ctx.context.internalAdapter.listSessions(ctx.context.session.user.id, { | ||
| onlyActiveSessions: true, | ||
| }) | ||
| const target = sessions.find((session) => session.id === ctx.body.id) | ||
| if (!target) { | ||
| throw new APIError('NOT_FOUND', { | ||
| message: 'This device is no longer paired', | ||
| code: DEVICE_PAIRING_ERROR_CODES.DEVICE_NOT_FOUND, | ||
| }) | ||
| } | ||
| if (target.id === ctx.context.session.session.id) { | ||
| throw new APIError('BAD_REQUEST', { | ||
| message: 'The current device cannot be revoked', | ||
| code: DEVICE_PAIRING_ERROR_CODES.CANNOT_REVOKE_CURRENT_SESSION, | ||
| }) | ||
| } | ||
|
|
||
| await ctx.context.internalAdapter.deleteSession(target.token) | ||
| if (onDeviceRevoked) { | ||
| try { | ||
| onDeviceRevoked(target.id) | ||
| } catch (error) { | ||
| logger.error(`Failed to delete push subscriptions for revoked session ${target.id}`, error) | ||
| } | ||
| } | ||
| return ctx.json({ status: true }) | ||
| }, | ||
| ), | ||
| }, | ||
| } satisfies BetterAuthPlugin | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import type { Database } from 'bun:sqlite' | ||
|
|
||
| /** Reports whether a Better Auth session row still exists and has not expired. */ | ||
| export function isAuthSessionActive(db: Database, sessionId: string, now: number = Date.now()): boolean { | ||
| const row = db | ||
| .prepare('SELECT expiresAt FROM "session" WHERE id = ?') | ||
| .get(sessionId) as { expiresAt: string | number } | null | ||
|
|
||
| if (!row) return false | ||
|
|
||
| const expiresAt = typeof row.expiresAt === 'number' ? row.expiresAt : Date.parse(row.expiresAt) | ||
| return Number.isFinite(expiresAt) && expiresAt > now | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import type { Migration } from '../migration-runner' | ||
|
|
||
| interface ColumnInfo { | ||
| name: string | ||
| } | ||
|
|
||
| const migration: Migration = { | ||
| id: '202610221800-session-device-name', | ||
| up(db) { | ||
| const tableInfo = db.prepare('PRAGMA table_info("session")').all() as ColumnInfo[] | ||
| if (!tableInfo.some((column) => column.name === 'deviceName')) { | ||
| db.run('ALTER TABLE "session" ADD COLUMN deviceName TEXT') | ||
| } | ||
| }, | ||
| down(db) { | ||
| void db | ||
| }, | ||
| } | ||
|
|
||
| export default migration |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import type { Migration } from '../migration-runner' | ||
| import { ensurePushSubscriptionsTable } from '../../db/push-subscriptions' | ||
|
|
||
| const migration: Migration = { | ||
| id: '202610231200-push-subscription-session-id', | ||
| up(db) { | ||
| ensurePushSubscriptionsTable(db) | ||
| }, | ||
| down(db) { | ||
| void db | ||
| }, | ||
| } | ||
|
|
||
| export default migration |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import type { Database } from 'bun:sqlite' | ||
|
|
||
| /** Creates the push subscriptions schema and adds the columns older databases lack. */ | ||
| export function ensurePushSubscriptionsTable(db: Database): void { | ||
| db.run(` | ||
| CREATE TABLE IF NOT EXISTS push_subscriptions ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| user_id TEXT NOT NULL, | ||
| endpoint TEXT NOT NULL UNIQUE, | ||
| p256dh TEXT NOT NULL, | ||
| auth TEXT NOT NULL, | ||
| device_name TEXT, | ||
| session_id TEXT, | ||
| created_at INTEGER NOT NULL, | ||
| last_used_at INTEGER | ||
| ) | ||
| `) | ||
|
|
||
| const columns = db.prepare('PRAGMA table_info(push_subscriptions)').all() as Array<{ name: string }> | ||
| if (!columns.some((column) => column.name === 'session_id')) { | ||
| db.run('ALTER TABLE push_subscriptions ADD COLUMN session_id TEXT') | ||
| } | ||
|
|
||
| db.run('CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id)') | ||
| db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint)') | ||
| db.run('CREATE INDEX IF NOT EXISTS idx_push_sub_session ON push_subscriptions(session_id)') | ||
| } | ||
|
|
||
| /** Deletes the push subscriptions registered by any of the given Better Auth sessions. */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | Remove the new code comment. The function name states what the helper deletes. Remove the comment to meet the repository rule: “Any code comments (code must be self-documenting).” As per coding guidelines: “Any code comments (code must be self-documenting).”
|
||
| export function deletePushSubscriptionsForSessions(db: Database, sessionIds: string[]): number { | ||
| if (sessionIds.length === 0) return 0 | ||
| const placeholders = sessionIds.map(() => '?').join(', ') | ||
| return db | ||
| .prepare(`DELETE FROM push_subscriptions WHERE session_id IN (${placeholders})`) | ||
| .run(...sessionIds).changes | ||
| } | ||
|
|
||
| /** Deletes the push subscriptions a revoked Better Auth session registered. */ | ||
| export function deletePushSubscriptionsForSession(db: Database, sessionId: string): number { | ||
| return deletePushSubscriptionsForSessions(db, [sessionId]) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove the new code comment.
The function name states its purpose. Remove the comment to meet the repository rule: “Any code comments (code must be self-documenting).” As per coding guidelines, “Any code comments (code must be self-documenting).”
Source: Coding guidelines