From caa6e6483ca3daf67471a0e92defb71ff361a5df Mon Sep 17 00:00:00 2001 From: m Date: Thu, 10 Sep 2026 07:25:11 +0200 Subject: [PATCH] feat(api): /devices register + paseto v4 (middleware Bearer, bootstrap mobile du token) --- backend/cmd/server/main.go | 37 +++--- backend/handlers/auth_test.go | 105 ++++++++++++++++++ backend/handlers/devices.go | 30 ++++- backend/handlers/middleware.go | 39 +++++++ backend/pkg/auth/auth.go | 49 ++++++++ backend/pkg/auth/auth_test.go | 63 +++++++++++ backend/service/move_test.go | 2 +- docs/api-v1.md | 5 +- mobile/api/client.ts | 24 ++++ mobile/api/types.ts | 5 + mobile/context/AuthContext.tsx | 33 ++++-- mobile/package-lock.json | 12 ++ mobile/package.json | 1 + mobile/services/db/index.ts | 1 + mobile/services/db/repositories/index.ts | 2 + .../services/db/repositories/preferences.ts | 22 +++- mobile/services/db/schema.ts | 2 + mobile/services/localStorage.ts | 2 + 18 files changed, 407 insertions(+), 27 deletions(-) create mode 100644 backend/handlers/auth_test.go create mode 100644 backend/handlers/middleware.go create mode 100644 backend/pkg/auth/auth.go create mode 100644 backend/pkg/auth/auth_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index eb26e9a..63f99ee 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -7,29 +7,34 @@ import ( "github.com/gin-gonic/gin" "github.com/vaultdrop/backend/config" "github.com/vaultdrop/backend/handlers" + "github.com/vaultdrop/backend/pkg/auth" ) func newRouter() *gin.Engine { r := gin.Default() - api := r.Group("/api/v1") + public := r.Group("/api/v1") { - api.GET("/health", handlers.Health) - api.POST("/devices", handlers.DevicesRegister) + public.GET("/health", handlers.Health) + public.POST("/devices", handlers.DevicesRegister) + } - api.GET("/files", handlers.FilesList) - api.GET("/files/search", handlers.FilesSearch) - api.GET("/files/:id", handlers.FilesGet) - api.DELETE("/files/:id", handlers.FilesDelete) - api.GET("/files/folders", handlers.FoldersList) - api.POST("/files/upload", handlers.FilesUpload) + protected := r.Group("/api/v1") + protected.Use(handlers.RequireDevice) + { + protected.GET("/files", handlers.FilesList) + protected.GET("/files/search", handlers.FilesSearch) + protected.GET("/files/:id", handlers.FilesGet) + protected.DELETE("/files/:id", handlers.FilesDelete) + protected.GET("/files/folders", handlers.FoldersList) + protected.POST("/files/upload", handlers.FilesUpload) - api.POST("/ocr/jobs", handlers.OcrJobsCreate) - api.GET("/ocr/jobs/:id", handlers.OcrJobsGet) + protected.POST("/ocr/jobs", handlers.OcrJobsCreate) + protected.GET("/ocr/jobs/:id", handlers.OcrJobsGet) - api.POST("/sync/ops", handlers.SyncOpsPush) - api.GET("/sync/permissions", handlers.SyncPermissionsGet) + protected.POST("/sync/ops", handlers.SyncOpsPush) + protected.GET("/sync/permissions", handlers.SyncPermissionsGet) } return r @@ -44,6 +49,12 @@ func main() { log.Fatalln(err) } + authManager, err := auth.NewManager(cfg.AuthSecret) + if err != nil { + log.Fatalln(err) + } + handlers.Auth = authManager + if err := newRouter().Run(fmt.Sprintf(":%d", cfg.Port)); err != nil { log.Fatalln(err) } diff --git a/backend/handlers/auth_test.go b/backend/handlers/auth_test.go new file mode 100644 index 0000000..6c122a1 --- /dev/null +++ b/backend/handlers/auth_test.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/pkg/auth" +) + +func newTestRouterForAuth() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/devices", DevicesRegister) + grp := r.Group("") + grp.Use(RequireDevice) + grp.GET("/files", FilesList) + return r +} + +func TestDevicesRegisterValid(t *testing.T) { + m, _ := auth.NewManager("test-secret") + Auth = m + defer func() { Auth = nil }() + + deviceID := "0123456789abcdef0123456789abcdef" + body := `{"deviceId":"` + deviceID + `"}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + newTestRouterForAuth().ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + + var envelope struct { + Data struct { + DeviceID string `json:"deviceId"` + Token string `json:"token"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if envelope.Data.DeviceID != deviceID { + t.Errorf("deviceId = %q", envelope.Data.DeviceID) + } + + verified, err := m.Verify(envelope.Data.Token) + if err != nil || verified != deviceID { + t.Errorf("token invalid: %v", err) + } +} + +func TestDevicesRegisterRejectsBadDeviceID(t *testing.T) { + Auth, _ = auth.NewManager("test-secret") + defer func() { Auth = nil }() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/devices", strings.NewReader(`{"deviceId":"UPPERCASEANDTOOLONG"}`)) + req.Header.Set("Content-Type", "application/json") + newTestRouterForAuth().ServeHTTP(rec, req) + + if rec.Code != 400 { + t.Fatalf("status = %d", rec.Code) + } +} + +func TestRequireDeviceRejectsMissingToken(t *testing.T) { + Auth, _ = auth.NewManager("test-secret") + defer func() { Auth = nil }() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/files", nil) + newTestRouterForAuth().ServeHTTP(rec, req) + + if rec.Code != 401 { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } +} + +func TestRequireDeviceAcceptsValidToken(t *testing.T) { + Auth, _ = auth.NewManager("test-secret") + defer func() { Auth = nil }() + + deviceID := "0123456789abcdef0123456789abcdef" + signed, err := Auth.Issue(deviceID) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/files", nil) + req.Header.Set("Authorization", "Bearer "+signed) + newTestRouterForAuth().ServeHTTP(rec, req) + + // FilesList is still a 501 stub — the point is it got past the middleware. + if rec.Code != 501 { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/handlers/devices.go b/backend/handlers/devices.go index e4ea106..4b8ac9d 100644 --- a/backend/handlers/devices.go +++ b/backend/handlers/devices.go @@ -1,8 +1,36 @@ package handlers import ( + "regexp" + "github.com/gin-gonic/gin" "github.com/vaultdrop/backend/pkg/api" ) -func DevicesRegister(c *gin.Context) { api.NotImplemented(c) } +var deviceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) + +type DeviceRegisterRequest struct { + DeviceID string `json:"deviceId"` +} + +func DevicesRegister(c *gin.Context) { + + var req DeviceRegisterRequest + if err := c.ShouldBindJSON(&req); err != nil || req.DeviceID == "" { + api.Error(c, 400, "INVALID_REQUEST", "missing deviceId") + return + } + if !deviceIDPattern.MatchString(req.DeviceID) { + api.Error(c, 400, "INVALID_DEVICE_ID", "deviceId must be 32 lowercase hex chars") + return + } + + token, err := Auth.Issue(req.DeviceID) + if err != nil { + api.Error(c, 500, "TOKEN_ERROR", "could not issue token") + return + } + + api.OK(c, gin.H{"deviceId": req.DeviceID, "token": token}) + +} diff --git a/backend/handlers/middleware.go b/backend/handlers/middleware.go new file mode 100644 index 0000000..bc152aa --- /dev/null +++ b/backend/handlers/middleware.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/vaultdrop/backend/pkg/api" + "github.com/vaultdrop/backend/pkg/auth" +) + +const DeviceIDKey = "device_id" + +// Auth issues/verifies device bearer tokens; set once at startup (cmd/server). +var Auth *auth.Manager + +// RequireDevice authenticates the bearer paseto token and stores the resolved +// device_id in the gin context (cf. docs/api-v1.md §2). +func RequireDevice(c *gin.Context) { + + header := c.GetHeader("Authorization") + token, found := strings.CutPrefix(header, "Bearer ") + + if Auth == nil || !found || token == "" { + api.Error(c, 401, "UNAUTHORIZED", "missing bearer token") + c.Abort() + return + } + + deviceID, err := Auth.Verify(token) + if err != nil { + api.Error(c, 401, "UNAUTHORIZED", "invalid or expired token") + c.Abort() + return + } + + c.Set(DeviceIDKey, deviceID) + c.Next() + +} diff --git a/backend/pkg/auth/auth.go b/backend/pkg/auth/auth.go new file mode 100644 index 0000000..fbd0795 --- /dev/null +++ b/backend/pkg/auth/auth.go @@ -0,0 +1,49 @@ +package auth + +import ( + "crypto/sha256" + "errors" + "time" + + "aidanwoods.dev/go-paseto" +) + +const TokenTTL = 90 * 24 * time.Hour + +var ErrInvalidToken = errors.New("invalid token") + +// Manager issues and verifies paseto v4-local bearer tokens bound to a device_id. +type Manager struct { + key paseto.V4SymmetricKey +} + +func NewManager(secret string) (*Manager, error) { + sum := sha256.Sum256([]byte(secret)) + key, err := paseto.V4SymmetricKeyFromBytes(sum[:]) + if err != nil { + return nil, err + } + return &Manager{key: key}, nil +} + +func (m *Manager) Issue(deviceID string) (string, error) { + now := time.Now() + token := paseto.NewToken() + token.SetIssuedAt(now) + token.SetNotBefore(now) + token.SetExpiration(now.Add(TokenTTL)) + token.SetSubject(deviceID) + return token.V4Encrypt(m.key, nil), nil +} + +func (m *Manager) Verify(signed string) (string, error) { + parsed, err := paseto.NewParserForValidNow().ParseV4Local(m.key, signed, nil) + if err != nil { + return "", ErrInvalidToken + } + subject, err := parsed.GetSubject() + if err != nil { + return "", ErrInvalidToken + } + return subject, nil +} diff --git a/backend/pkg/auth/auth_test.go b/backend/pkg/auth/auth_test.go new file mode 100644 index 0000000..c3ed868 --- /dev/null +++ b/backend/pkg/auth/auth_test.go @@ -0,0 +1,63 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestIssueVerifyRoundtrip(t *testing.T) { + m, err := NewManager("test-secret") + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + deviceID := "0123456789abcdef0123456789abcdef" + signed, err := m.Issue(deviceID) + if err != nil { + t.Fatalf("Issue: %v", err) + } + + got, err := m.Verify(signed) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if got != deviceID { + t.Fatalf("Verify: got %q want %q", got, deviceID) + } +} + +func TestVerifyRejectsTamperedToken(t *testing.T) { + m, err := NewManager("test-secret") + if err != nil { + t.Fatalf("NewManager: %v", err) + } + + signed, _ := m.Issue("0123456789abcdef0123456789abcdef") + parts := strings.Split(signed, ".") + parts[len(parts)-1] = "nope" + tampered := strings.Join(parts, ".") + + if _, err := m.Verify(tampered); err == nil { + t.Fatal("expected tampered token to be rejected") + } +} + +func TestVerifyRejectsGarbage(t *testing.T) { + m, err := NewManager("test-secret") + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if _, err := m.Verify("not-a-token"); err == nil { + t.Fatal("expected garbage to be rejected") + } +} + +func TestDifferentSecretRejectsToken(t *testing.T) { + a, _ := NewManager("secret-a") + b, _ := NewManager("secret-b") + + signed, _ := a.Issue("0123456789abcdef0123456789abcdef") + if _, err := b.Verify(signed); err == nil { + t.Fatal("expected token from another manager to be rejected") + } +} diff --git a/backend/service/move_test.go b/backend/service/move_test.go index a1f1089..da137fe 100644 --- a/backend/service/move_test.go +++ b/backend/service/move_test.go @@ -49,4 +49,4 @@ func TestMoveDocumentToAFile(t *testing.T) { t.Error(err) } -} \ No newline at end of file +} diff --git a/docs/api-v1.md b/docs/api-v1.md index 4f57da0..867c5eb 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -16,7 +16,8 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync) ## 2. Identité et identifiants (invariants) -- **Device-first** : le device s'enregistre (`POST /devices`) et reçoit un token **paseto** qu'il stocke localement. Requêtes suivantes : `Authorization: Bearer ` ; le serveur en résout le `device_id`. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`). +- **Device-first** : le device s'enregistre (`POST /devices`) avec son identité **générée localement** (`device_user_id` 32-hex mobile) et reçoit en échange un token **paseto** v4-local qu'il stocke. Requêtes suivantes : `Authorization: Bearer ` (toutes les routes **sauf `/health`**), résolu en `device_id` par middleware. V1 : pas de comptes utilisateurs (`users.user_id` reste NULL sur `devices`). +- La ré-émission est tolérée (le server décide de ré-énoncer un token ; la déduplication/persistance des devices arrive avec la table `devices`). - **Identifiants** : `resource_id`, `device_user_id`, `token` de share-link = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK. - Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`). @@ -25,7 +26,7 @@ Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync) | Méthode | Path | Requête | Réponse `data` | Statut absence | |---|---|---|---|---| | GET | `/health` | — | `{ "status": "healthy" }` | — | -| POST | `/devices` | `{}` | `{ "deviceId": "…32-hex", "token": "paseto…" }` | — | +| POST | `/devices` | `{ "deviceId": "…32-hex" }` (client-generated) | `{ "deviceId": "…32-hex", "token": "v4.local…" }` | `INVALID_DEVICE_ID` | | GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — | | GET | `/files/:id` | — | `FileDto` | `NOT_FOUND` | | DELETE | `/files/:id` | — | `{ "id": "…" }` | `NOT_FOUND` | diff --git a/mobile/api/client.ts b/mobile/api/client.ts index dd4f47c..e9d93d5 100644 --- a/mobile/api/client.ts +++ b/mobile/api/client.ts @@ -1,6 +1,7 @@ import type { ApiData, ApiErrorBody, + DeviceRegistration, FileDto, FolderDto, ListFilesParams, @@ -11,6 +12,12 @@ const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8 export const DEFAULT_TIMEOUT_MS = 15_000; +let authToken: string | null = null; + +export function setAuthToken(token: string | null): void { + authToken = token; +} + type QueryParams = Record; function toQuery(params?: QueryParams): string { @@ -24,6 +31,15 @@ function toQuery(params?: QueryParams): string { return query ? `?${query}` : ''; } +function mergeHeaders(init?: HeadersInit): HeadersInit | undefined { + if ( !authToken ) return init; + const merged = new Headers(init); + if ( !merged.has('Authorization') ) { + merged.set('Authorization', `Bearer ${authToken}`); + } + return merged; +} + export class ApiError extends Error { readonly code: string; @@ -46,6 +62,7 @@ async function request( try { response = await fetch(`${API_BASE_URL}${path}`, { ...init, + headers: mergeHeaders(init.headers), signal: controller.signal, }); } catch { @@ -76,6 +93,13 @@ export const api = { health: () => request<{ status: string }>('/health'), + registerDevice: (deviceId: string) => + request('/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceId }), + }), + listFiles: (params?: ListFilesParams) => request(`/files${toQuery(params)}`), diff --git a/mobile/api/types.ts b/mobile/api/types.ts index 9277702..fb085a8 100644 --- a/mobile/api/types.ts +++ b/mobile/api/types.ts @@ -42,6 +42,11 @@ export type OcrJob = { error?: string | null; }; +export type DeviceRegistration = { + deviceId: string; + token: string; +}; + export type ListParams = { page?: number; pageSize?: number; diff --git a/mobile/context/AuthContext.tsx b/mobile/context/AuthContext.tsx index 4e727ce..cf55ff5 100644 --- a/mobile/context/AuthContext.tsx +++ b/mobile/context/AuthContext.tsx @@ -1,7 +1,12 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useSyncDevice } from '../features/syncDevice'; -import { getDeviceUserId } from '../services/localStorage'; +import { api, setAuthToken } from '../api/client'; +import { + getDeviceAuthToken, + getDeviceUserId, + saveDeviceAuthToken, +} from '../services/localStorage'; import type { AuthContextValue, User } from './AuthContext.types'; const AuthContext = createContext(null); @@ -13,15 +18,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { let active = true; - (async () => { - try { - const id = await getDeviceUserId(); - if (active) setDeviceUserId(id); - } catch (error) { - console.warn('device identity unavailable', error); - } - })(); + const bootstrap = async () => { + const id = await getDeviceUserId(); + if (active) setDeviceUserId(id); + + try { + let token = await getDeviceAuthToken(); + if ( !token ) { + const { data } = await api.registerDevice(id); + token = data.token; + await saveDeviceAuthToken(token); + } + setAuthToken(token); + } catch (error) { + console.warn('device registration failed (offline?)', error); + } + }; + + bootstrap(); useSyncDevice(); return () => { diff --git a/mobile/package-lock.json b/mobile/package-lock.json index e31253b..8960283 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -8,6 +8,7 @@ "name": "webui", "version": "2.0.0", "dependencies": { + "@expo/vector-icons": "^15.1.1", "@tanstack/react-query": "^5.102.8", "expo": "~57.0.8", "expo-constants": "~57.0.17", @@ -1991,6 +1992,17 @@ "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", "license": "MIT" }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/xcpretty": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz", diff --git a/mobile/package.json b/mobile/package.json index 8841cc6..5385aa3 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -3,6 +3,7 @@ "version": "2.0.0", "main": "expo-router/entry", "dependencies": { + "@expo/vector-icons": "^15.1.1", "@tanstack/react-query": "^5.102.8", "expo": "~57.0.8", "expo-constants": "~57.0.17", diff --git a/mobile/services/db/index.ts b/mobile/services/db/index.ts index 6ee55cc..4d77252 100644 --- a/mobile/services/db/index.ts +++ b/mobile/services/db/index.ts @@ -39,6 +39,7 @@ export { DATABASE_NAME, DATABASE_VERSION, DEVICE_USER_ID_KEY, + AUTH_TOKEN_KEY, PREFERENCES_KEY, PERMISSION_TTL_MS, } from './schema'; \ No newline at end of file diff --git a/mobile/services/db/repositories/index.ts b/mobile/services/db/repositories/index.ts index 171b244..5774f6e 100644 --- a/mobile/services/db/repositories/index.ts +++ b/mobile/services/db/repositories/index.ts @@ -11,6 +11,8 @@ export { saveUserPreferences, getUserPreferences, getDeviceUserId, + getDeviceAuthToken, + saveDeviceAuthToken, } from './preferences'; export { getResourcePermission, diff --git a/mobile/services/db/repositories/preferences.ts b/mobile/services/db/repositories/preferences.ts index 4a20b62..e2d5da9 100644 --- a/mobile/services/db/repositories/preferences.ts +++ b/mobile/services/db/repositories/preferences.ts @@ -1,5 +1,5 @@ import { getSession } from '../session'; -import { DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema'; +import { AUTH_TOKEN_KEY, DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema'; import type { UserPreferences } from '../types'; const DEFAULT_PREFERENCES: UserPreferences = { @@ -38,6 +38,26 @@ export async function saveUserPreferences(preferences: UserPreferences): Promise ); } +export async function getDeviceAuthToken(): Promise { + const db = await getSession(); + const row = await db.getFirstAsync<{ value: string }>( + 'SELECT "value" FROM user_preferences WHERE "key" = ?', + AUTH_TOKEN_KEY, + ); + return row?.value ?? null; +} + +export async function saveDeviceAuthToken(token: string): Promise { + const db = await getSession(); + await db.runAsync( + `INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?) + ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`, + AUTH_TOKEN_KEY, + token, + Date.now(), + ); +} + export async function getUserPreferences(): Promise { const db = await getSession(); const row = await db.getFirstAsync<{ value: string }>( diff --git a/mobile/services/db/schema.ts b/mobile/services/db/schema.ts index 35a06c3..2981a9e 100644 --- a/mobile/services/db/schema.ts +++ b/mobile/services/db/schema.ts @@ -6,6 +6,8 @@ export const PREFERENCES_KEY = 'user_preferences'; export const DEVICE_USER_ID_KEY = 'device_user_id'; +export const AUTH_TOKEN_KEY = 'auth_token'; + export const FOLDER_COLUMNS = [ 'resource_id', 'uri', diff --git a/mobile/services/localStorage.ts b/mobile/services/localStorage.ts index 03222ea..8646d71 100644 --- a/mobile/services/localStorage.ts +++ b/mobile/services/localStorage.ts @@ -1,6 +1,8 @@ export { getUserPreferences, getDeviceUserId, + getDeviceAuthToken, + saveDeviceAuthToken, getFolders, getFolderFolders, getFolder,