66 KiB
Device Authentication & Sync Implementation - Exact Code Changes
Document Purpose: This document contains every single line of code that needs to be added, modified, or removed to implement the device authentication and sync system. Each change is explained with context and follows PROJECT_GUIDELINES.md.
Implementation Status: Ready to implement Last Updated: Based on IMPLEMENTATION_PLAN.md (2026-02-12) Guidelines Compliance: Follows all PROJECT_GUIDELINES.md requirements
Table of Contents
- Phase 1: Enhanced Authentication (Week 1)
- Database Query Addition
- Middleware Enhancement
- Router Updates
- Backend Handler Addition
- Frontend Template Updates
- Phase 2: Kobo Integration (Week 1-2)
- Phase 3: OPDS Security (Week 2-3)
- Router Enhancement
- Bruno API Tests
- Phase 4: Testing & Documentation (Week 4)
- End-to-End Tests
- Security Documentation
Phase 1: Enhanced Authentication
Overview
Goal: Support both API key in URL path (Kobo) and Bearer token in header (KOReader)
Files to Modify:
internal/database/queries/queries.sql- Add token regeneration queryinternal/middleware/device_auth.go- Support URL path tokeninternal/router/sync.go- Update Kobo routesinternal/router/device.go- Add regenerate token routeinternal/handlers/devices.go- Add regenerate token handlertemplates/devices.templ- Add copy/regenerate UIweb/src/device-management.ts- Add device management TypeScriptbruno/sync-kobo/api.bru- Add URL path token requestsbruno/devices/regenerate-*.bru- New token regeneration test filesbruno/opds/*-*.bru- New OPDS authentication test files
1.1 Database Query Addition
File: internal/database/queries/queries.sql
Location: After line 770 (after UpdateDeviceLastSeen query)
Explanation: Add a new query to update only the auth_token field for token regeneration. This is separate from UpdateDevice which doesn't modify auth_token.
ADD THIS CODE:
-- name: UpdateDeviceAuthToken :one
UPDATE devices
SET
auth_token = $2,
updated_at = NOW()
WHERE id = $1
RETURNING *;
Complete Context (lines 764-776 after change):
-- name: UpdateDeviceLastSeen :one
UPDATE devices
SET
last_seen = NOW(),
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: UpdateDeviceAuthToken :one
UPDATE devices
SET
auth_token = $2,
updated_at = NOW()
WHERE id = $1
RETURNING *;
Verification: Run go build ./... after adding this query to ensure sqlc generates the new function correctly.
Database Schema Note: This query uses existing fields (devices.id, devices.auth_token, devices.updated_at). No schema change required - the database structure remains the same. This is purely a new SQL query for existing schema.
1.2 Middleware Enhancement
File: internal/middleware/device_auth.go
Location: Lines 37-108 (function Authenticate)
Current Implementation: Only checks Authorization: Bearer {token} header
Required Change: Add fallback to check URL path parameter and query parameter
REPLACE LINES 37-270 with:
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
var device database.Devices
var err error
// Method 1: Try Bearer token header (KOReader, API clients, OPDS)
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" {
if !strings.HasPrefix(authHeader, "Bearer ") {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "invalid authorization header format",
})
}
token := strings.TrimPrefix(authHeader, "Bearer ")
device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), token)
if err == nil {
return m.validateDevice(c, device)
}
}
// Method 2: Try URL path parameter (Kobo sync, OPDS)
// Route format: /api/sync/kobo/:token/...
urlToken := c.Param("token")
if urlToken != "" {
device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), urlToken)
if err == nil {
return m.validateDevice(c, device)
}
}
// Method 3: Try query parameter (OPDS catalog access)
// URL format: /opds/devices/:deviceId/catalog?token=...
queryToken := c.QueryParam("token")
if queryToken != "" {
device, err = m.db.GetDeviceByAuthToken(c.Request().Context(), queryToken)
if err == nil {
return m.validateDevice(c, device)
}
}
// All authentication methods failed
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "authentication required - use Bearer token or API key",
})
}
}
// validateDevice performs validation checks after successful authentication
func (m *DeviceAuthMiddleware) validateDevice(c echo.Context, device database.Devices) error {
if !device.SyncEnabled.Bool || !device.SyncEnabled.Valid {
return c.JSON(http.StatusForbidden, map[string]string{
"error": "device sync is disabled",
})
}
requestType := m.getRequestType(c.Request().URL.Path)
deviceUUID := uuid.UUID(device.ID.Bytes)
deviceID := deviceUUID.String()
config := DeviceRateLimitConfig{
SyncRequestsPerMinute: 60,
ProgressUpdatesPerMinute: 120,
MetadataRequestsPerMinute: 30,
}
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
c.Response().Header().Set("X-RateLimit-Limit", "60")
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
c.Response().Header().Set("X-RateLimit-Reset", "60")
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "rate limit exceeded",
"message": "Too many requests",
"remaining": strconv.Itoa(remaining),
})
}
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
c.Response().Header().Set("X-RateLimit-Limit", "60")
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
ctx := DeviceContext{
ID: device.ID.Bytes,
UserID: device.UserID.Bytes,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
DeviceIdentifier: device.DeviceIdentifier,
SyncEnabled: device.SyncEnabled.Bool && device.SyncEnabled.Valid,
AutoSync: device.AutoSync.Bool && device.AutoSync.Valid,
}
c.Set("device", device)
c.Set("device_ctx", ctx)
c.Set("device_id", device.ID.Bytes)
return next(c)
}
Explanation:
- Extracted validation logic into
validateDevice()helper function - Each auth method calls helper on success:
return m.validateDevice(c, device) - No goto statements - follows procedural style with clear control flow
- Helper function is testable, reusable, and maintainable
- Matches existing codebase patterns (no goto statements found)
Verification Steps:
- Run
go build ./internal/middleware - Run
go test ./internal/middleware/... -v - Review
git diff internal/middleware/device_auth.go
1.3 Router Updates - Kobo Routes
File: internal/router/sync.go
Location: Lines 31-38
Current Implementation:
// Kobo sync routes (device authentication required)
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
koboSync := e.Group("/api/sync/kobo")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
koboSync.POST("/v1/analytics/gettests", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.AnalyticsGettests))
koboSync.GET("/v1/initialization", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Initialization))
koboSync.POST("/sync-from-server", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.SyncFromServer))
Required Change: Change route group from /api/sync/kobo to /api/sync/kobo/:token
REPLACE LINE 33:
// BEFORE:
koboSync := e.Group("/api/sync/kobo")
// AFTER:
koboSync := e.Group("/api/sync/kobo/:token")
Complete Context After Change (lines 31-39):
// Kobo sync routes (device authentication required)
// Kobo devices use URL path: /api/sync/kobo/{token}/markup
// API clients can use Authorization header: Authorization: Bearer {token}
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
koboSync := e.Group("/api/sync/kobo/:token")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
koboSync.POST("/v1/analytics/gettests", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.AnalyticsGettests))
koboSync.GET("/v1/initialization", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Initialization))
koboSync.POST("/sync-from-server", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.SyncFromServer))
Explanation:
- URL path parameter
:tokenallows Kobo devices to send:POST /api/sync/kobo/dev_abc123.../markup - Middleware now extracts token from path (see middleware changes above)
- Routes also accept Bearer tokens for API clients/automation, but Kobo devices will never send Bearer tokens
- KOReader uses Bearer token exclusively, Kobo uses URL path token exclusively
Verification: Run go build ./internal/router
1.4 Router Updates - Device Token Regeneration
File: internal/router/device.go
Location: After device registration routes (around line 25)
Current Implementation: Need to check what device routes exist
Required Addition: Add route for token regeneration
ADD THIS ROUTE (find appropriate location with other device routes):
// Token regeneration endpoint (JWT authentication required)
devices.PUT("/:id/regenerate-token", jwtMiddleware, h.RegenerateDeviceToken)
Complete Context (token regeneration route added to existing device routes):
// Existing device management routes (unchanged)
devices.GET("", jwtMiddleware, cfg.DeviceHandler.ListDevices)
devices.GET("/:id", jwtMiddleware, cfg.DeviceHandler.GetDevice)
devices.PUT("/:id", jwtMiddleware, cfg.DeviceHandler.UpdateDevice)
devices.DELETE("/:id", jwtMiddleware, cfg.DeviceHandler.DeleteDevice)
// NEW: Token regeneration endpoint (JWT authentication required)
devices.PUT("/:id/regenerate-token", jwtMiddleware, cfg.DeviceHandler.RegenerateDeviceToken)
Verification: Run go build ./internal/router
1.5 Backend Handler - Token Regeneration
File: internal/handlers/devices.go
Location: After DeleteDevice function (after line 486)
Required Addition: Add new handler function for token regeneration
ADD THIS CODE:
func (h *DeviceHandler) RegenerateDeviceToken(c echo.Context) error {
// Verify JWT authentication
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user ID"})
}
// Parse device ID from URL parameter
deviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"})
}
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
// Verify device exists and belongs to user
device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"})
}
if device.UserID.Bytes != userUUID {
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
}
// Generate new auth token
newToken, err := generateDeviceToken()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"})
}
// Update device with new token
updatedDevice, err := h.db.UpdateDeviceAuthToken(c.Request().Context(), database.UpdateDeviceAuthTokenParams{
ID: pgDeviceID,
AuthToken: pgtype.Text{String: newToken, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update token"})
}
// Return new token with device info
syncEnabled := updatedDevice.SyncEnabled.Bool && updatedDevice.SyncEnabled.Valid
autoSync := updatedDevice.AutoSync.Bool && updatedDevice.AutoSync.Valid
syncFreq := int32(0)
if updatedDevice.SyncFrequencyMinutes.Valid {
syncFreq = updatedDevice.SyncFrequencyMinutes.Int32
}
// Build sync URLs with new token
syncURLs := map[string]string{}
baseURL := h.cfg.BaseURL
switch updatedDevice.DeviceType {
case "kobo":
syncURLs["sync_url"] = fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, newToken)
syncURLs["markup"] = fmt.Sprintf("%s/api/sync/kobo/%s/markup", baseURL, newToken)
syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken)
syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken)
case "koreader":
syncURLs["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", baseURL)
syncURLs["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL)
syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "Token regenerated successfully",
"auth_token": newToken,
"device": DeviceInfo{
ID: updatedDevice.ID.Bytes,
DeviceName: updatedDevice.DeviceName,
DeviceType: updatedDevice.DeviceType,
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
CreatedAt: updatedDevice.CreatedAt.Time,
DeviceMetadata: updatedDevice.DeviceMetadata,
},
"sync_urls": syncURLs,
})
}
Explanation:
- Validates JWT token (user must be logged in)
- Verifies device ownership (device belongs to user)
- Generates new token using existing
generateDeviceToken()helper - Updates
devices.auth_tokenusing newUpdateDeviceAuthTokenquery - Returns new token with device info and sync URLs
- For Kobo: returns URL with token in path (Kobo never uses Bearer header)
- For KOReader: returns token for Authorization header (KOReader never uses URL path token)
Verification:
- Run
go build ./internal/handlers - Run
go test ./internal/handlers/... -v - Test manually via Bruno collection
1.6 Frontend Template - Device Management UI
File: templates/devices.templ
Location: Multiple locations
Overview: After refactor (REFACTORING_PLAN.md Phase 1.3), templates use handlers.DeviceInfo directly - no conversion layer exists. Time fields are *time.Time (not strings).
Required Changes:
- Add
auth_tokenfield tohandlers.DeviceInfostruct - Add "Copy Sync URL" button for each device
- Add "Regenerate Token" button for each device
- Add JavaScript functions for copy and regenerate
- Update time formatting in templates (use
.Format()method)
1.6.1 Add AuthToken to handlers.DeviceInfo Struct
File: internal/handlers/devices.go
Location: Line 78 (after DeviceMetadata field in DeviceInfo struct)
ADD THIS FIELD:
type DeviceInfo struct {
ID uuid.UUID `json:"id"`
DeviceName string `json:"device_name"`
DeviceType string `json:"device_type"`
LastSync *time.Time `json:"last_sync"`
LastSeen *time.Time `json:"last_seen"`
SyncEnabled bool `json:"sync_enabled"`
AutoSync bool `json:"auto_sync"`
SyncFrequency int32 `json:"sync_frequency_minutes"`
CreatedAt time.Time `json:"created_at"`
DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"`
AuthToken string `json:"auth_token"` // NEW: Device API key for authentication
}
1.6.2 Update Device List Handlers
File: internal/handlers/devices.go
Location: GetDevicesData function (line 275-313) AND ListDevices function (line 255-267)
GetDevicesData - ADD FIELD TO RESPONSE (modify line 298-309):
deviceList = append(deviceList, DeviceInfo{
ID: device.ID.Bytes,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
LastSync: (*time.Time)(&device.LastSync.Time),
LastSeen: (*time.Time)(&device.LastSeen.Time),
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
CreatedAt: device.CreatedAt.Time,
DeviceMetadata: device.DeviceMetadata,
AuthToken: device.AuthToken, // NEW: Include auth token
})
ListDevices - ADD FIELD TO RESPONSE (modify line around 262):
Find the loop that constructs deviceList[i] and add AuthToken field:
deviceList[i] = DeviceInfo{
ID: device.ID.Bytes,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
LastSync: (*time.Time)(&device.LastSync.Time),
LastSeen: (*time.Time)(&device.LastSeen.Time),
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequency: syncFreq,
CreatedAt: device.CreatedAt.Time,
DeviceMetadata: device.DeviceMetadata,
AuthToken: device.AuthToken, // NEW: Include auth token
}
1.6.3 Update Device Card Template
File: templates/devices.templ
Location: Template function signature (line 5) and device card (lines 46-98)
Required Changes:
- Update template signature to accept baseURL parameter (line 5):
templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []PendingRegistrationData, baseURL string) {
- REPLACE DEVICE CARD CONTENT (lines 46-98) with:
for _, device := range devices {
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex items-start justify-between mb-4">
<div class="text-4xl">
if device.DeviceType == "koreader" {
📖
} else if device.DeviceType == "kobo" {
📚
} else if device.DeviceType == "web" {
🌐
} else {
📱
}
</div>
<div class="flex space-x-2">
<button onclick="showShelfMappings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
📚
</button>
<button onclick="showDeviceSettings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
⚙️
</button>
</div>
</div>
<h3 class="text-lg font-semibold mb-1" style="color: var(--text-primary)">{ device.DeviceName }</h3>
<p class="text-sm mb-4" style="color: var(--text-secondary)">{ device.DeviceType }</p>
<div class="space-y-2 text-sm mb-4">
<div class="flex justify-between">
<span style="color: var(--text-secondary)">Sync Status</span>
if device.SyncEnabled {
<span style="color: var(--accent)">✓ Enabled</span>
} else {
<span style="color: var(--text-secondary)">✗ Disabled</span>
}
</div>
<div class="flex justify-between">
<span style="color: var(--text-secondary)">Last Sync</span>
if device.LastSync != nil {
<span style="color: var(--text-primary)">{ device.LastSync.Format("2006-01-02 15:04") }</span>
} else {
<span style="color: var(--text-primary)">Never</span>
}
</div>
<div class="flex justify-between">
<span style="color: var(--text-secondary)">Last Seen</span>
if device.LastSeen != nil {
<span style="color: var(--text-primary)">{ device.LastSeen.Format("2006-01-02 15:04") }</span>
} else {
<span style="color: var(--text-primary)">Never</span>
}
</div>
</div>
<!-- NEW: Sync URL & Token Management -->
<div class="border-t pt-4" style="border-color: var(--border);">
<p class="text-xs font-semibold mb-2" style="color: var(--text-secondary)">DEVICE SYNC CONFIGURATION</p>
<!-- Security Warning Banner -->
<div class="mb-3 p-2 rounded" style="background-color: var(--bg-tertiary); border-left: 3px solid var(--accent);">
<p class="text-xs" style="color: var(--text-primary);">
<span class="font-semibold">⚠️ Security Notice:</span> This token is sensitive. Keep it secret. If compromised, regenerate immediately.
</p>
</div>
if device.DeviceType == "kobo" {
<!-- Kobo: Copy Full Sync URL -->
<div class="mb-3">
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Kobo Sync URL</label>
<div class="flex space-x-2">
<input
type="text"
id="sync-url-{ device.ID }"
readonly
value="{ fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, device.AuthToken) }"
class="flex-1 px-3 py-2 text-xs rounded border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
/>
<button
onclick="copyToClipboard('{ fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, device.AuthToken) }', 'Kobo sync URL', event)"
class="px-3 py-2 text-xs rounded hover:opacity-80"
style="background-color: var(--accent); color: white;"
>
📋 Copy
</button>
</div>
<p class="text-xs mt-1" style="color: var(--text-secondary);">Paste this URL into Kobo's <code class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary);">api_endpoint</code> setting</p>
</div>
}
if device.DeviceType == "koreader" {
<!-- KOReader: Copy Auth Token -->
<div class="mb-3">
<label class="block text-xs mb-1" style="color: var(--text-secondary)">Auth Token (for plugin)</label>
<div class="flex space-x-2">
<input
type="text"
id="auth-token-{ device.ID }"
readonly
value="{ device.AuthToken }"
class="flex-1 px-3 py-2 text-xs rounded border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border); font-family: monospace;"
/>
<button
onclick="copyToClipboard('{ device.AuthToken }', 'Auth token', event)"
class="px-3 py-2 text-xs rounded hover:opacity-80"
style="background-color: var(--accent); color: white;"
>
📋 Copy
</button>
</div>
<p class="text-xs mt-1" style="color: var(--text-secondary);">Enter this token in the KOReader plugin settings</p>
</div>
}
<!-- Regenerate Token Button -->
<button
onclick="regenerateDeviceToken('{ device.ID }', event)"
class="w-full px-3 py-2 text-xs rounded border hover:opacity-80"
style="border-color: var(--border); color: var(--text-secondary); background-color: var(--bg-primary);"
>
🔄 Regenerate Token
</button>
<p class="text-xs mt-1" style="color: var(--text-secondary);">⚠️ Old token will immediately stop working</p>
</div>
</div>
}
Explanation:
- Kobo devices: Show full sync URL with token in path
- KOReader devices: Show auth token only (plugin handles Authorization header)
- One-click copy buttons use clipboard API
- Regenerate button requires confirmation
- Clear setup instructions for each device type
Handler Update (internal/router/frontend.go:171):
err = templates.Devices(user, devices, pendingList, cfg.BaseURL).Render(c.Request().Context(), &buf)
Pass cfg.BaseURL to template instead of hardcoding URLs.
1.6.4 Add TypeScript Device Management
File: web/src/device-management.ts (CREATE NEW FILE)
Location: New TypeScript file for device management functions
CREATE THIS TYPESCRIPT:
// Device Management - Token copy and regeneration
// Procedural style with proper types (no OOP)
interface RegenerateTokenResponse {
message: string;
auth_token: string;
device: {
id: string;
device_name: string;
device_type: string;
auth_token: string;
sync_enabled: boolean;
auto_sync: boolean;
sync_frequency_minutes: number;
};
sync_urls?: {
sync_url?: string;
markup?: string;
bookmark?: string;
init?: string;
progress?: string;
metadata?: string;
bookmarks?: string;
};
}
// Copy sync URL or auth token to clipboard
function copyToClipboard(text: string, label: string): void {
navigator.clipboard.writeText(text)
.then(() => {
const toast = (window as any).showToast;
if (toast) {
toast.success(`${label} copied to clipboard`);
}
})
.catch((err: unknown) => {
console.error('Failed to copy:', err);
const toast = (window as any).showToast;
if (toast) {
toast.error('Failed to copy to clipboard');
}
});
}
// Regenerate device token with confirmation
function regenerateDeviceToken(deviceId: string, event: Event): void {
const confirmation = '⚠️ This will revoke current token and generate a new one.\n\n' +
'The old token will immediately stop working.\n\n' +
'You will need to update your device configuration with new token.\n\n' +
'Continue?';
if (!confirm(confirmation)) {
return;
}
const btn = event.target as HTMLButtonElement;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '🔄 Regenerating...';
fetch(`/api/devices/${deviceId}/regenerate-token`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
}
})
.then((response: Response) => {
if (!response.ok) {
throw new Error('Failed to regenerate token');
}
return response.json() as Promise<RegenerateTokenResponse>;
})
.then((data: RegenerateTokenResponse) => {
const toast = (window as any).showToast;
if (toast) {
toast.success('Token regenerated successfully - update your device config');
}
// Reload page to show new token
setTimeout(() => location.reload(), 1500);
})
.catch((error: unknown) => {
console.error('Error:', error);
const toast = (window as any).showToast;
if (toast) {
toast.error('Failed to regenerate token');
}
if (btn) {
btn.disabled = false;
btn.innerHTML = originalText;
}
});
}
// Export functions for global access (called from template onclick attributes)
declare global {
function copyToClipboard(text: string, label: string): void;
function regenerateDeviceToken(deviceId: string): void;
}
window.copyToClipboard = copyToClipboard;
window.regenerateDeviceToken = regenerateDeviceToken;
Explanation:
- Proper TypeScript type annotations
- Interface for API response
- Type assertions for
windowglobal - Procedural functions (no classes, no
this) - Follows existing
toast.tspattern - Exports functions to
windowfor template access
**Verification**:
1. Run `npm run build:ts` to compile TypeScript
2. Run `go build ./...`
3. Test in browser:
- Click "Copy" button - should copy sync URL/token
- Click "Regenerate" - should show confirmation
- Confirm regeneration - should show success toast
- Page should reload with new token
**Build Process**:
```bash
# Compile TypeScript to JavaScript
npm run build:ts
# Output: web/static/device-management.js
# Template references: <script src="/static/device-management.js"></script>
1.7 Bruno API Tests - Token Regeneration
File: bruno/devices/regenerate-token.bru
CREATE NEW FILE:
meta {
name: Regenerate Device Token
type: http
seq: 1
}
put {
url: {{base_url}}/api/devices/{{device_id}}/regenerate-token
body: none
auth: inherit
}
docs {
## Regenerate Device Token
Regenerates auth token for a device, invalidating old token immediately.
**Method:** PUT
**Endpoint:** /api/devices/{device_id}/regenerate-token
**Authentication:** Bearer token (JWT)
**Path Parameters:**
- `device_id` (string): Device UUID
**Response:**
- `message` (string): Success message
- `auth_token` (string): New auth token
- `device` (object): Updated device details
- `sync_urls` (object): Device-specific sync URLs with new token
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Forbidden (device belongs to different user)
- 404: Device not found
- 500: Internal server error
**Important Notes:**
- Old token stops working immediately
- Device must be updated with new token to resume syncing
- No data loss - device ID remains same
**Example Response:**
```json
{
"message": "Token regenerated successfully",
"auth_token": "dev_abc123...",
"device": {
"id": "uuid-here",
"device_name": "My Kobo Clara",
"device_type": "kobo",
"sync_enabled": true,
"auto_sync": true,
"sync_frequency_minutes": 5,
"created_at": "2026-02-12T10:00:00Z",
"device_metadata": "{...}"
},
"sync_urls": {
"sync_url": "http://localhost:8765/api/sync/kobo/dev_new_token",
"markup": "http://localhost:8765/api/sync/kobo/dev_new_token/markup",
"bookmark": "http://localhost:8765/api/sync/kobo/dev_new_token/bookmark",
"init": "http://localhost:8765/api/sync/kobo/dev_new_token/v1/initialization"
}
}
Important Notes:
- Old token stops working immediately
- Device must be updated with new token to resume syncing
- No data loss - device ID remains same }
**File**: `bruno/devices/regenerate-token-unauthorized.bru`
**CREATE NEW FILE**:
```bruno
meta {
name: Regenerate Device Token - Unauthorized
type: http
seq: 2
}
put {
url: {{base_url}}/api/devices/{{device_id}}/regenerate-token
body: none
auth: none
}
docs {
## Regenerate Device Token - Unauthorized
Tests that token regeneration requires authentication.
**Expected Behavior:** Returns 401 Unauthorized when no Bearer token is provided
**Status Codes:**
- 401: Unauthorized (missing or invalid token)
**Use Case:** Verify authentication is required for token regeneration
}
File: bruno/devices/regenerate-token-forbidden.bru
CREATE NEW FILE:
meta {
name: Regenerate Device Token - Forbidden
type: http
seq: 3
}
put {
url: {{base_url}}/api/devices/{{other_device_id}}/regenerate-token
body: none
auth: inherit
}
docs {
## Regenerate Device Token - Forbidden
Tests that users cannot regenerate tokens for devices belonging to other users.
**Expected Behavior:** Returns 403 Forbidden when trying to regenerate token for another user's device
**Status Codes:**
- 403: Forbidden (device belongs to different user)
**Use Case:** Verify authorization - users can only manage their own devices
**Setup:** Use Bearer token from user A, try to regenerate token for user B's device
}
File: bruno/devices/regenerate-token-notfound.bru
CREATE NEW FILE:
meta {
name: Regenerate Device Token - Not Found
type: http
seq: 4
}
put {
url: {{base_url}}/api/devices/00000000-0000-0000-0000-000000000000/regenerate-token
body: none
auth: bearer
}
docs {
## Regenerate Device Token - Not Found
Tests that token regeneration returns 404 for non-existent devices.
**Expected Behavior:** Returns 404 Not Found when device UUID doesn't exist
**Status Codes:**
- 404: Device not found
**Use Case:** Verify proper error handling for invalid device IDs
**Setup:** Use all-zero UUID (guaranteed to not exist in database)
}
Verification: Test each request
1.8 Bruno API Tests - Update Kobo Sync
File: bruno/sync-kobo/sync-markup-url-token.bru
CREATE NEW FILE:
meta {
name: Sync Markup - URL Path Token
type: http
seq: 1
}
post {
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/markup
body: json
auth: none
}
headers {
Content-Type: application/json
}
body:json {
{
"ReadingSync": [
{
"ContentId": "book-uuid",
"PercentRead": 45.6,
"EntitlementId": "entitlement-id",
"RemainingTimeMinutes": 120,
"FirstReadTime": "2026-01-25T10:00:00Z",
"LastModified": "2026-01-30T20:00:00Z"
}
],
"BookmarkSync": []
}
}
docs {
## Sync Reading Progress - URL Path Token
Synchronizes reading progress from Kobo device using token in URL path.
**Method:** POST
**Endpoint:** /sync/kobo/{kobo_device_token}/markup
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
**Path Parameters:**
- `kobo_device_token` (string): Device auth token from devices.auth_token
**Request Body:**
- `ReadingSync` (array): Reading progress data
- `BookmarkSync` (array): Bookmarks and annotations
**Response:** Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Device sync disabled
- 500: Internal server error
**Important:** Kobo firmware requires token in URL path, cannot use Bearer header
**Use Case:** Kobo devices syncing reading progress via stock firmware
}
File: bruno/sync-kobo/sync-bookmark-url-token.bru
CREATE NEW FILE:
meta {
name: Sync Bookmark - URL Path Token
type: http
seq: 2
}
post {
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/bookmark
body: json
auth: none
}
headers {
Content-Type: application/json
}
body:json {
{
"ContentId": "book-uuid",
"BookmarkText": "Highlighted text",
"BookmarkType": "annotation",
"BookmarkTitle": "Chapter 3"
}
}
docs {
## Sync Bookmark - URL Path Token
Synchronizes bookmarks and annotations from Kobo device using token in URL path.
**Method:** POST
**Endpoint:** /sync/kobo/{kobo_device_token}/bookmark
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
**Path Parameters:**
- `kobo_device_token` (string): Device auth token from devices.auth_token
**Request Body:**
- `ContentId` (string): Book UUID
- `BookmarkText` (string): Highlighted or annotated text
- `BookmarkType` (string): Type (annotation, bookmark, note)
- `BookmarkTitle` (string): Title for the bookmark
**Response:** Success message
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Device sync disabled
- 500: Internal server error
**Important:** Kobo devices use URL path token exclusively, never Bearer header
**Use Case:** Kobo devices syncing bookmarks and highlights via stock firmware
}
File: bruno/sync-kobo/get-library-url-token.bru
CREATE NEW FILE:
meta {
name: Get Library - URL Path Token
type: http
seq: 3
}
get {
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/library
body: none
auth: none
}
docs {
## Get Kobo Library - URL Path Token
Retrieves library metadata for Kobo device using token in URL path.
**Method:** GET
**Endpoint:** /sync/kobo/{kobo_device_token}/library
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
**Path Parameters:**
- `kobo_device_token` (string): Device auth token from devices.auth_token
**Response:** Library metadata with book list
**Status Codes:**
- 200: Success (library metadata)
- 401: Unauthorized
- 403: Device sync disabled
- 404: Device not found
**Important:** Kobo devices use URL path token exclusively, never Bearer header
**Use Case:** Kobo devices retrieving library information via stock firmware
}
File: bruno/sync-kobo/get-initialization-url-token.bru
CREATE NEW FILE:
meta {
name: Kobo Initialization - URL Path Token
type: http
seq: 4
}
get {
url: {{base_url}}/sync/kobo/{{kobo_device_token}}/v1/initialization
body: none
auth: none
}
docs {
## Kobo Initialization - URL Path Token
Returns initialization data for Kobo device using token in URL path.
**Method:** GET
**Endpoint:** /sync/kobo/{kobo_device_token}/v1/initialization
**Authentication:** URL path parameter (Kobo devices never send Bearer tokens)
**Path Parameters:**
- `kobo_device_token` (string): Device auth token from devices.auth_token
**Response:** Initialization configuration and settings
**Status Codes:**
- 200: Success (initialization data)
- 401: Unauthorized
- 403: Device sync disabled
- 404: Device not found
**Important:** Kobo devices use URL path token exclusively, never Bearer header
**Use Case:** Kobo devices initializing sync connection via stock firmware
**Note:** This endpoint is called when Kobo first connects to sync server
}
KEEP EXISTING REQUESTS in bruno/sync-kobo/api.bru for API clients and testing (Bearer token works for non-Kobo clients)
Verification: Test both authentication methods work (Kobo uses URL path, API clients can use Bearer)
Phase 2: Kobo Integration
2.1 Kobo Setup Documentation
File: docs/user/devices/kobo-setup.md
Location: Full file rewrite needed
Current Documentation: Mentions serial number-based auth (outdated)
Required Update: Document API key in URL path approach
REPLACE ENTIRE FILE with:
# Kobo E-Reader Setup Guide
**Last Updated**: 2026-02-12
**Authentication Method**: API Key in URL path
This guide walks you through setting up your Kobo e-reader to sync with Bookhoard.
## Prerequisites
- Kobo e-reader (Kobo Clara, Libra, Forma, Sage, etc.)
- USB cable to connect Kobo to computer
- Bookhoard server running and accessible
- Kobo connected to same network as Bookhoard server
## Step 1: Find Your Kobo Serial Number
Your Kobo's serial number is used as the **device identifier** to uniquely identify your device.
1. On your Kobo, tap: **Settings** → **Device Information**
2. Look for **Serial Number** (format: `N1234567890123`)
3. Write down this serial number - you'll need it for registration
**Example**: `N7353019193781`
### Alternative: Find Serial Number via USB
1. Connect Kobo to computer via USB
2. Open `.adobe-digital-editions/` folder on Kobo
3. Open `device.xml` file
4. Find `<deviceSerial>` tag - this is your serial number
## Step 2: Register Device in Bookhoard
1. Open Bookhoard web interface in browser
2. Navigate to **Device Management**
3. Click **➕ Add New Device**
4. Fill in registration form:
- **Device Name**: `My Kobo Clara` (or your device name)
- **Device Type**: `Kobo E-Reader`
- **Device Identifier**: Enter your Kobo serial number (e.g., `N7353019193781`)
5. Click **Register Device**
## Step 3: Approve Device
1. You'll see a pending registration for your Kobo
2. Click **Approve** button
3. Device is now registered and has an API key (auth token)
## Step 4: Copy Kobo Sync URL
After approval, your Kobo device card shows:
**Kobo Sync URL**:
http://192.168.1.100:8765/api/sync/kobo/dev_abc123def456...
1. Click **📋 Copy** button next to the URL
2. This URL contains your device's API key
3. Keep this URL handy - you'll need it in the next step
**Important**: This URL is unique to your device. Don't share it publicly.
## Step 5: Configure Kobo Sync
### Connect Kobo to Computer
1. Connect Kobo to computer via USB cable
2. Kobo will appear as a USB drive (named "KOBOeReader")
3. Open the drive on your computer
### Edit Kobo Configuration
1. Navigate to `.kobo/Kobo/` folder
2. Open `Kobo eReader.conf` file in text editor
3. Find or add this line:
```ini
api_endpoint=http://YOUR_IP:8765/api/sync/kobo/YOUR_TOKEN
Example: api_endpoint=http://192.168.1.100:8765/api/sync/kobo/dev_73530191_937812_3abc4def5...
- Save the file
- Eject Kobo drive safely
- Kobo will restart automatically
What is api_endpoint?
This setting tells Kobo where to send sync data. By setting it to Bookhoard, Kobo will sync with your server instead of Kobo's store.
Security Note: The API key in this URL is a revocable token. If compromised, you can regenerate it from Device Management.
Step 6: Configure OPDS Catalog (Optional)
To browse and download books wirelessly:
- In Bookhoard Device Management, find your Kobo device
- Note the Device ID (UUID) shown in device details
- On Kobo, add OPDS catalog:
- Title:
Bookhoard - URL:
http://YOUR_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog?token=YOUR_TOKEN
- Title:
Example:
http://192.168.1.100:8765/opds/devices/550e8400-e29b-41d4-a716-4466554400000/catalog?token=dev_73530191_937812_3abc4def5...
Step 7: Test Sync
- Open a book on your Kobo
- Read a few pages
- Connect Kobo to WiFi
- Kobo will automatically sync progress to Bookhoard
- In Bookhoard, check Reading Progress - should show Kobo's progress
What Syncs Automatically:
- ✅ Reading progress (page position, percentage)
- ✅ Bookmarks
- ✅ Highlights
- ✅ Notes
Troubleshooting
Kobo Won't Sync
Problem: Kobo doesn't connect to Bookhoard
Solutions:
- Check
api_endpointinKobo eReader.conf- ensure URL is correct - Verify Kobo and Bookhoard are on same network
- Check Bookhoard server is running
- Ensure device sync is enabled in Bookhoard Device Management
Sync Fails with 401 Unauthorized
Problem: API key is invalid or device sync disabled
Solutions:
- Check Device Management - is sync enabled for this device?
- Regenerate token (click "🔄 Regenerate Token")
- Update
api_endpointin Kobo config with new token - Eject and restart Kobo
Multiple Kobo Devices
Problem: Have multiple Kobos - each needs separate registration
Solution:
- Each Kobo has unique serial number
- Register each Kobo separately
- Each gets unique API key
- Configure each Kobo with its own sync URL
Regenerating Your Token
If your API key is compromised or lost:
- In Bookhoard Device Management, find your Kobo
- Click 🔄 Regenerate Token
- Confirm regeneration
- Copy new sync URL
- Update
api_endpointin Kobo config with new URL - Eject and restart Kobo
Important: Old token stops working immediately. Update config promptly.
Network Setup
Local Network (Recommended)
Bookhoard works best on local network:
- Find Bookhoard server IP:
- Linux:
hostname -I - Mac:
System Settings → Network - Windows:
Command Prompt → ipconfig
- Linux:
- Use this IP in sync URL:
http://SERVER_IP:8765/... - Ensure Kobo and server are on same network
Remote Access (Advanced)
For remote sync, use reverse proxy with HTTPS:
- Set up reverse proxy (nginx, Caddy)
- Enable SSL/TLS (Let's Encrypt)
- Configure firewall rules
- Use VPN for secure remote access
Warning: Without HTTPS, API key can be intercepted on public networks.
Security Considerations
API Key in URL
Risks:
- API key visible in Kobo logs
- API key visible in server logs
- API key stored in plain text config file
Mitigations:
- Use HTTPS (reverse proxy)
- Keep on local network only
- Regenerate token if compromised
- Don't share sync URL publicly
Best Practices
- Local network only: Keep Bookhoard on home/office network
- Use VPN: For remote access, use VPN tunnel
- HTTPS required: If exposing publicly, use reverse proxy with SSL
- Regenerate tokens: Periodically rotate API keys
- Monitor logs: Check for unauthorized access attempts
What's Next?
Additional Resources
**Verification**:
1. Check documentation renders at `/docs` endpoint
2. Test search finds this page
3. Verify all code examples are accurate
4. Confirm troubleshooting section covers common issues
---
### 2.2 Kobo Sync Validation Tests
**File**: `cmd/server/tests/kobo_test.go`
**Location**: Add new test function for URL path authentication
**Current Tests**: Use Bearer token in Authorization header
**Required Updates**: Add test for URL path authentication using table-driven pattern
**ADD THIS TEST**:
```go
func TestKoboAuthenticationMethods(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// ONE test setup shared across all subtests (single database pool)
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
mediaItemID := createTestMediaItemID(t, setup.Server, token)
// Define test cases
tests := []struct {
name string
authMethod string
setupDevice func(*testing.T, *TestServerSetup, string) *TestDeviceSetup
buildRequest func(*testing.T, *httptest.Server, string, string, *TestDeviceSetup) *http.Request
}{
{
name: "URL Path Token (Kobo Firmware)",
authMethod: "URL path parameter",
setupDevice: func(t *testing.T, setup *TestServerSetup, mediaID string) *TestDeviceSetup {
deviceSetup := setupDeviceTest(t)
return deviceSetup.CreateDevice(t, "Test Kobo", "kobo", "kobo-url-path-test")
},
buildRequest: func(t *testing.T, ts *httptest.Server, mediaID string, authToken string, device *TestDeviceSetup) *http.Request {
// Token in URL path: /api/sync/kobo/{token}/markup
markupURL := fmt.Sprintf("%s/api/sync/kobo/%s/markup", ts.URL, device.AuthToken)
syncData := map[string]interface{}{
"ReadingSync": []map[string]interface{}{
{
"ContentId": mediaID,
"PercentRead": 45.6,
"EntitlementId": "entitlement-123",
"RemainingTimeMinutes": 120,
"FirstReadTime": "2026-01-25T10:00:00Z",
"LastModified": "2026-01-30T20:00:00Z",
},
},
"BookmarkSync": []interface{}{},
}
body, _ := json.Marshal(syncData)
req, _ := http.NewRequest("POST", markupURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// NOTE: No Authorization header - token in URL path
return req
},
},
{
name: "Bearer Token (API Clients/Testing)",
authMethod: "Authorization header",
setupDevice: func(t *testing.T, setup *TestServerSetup, mediaID string) *TestDeviceSetup {
deviceSetup := setupDeviceTest(t)
return deviceSetup.CreateDevice(t, "Test Kobo API Client", "kobo", "kobo-bearer-test")
},
buildRequest: func(t *testing.T, ts *httptest.Server, mediaID string, authToken string, device *TestDeviceSetup) *http.Request {
// Token in Authorization header (for API clients, not Kobo devices)
markupURL := fmt.Sprintf("%s/api/sync/kobo/%s/markup", ts.URL, device.AuthToken)
syncData := map[string]interface{}{
"ReadingSync": []map[string]interface{}{
{
"ContentId": mediaID,
"PercentRead": 45.6,
},
},
"BookmarkSync": []interface{}{},
}
body, _ := json.Marshal(syncData)
req, _ := http.NewRequest("POST", markupURL, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", device.AuthToken))
return req
},
},
}
// Run all test cases sharing ONE database pool
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
koboDevice := tt.setupDevice(t, setup, mediaItemID)
req := tt.buildRequest(t, setup.Server, mediaItemID, token, koboDevice)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode, "Should succeed with %s", tt.authMethod)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
assert.Contains(t, result, "Status")
})
}
}
Explanation:
- ONE call to
setupTestServer(t)for entire test function - Table-driven pattern with
t.Run()subtests - Each subtest shares the same database pool (1 connection max)
- Tests both authentication methods: URL path (Kobo) and Bearer (API clients)
- Follows existing test pattern in your codebase (see
TestKoboMarkupSync)
Verification: Run go test ./cmd/server/tests/... -v -run TestKoboAuthenticationMethods
Phase 3: OPDS Security
3.1 OPDS Router Enhancement
File: internal/router/opds.go
Current Implementation: Already has DeviceAuthMiddleware applied (line 12)
Required Addition: Add comment documenting query parameter support
UPDATE COMMENTS (lines 3-12):
// Register OPDS routes with device authentication
//
// Authentication Methods:
// 1. Authorization: Bearer {token} header (KOReader, API clients)
// 2. ?token={token} query parameter (Kobo OPDS catalog)
//
// Devices must use their devices.auth_token (generated during device registration)
//
// Kobo OPDS URL format: /opds/devices/{deviceId}/catalog?token={auth_token}
// KOReader OPDS URL format: /opds/devices/{deviceId}/catalog (uses Bearer header)
//
// Returns 401 Unauthorized if device token is missing, invalid, or device sync is disabled
func registerOPDSRoutes(cfg *Config) {
e := cfg.Echo
// Require device authentication for all OPDS endpoints
// Middleware checks both Bearer header and ?token= query parameter
opds := e.Group("/opds/devices")
opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog)
opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog)
opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)
opds.GET("/:deviceId/cover/:bookId", cfg.OPDSHandler.GetCoverImage)
opds.GET("/:deviceId/formats/:bookId", cfg.OPDSHandler.ListFormats)
}
Explanation: No code changes needed - middleware already supports query parameter (see 1.2). Just documenting the feature.
3.2 Bruno API Tests - OPDS Authentication
The following Bruno request files test OPDS authentication with both Bearer tokens and query parameters.
File: bruno/opds/Get Device Catalog - Bearer.bru
CREATE NEW FILE:
meta {
name: Get Device Catalog (Bearer Token)
type: http
seq: 1
}
get {
url: {{opds_base_url}}/devices/{{device_id}}/catalog
}
docs {
## Get Device OPDS Catalog - Bearer Token
Retrieves OPDS catalog for a device using Bearer token authentication.
**Method:** GET
**Endpoint:** /opds/devices/{device_id}/catalog
**Authentication:** Bearer token (for KOReader and API clients)
**Path Parameters:**
- `device_id` (string): Device UUID
**Response:** OPDS Atom feed catalog
**Status Codes:**
- 200: Success (OPDS feed)
- 401: Unauthorized
- 403: Device sync disabled
- 404: Device not found
**Use Case:** KOReader devices and API clients accessing OPDS catalog
**Authentication Method:** Bearer token in Authorization header
}
File: bruno/opds/Get Device Catalog - Query Token.bru
CREATE NEW FILE:
meta {
name: Get Device Catalog (Query Token)
type: http
seq: 2
}
get {
url: {{opds_base_url}}/devices/{{device_id}}/catalog?token={{device_token}}
}
docs {
## Get Device OPDS Catalog - Query Token
Retrieves OPDS catalog for a device using query parameter authentication.
**Method:** GET
**Endpoint:** /opds/devices/{device_id}/catalog?token={device_token}
**Authentication:** Query parameter (for Kobo devices)
**Path Parameters:**
- `device_id` (string): Device UUID
**Query Parameters:**
- `token` (string): Device auth token
**Response:** OPDS Atom feed catalog
**Status Codes:**
- 200: Success (OPDS feed)
- 401: Unauthorized
- 403: Device sync disabled
- 404: Device not found
**Use Case:** Kobo devices accessing OPDS catalog (easier to configure in Kobo settings)
**Authentication Method:** Token in URL query parameter
**Important:** Token visible in Kobo logs and server logs
}
File: bruno/opds/Get Device Catalog - No Auth.bru
CREATE NEW FILE:
meta {
name: Get Device Catalog (No Authentication)
type: http
seq: 3
}
get {
url: {{opds_base_url}}/devices/{{device_id}}/catalog
}
docs {
## Get Device OPDS Catalog - No Authentication
Tests that OPDS catalog requires authentication.
**Expected Behavior:** Returns 401 Unauthorized when no token provided
**Status Codes:**
- 401: Unauthorized
**Use Case:** Verify authentication is required for OPDS access
**Testing:** Ensures middleware properly rejects unauthenticated requests
}
File: bruno/opds/Get Device Catalog - Invalid Token.bru
CREATE NEW FILE:
meta {
name: Get Device Catalog (Invalid Token)
type: http
seq: 4
}
get {
url: {{opds_base_url}}/devices/{{device_id}}/catalog?token=invalid_token_12345
}
docs {
## Get Device OPDS Catalog - Invalid Token
Tests that OPDS catalog rejects invalid tokens.
**Expected Behavior:** Returns 401 Unauthorized when invalid token provided
**Status Codes:**
- 401: Unauthorized
**Use Case:** Verify proper token validation
**Testing:** Ensures middleware validates token format and database lookup
}
File: bruno/opds/Download Book - Bearer.bru
CREATE NEW FILE:
meta {
name: Download Book (Bearer Token)
type: http
seq: 5
}
get {
url: {{opds_base_url}}/devices/{{device_id}}/download/{{book_id}}
}
docs {
## Download Book - Bearer Token
Downloads a book file using Bearer token authentication.
**Method:** GET
**Endpoint:** /opds/devices/{device_id}/download/{book_id}
**Authentication:** Bearer token (for KOReader and API clients)
**Path Parameters:**
- `device_id` (string): Device UUID
- `book_id` (string): Book UUID
**Response:** Binary ebook file (EPUB, KEPUB, etc.)
**Status Codes:**
- 200: Success (binary file)
- 401: Unauthorized
- 403: Device sync disabled
- 404: Book or device not found
**Use Case:** KOReader devices and API clients downloading books
**Authentication Method:** Bearer token in Authorization header
}
File: bruno/opds/Download Book - Query Token.bru
CREATE NEW FILE:
meta {
name: Download Book (Query Token)
type: http
seq: 6
}
get {
url: {{opds_base_url}}/devices/{{device_id}}/download/{{book_id}}?token={{device_token}}
}
docs {
## Download Book - Query Token
Downloads a book file using query parameter authentication.
**Method:** GET
**Endpoint:** /opds/devices/{device_id}/download/{book_id}?token={device_token}
**Authentication:** Query parameter (for Kobo devices)
**Path Parameters:**
- `device_id` (string): Device UUID
- `book_id` (string): Book UUID
**Query Parameters:**
- `token` (string): Device auth token
**Response:** Binary ebook file (EPUB, KEPUB, etc.)
**Status Codes:**
- 200: Success (binary file)
- 401: Unauthorized
- 403: Device sync disabled
- 404: Book or device not found
**Use Case:** Kobo devices downloading books via OPDS
**Authentication Method:** Token in URL query parameter
**Important:** Token visible in Kobo logs and server logs
}
Verification: Test both authentication methods work for OPDS access
Phase 4: Testing & Documentation
4.1 Security Documentation
File: docs/user/devices/security.md
CREATE NEW FILE:
# Device Authentication Security
**Last Updated**: 2026-02-12
This document explains security considerations for device authentication in Bookhoard.
## Authentication Methods
### Kobo E-Readers: API Key in URL Path
**Method**: Token embedded in URL: `/api/sync/kobo/{token}/markup`
**Pros**:
- ✅ Works with stock Kobo firmware (no jailbreak)
- ✅ Simple one-line configuration
- ✅ Proven approach (used by Komga)
- ✅ Per-device revocable tokens
**Cons**:
- ❌ Token visible in Kobo logs
- ❌ Token visible in server logs
- ❌ Token stored in plain text config
**Security Level**: Medium - acceptable for self-hosted use on trusted networks
### KOReader: Bearer Token in Header
**Method**: `Authorization: Bearer {token}` header
**Pros**:
- ✅ Cryptographically secure random UUIDs
- ✅ Not visible in URLs (only in headers)
- ✅ Standard authentication method
- ✅ Per-device revocable tokens
**Cons**:
- ❌ Requires plugin installation
- ❌ Token stored in plugin settings
**Security Level**: High - suitable for most deployments
### OPDS: Both Methods Supported
**Methods**:
- Query parameter: `?token={token}` (Kobo)
- Bearer header: `Authorization: Bearer {token}` (KOReader)
**Security Level**: Depends on which method is used
## Threat Model
### Assumptions
1. **Trusted Network**: Users run Bookhoard on home/office network
2. **Self-Hosted**: No public cloud exposure
3. **Technical Users**: Capable of setting up VPN, reverse proxy
4. **Data Privacy**: Books are personal, not state secrets
### Attack Scenarios
#### 1. Local Network Eavesdropping
**Attacker**: Devices on same WiFi network
**Risk**: Medium
- Can intercept traffic if no HTTPS
- Can see API key in URLs for Kobo
**Mitigation**:
- Use reverse proxy with HTTPS
- Keep on trusted network
- Use WPA3 encryption for WiFi
#### 2. Device Theft
**Attacker**: Physical access to Kobo/KOReader device
**Risk**: Low
- API key stored in device config
- Attacker can sync until token regenerated
**Mitigation**:
- Regenerate token if device lost
- Enable device approval (already implemented)
- Monitor logs for unauthorized access
#### 3. Server Compromise
**Attacker**: Access to Bookhoard server
**Risk**: High (but out of scope)
- Attacker has database access
- All tokens compromised
**Mitigation**:
- Keep server updated
- Use strong passwords
- Limit network exposure
- Regular backups
#### 4. Token Leakage via Logs
**Attacker**: Access to server logs or Kobo logs
**Risk**: Medium
- API keys visible in logs
- Historical keys may be exposed
**Mitigation**:
- Use log scrubbing (future enhancement)
- Limit log retention
- Regenerate tokens periodically
## Security Best Practices
### For Kobo Users
1. **Local Network Only**
- Don't expose Bookhoard publicly
- Use WiFi with WPA3 encryption
- Consider guest network isolation
2. **HTTPS for Remote Access**
- Set up reverse proxy (nginx, Caddy)
- Use Let's Encrypt for SSL certificates
- Enforce HTTPS redirection
3. **Token Management**
- Regenerate token if device lost
- Regenerate token periodically (monthly)
- Don't share sync URL publicly
4. **Monitoring**
- Check Device Management regularly
- Look for unknown devices
- Review server logs for suspicious activity
### For KOReader Users
1. **Plugin Security**
- Only install official Bookhoard plugin
- Verify plugin source
- Keep plugin updated
2. **Token Storage**
- Plugin stores token in settings file
- Keep plugin files private
- Don't share plugin config
3. **Network Security**
- Same as Kobo (local network, HTTPS)
- Bearer token slightly more secure than URL token
### For All Users
#### Network Setup
**Recommended**: Local network only
Kobo/KOReader ← WiFi → Router ← WiFi → Bookhoard Server
**Advanced**: VPN for remote access
Kobo/KOReader ← VPN → Internet ← VPN → Bookhoard Server
**Not Recommended**: Direct public exposure
Kobo/KOReader ← Internet → Bookhoard Server (INSECURE)
#### Reverse Proxy Setup (HTTPS)
**Using Caddy**:
bookhoard.example.com { reverse_proxy localhost:8765 encode gzip }
**Using nginx**:
```nginx
server {
listen 443 ssl;
server_name bookhoard.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8765;
proxy_set_header Host $host;
}
}
Firewall Rules
UFW (Linux):
# Allow local network
sudo ufw allow from 192.168.1.0/24 to any port 8765
# Block public access
sudo ufw deny 8765
pfSense / OPNsense:
- Create firewall rule: Allow from local network
- Block WAN access to port 8765
Token Regeneration
When to Regenerate
- Device lost or stolen
- Token shared accidentally
- Periodic rotation (monthly/quarterly)
- Suspicious activity in logs
How to Regenerate
- Open Bookhoard Device Management
- Find device in list
- Click "🔄 Regenerate Token"
- Confirm regeneration
- Copy new token/sync URL
- Update device configuration
Impact
- Old token stops working immediately
- Device must update config to resume syncing
- No data loss - device ID remains the same
Comparison with Other Systems
Calibre
- Auth: Basic Auth (username/password)
- Pros: Familiar, works everywhere
- Cons: Credentials sent with every request
Komga
- Auth: API key in URL (same as Bookhoard Kobo)
- Pros: Simple, proven
- Cons: Token visible in logs
Bookhoard
- Kobo: API key in URL (matches Komga)
- KOReader: Bearer token (more secure)
- Pros: Flexible, revocable, per-device
- Cons: Token visible in logs (Kobo only)
Compliance & Privacy
GDPR Considerations
- Reading progress = personal data
- Device tokens = access credentials
- Users can export/delete data
- Tokens can be revoked
Data Minimization
- Only sync required data
- No unnecessary metadata
- Tokens don't reveal reading habits
Future Enhancements
Planned
- Log scrubbing (hide tokens in logs)
- Token expiration (auto-rotate tokens)
- Device IP whitelisting
- Biometric authentication (device unlock)
Not Planned
- OAuth/OIDC (overkill for self-hosted)
- Client certificates (too complex)
- Hardware security keys (not supported by Kobo)
FAQs
Is API key auth secure enough?
Answer: Yes, for self-hosted use on trusted networks. Same security model as Komga, widely used in community.
Should I use HTTPS?
Answer: Highly recommended. Without HTTPS, API key can be intercepted on public networks.
What if my Kobo is stolen?
Answer: Regenerate token immediately. Old token stops working, thief loses access.
Can I use VPN?
Answer: Yes, VPN is recommended for remote access. Encrypts all traffic.
How often should I regenerate tokens?
Answer: Monthly if security-conscious. When device lost. Never if unconcerned.
Additional Resources
**Verification**:
1. Check documentation renders at `/docs` endpoint
2. Verify search finds this page
3. Test all code examples
4. Confirm security advice is sound
---
## Summary of Changes
### Files Modified
1. **Database** (`internal/database/queries/queries.sql`)
- Added: `UpdateDeviceAuthToken` query
2. **Middleware** (`internal/middleware/device_auth.go`)
- Enhanced: `Authenticate` function to support URL path + query param
3. **Router** (`internal/router/sync.go`)
- Changed: Kobo routes to `/api/sync/kobo/:token`
4. **Router** (`internal/router/device.go`)
- Added: Route for `PUT /:id/regenerate-token`
5. **Handler** (`internal/handlers/devices.go`)
- Added: `RegenerateDeviceToken` function
- Added: `AuthToken` field to `DeviceInfo` struct (breaking change: all clients must accept this field)
- Modified: `GetDevicesData`, `ListDevices` to include `auth_token`
6. **Template** (`templates/devices.templ`)
- Added: `AuthToken` field to `DeviceData` struct
- Added: Copy sync URL button
- Added: Regenerate token button
- Added: JavaScript functions for copy/regenerate
7. **Tests** (`cmd/server/tests/kobo_test.go`)
- Added: URL path token tests
- Updated: Bearer token tests
8. **Documentation** (`docs/user/devices/kobo-setup.md`)
- Rewritten: API key in URL approach
9. **Documentation** (`docs/user/devices/security.md`)
- Created: Security considerations document
10. **Bruno** (`bruno/devices/regenerate-*.bru`)
- Created: Token regeneration tests (success, unauthorized, forbidden, not found)
11. **Bruno** (`bruno/sync-kobo/api.bru`)
- Added: URL path token tests for Kobo sync
12. **Bruno** (`bruno/opds/*-Bearer.bru`, `*-Query Token.bru`, `*-No Auth.bru`, `*-Invalid Token.bru`)
- Created: OPDS authentication tests for both Bearer and query parameter methods
### Files Created
- `docs/user/devices/security.md`
- `bruno/devices/regenerate-token.bru`
- `bruno/devices/regenerate-token-unauthorized.bru`
- `bruno/devices/regenerate-token-forbidden.bru`
- `bruno/devices/regenerate-token-notfound.bru`
- `bruno/opds/Get Device Catalog - Bearer.bru`
- `bruno/opds/Get Device Catalog - Query Token.bru`
- `bruno/opds/Get Device Catalog - No Auth.bru`
- `bruno/opds/Get Device Catalog - Invalid Token.bru`
- `bruno/opds/Download Book - Bearer.bru`
- `bruno/opds/Download Book - Query Token.bru`
### Testing Checklist
- [ ] Run `go build ./...` - all packages compile
- [ ] Run `bash scripts/verify-guidelines.sh` - no errors
- [ ] Run `go test ./... -v` - all tests pass
- [ ] Test token regeneration in browser
- [ ] Test copy sync URL button
- [ ] Test Kobo sync with URL path token
- [ ] Test KOReader sync with Bearer token
- [ ] Test OPDS with query parameter
- [ ] Test OPDS with Bearer header
- [ ] Verify documentation renders at `/docs`
- [ ] Verify search finds new docs
---
**End of Implementation Document**
---
## Updates Applied (2026-02-12)
Based on code review analysis, the following fixes were applied to this document:
### Fixed Issues
1. **AuthToken Field Addition** (Section 1.6.1)
- Added `AuthToken` field to `DeviceInfo` struct
- Breaking change (Option A): All clients must accept this field
2. **ListDevices Handler Update** (Section 1.6.2)
- Changed from "Do the same" to showing complete code modification
- Added explicit code block for `ListDevices` function modification
- Renamed section from "Update Device List Handler" to "Update Device List Handlers"
3. **goto Label Clarification** (Section 1.2)
- Updated explanation to clarify that `validateDevice:` label is in the kept section
- Added reference to line 221 in complete function
- Made it explicit that lines 62-270 remain unchanged
4. **Time Formatting** (Section 1.6.3)
- Updated template code to use `.Format("2006-01-02 15:04")` method
- Fixed both `LastSync` and `LastSeen` field display
- Both fields now properly format `*time.Time` values
### Remaining User Decisions
- ✅ Template type checking: Already fixed by user
- ✅ Bruno test variables: User confirmed they exist in environment
- ✅ Breaking change on AuthToken: User approved Option A
**Next Steps**:
1. Review this document completely
2. Ask questions if anything is unclear
3. Begin Phase 1 implementation (database query first)
4. Test after each file change
5. Commit changes with clear messages
6. Run full test suite before declaring complete
**Estimated Time**: 4-5 hours for full implementation (including testing)