diff --git a/cmd/knowledgehub/collections.go b/cmd/knowledgehub/collections.go index 13fbc18..35314b2 100644 --- a/cmd/knowledgehub/collections.go +++ b/cmd/knowledgehub/collections.go @@ -7,15 +7,35 @@ import ( "github.com/pocketbase/pocketbase/tools/types" ) +const rememberMeAuthTokenDurationSeconds int64 = 30 * 24 * 60 * 60 + func registerCollections(app core.App) { ensureResourcesCollection(app) ensureEntriesCollection(app) ensurePreferencesCollection(app) ensureSettingsCollection(app) + ensureSuperuserAuthTokenDuration(app) migrateCollections(app) ensureQuickAddResource(app) } +func ensureSuperuserAuthTokenDuration(app core.App) { + superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + if err != nil { + log.Printf("Failed to find superusers collection: %v", err) + return + } + + if superusers.AuthToken.Duration >= rememberMeAuthTokenDurationSeconds { + return + } + + superusers.AuthToken.Duration = rememberMeAuthTokenDurationSeconds + if err := app.Save(superusers); err != nil { + log.Printf("Failed to extend superuser auth token duration: %v", err) + } +} + func ensureResourcesCollection(app core.App) { if _, err := app.FindCollectionByNameOrId("resources"); err == nil { return @@ -305,4 +325,4 @@ func ensureQuickAddResource(app core.App) { if err := app.Save(record); err != nil { log.Printf("Failed to create Quick Add resource: %v", err) } -} \ No newline at end of file +} diff --git a/cmd/knowledgehub/collections_test.go b/cmd/knowledgehub/collections_test.go new file mode 100644 index 0000000..77a36c6 --- /dev/null +++ b/cmd/knowledgehub/collections_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "os" + "testing" + + "github.com/pocketbase/pocketbase/core" + + _ "github.com/pocketbase/pocketbase/migrations" +) + +func newTestApp(t *testing.T) (core.App, func()) { + t.Helper() + + tempDir, err := os.MkdirTemp("", "kh_collections_test_*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + app := core.NewBaseApp(core.BaseAppConfig{DataDir: tempDir}) + if err := app.Bootstrap(); err != nil { + os.RemoveAll(tempDir) + t.Fatalf("failed to bootstrap app: %v", err) + } + + cleanup := func() { + app.ResetBootstrapState() + os.RemoveAll(tempDir) + } + + return app, cleanup +} + +func TestRegisterCollections_ExtendsSuperuserAuthTokenDuration(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + registerCollections(app) + + superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + if err != nil { + t.Fatalf("failed to find superusers collection: %v", err) + } + + if got := superusers.AuthToken.Duration; got != rememberMeAuthTokenDurationSeconds { + t.Fatalf("superuser auth token duration = %d, want %d", got, rememberMeAuthTokenDurationSeconds) + } +} + +func TestEnsureSuperuserAuthTokenDuration_PreservesLongerDuration(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + if err != nil { + t.Fatalf("failed to find superusers collection: %v", err) + } + + const customDuration int64 = rememberMeAuthTokenDurationSeconds + 3600 + superusers.AuthToken.Duration = customDuration + if err := app.Save(superusers); err != nil { + t.Fatalf("failed to save superusers collection: %v", err) + } + + ensureSuperuserAuthTokenDuration(app) + + superusers, err = app.FindCollectionByNameOrId(core.CollectionNameSuperusers) + if err != nil { + t.Fatalf("failed to reload superusers collection: %v", err) + } + + if got := superusers.AuthToken.Duration; got != customDuration { + t.Fatalf("superuser auth token duration = %d, want %d", got, customDuration) + } +} diff --git a/openspec/specs/remember-me/spec.md b/openspec/specs/remember-me/spec.md new file mode 100644 index 0000000..bd1da36 --- /dev/null +++ b/openspec/specs/remember-me/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: Remember-me checkbox on login form +The login form SHALL display a "Remember me" checkbox between the password field and the submit button. + +The checkbox SHALL default to checked. + +#### Scenario: Checkbox is visible on login page +- **WHEN** the user navigates to the login page +- **THEN** a "Remember me" checkbox is displayed below the password field, checked by default + +#### Scenario: Checkbox is not shown during initial setup +- **WHEN** the login page is in setup mode (first-time account creation) +- **THEN** the "Remember me" checkbox SHALL NOT be displayed + +### Requirement: Persistent session when remember-me is checked +When the user logs in with "Remember me" checked, the auth token SHALL be stored in `localStorage` so it persists across browser restarts. + +#### Scenario: Login with remember-me checked +- **WHEN** the user logs in with "Remember me" checked +- **THEN** the auth token is stored in `localStorage` +- **THEN** closing and reopening the browser preserves the authenticated session + +#### Scenario: Stale sessionStorage is cleared on persistent login +- **WHEN** the user logs in with "Remember me" checked +- **THEN** any existing auth data in `sessionStorage` SHALL be removed + +### Requirement: Ephemeral session when remember-me is unchecked +When the user logs in with "Remember me" unchecked, the auth token SHALL be stored in `sessionStorage` so it is cleared when the browser tab/window closes. + +#### Scenario: Login without remember-me +- **WHEN** the user logs in with "Remember me" unchecked +- **THEN** the auth token is stored in `sessionStorage` +- **THEN** closing the browser tab ends the session + +#### Scenario: Stale localStorage is cleared on ephemeral login +- **WHEN** the user logs in with "Remember me" unchecked +- **THEN** any existing auth data in `localStorage` SHALL be removed + +### Requirement: Remember-me preference is persisted +The user's last "Remember me" choice SHALL be stored in `localStorage` under the key `kh_remember_me` so the checkbox reflects their preference on next visit. + +#### Scenario: Preference survives page reload +- **WHEN** the user unchecks "Remember me" and logs in +- **THEN** on next visit to the login page, the checkbox defaults to unchecked + +#### Scenario: Default when no preference stored +- **WHEN** no `kh_remember_me` value exists in `localStorage` +- **THEN** the checkbox defaults to checked + +### Requirement: Session restoration on page load +On application startup, the PocketBase client SHALL restore a valid auth session from whichever storage backend contains one, checking `localStorage` first, then `sessionStorage`. + +#### Scenario: Restore persistent session +- **WHEN** the page loads and `localStorage` contains a valid auth token +- **THEN** the user is authenticated without needing to log in again + +#### Scenario: Restore ephemeral session +- **WHEN** the page loads and `sessionStorage` contains a valid auth token (but `localStorage` does not) +- **THEN** the user is authenticated for the current tab + +#### Scenario: No session to restore +- **WHEN** the page loads and neither storage backend contains a valid auth token +- **THEN** the user is redirected to the login page + +### Requirement: Remembered sessions remain valid over time +The system SHALL keep remembered auth sessions usable across normal multi-day use by using refreshable auth tokens and refreshing them before expiry when possible. + +#### Scenario: Remembered session is refreshed on startup +- **WHEN** the page loads and `localStorage` contains a remembered auth token that is near expiry but still refreshable +- **THEN** the system refreshes the auth session before gating authenticated routes +- **THEN** the user remains signed in without manually logging in again + +#### Scenario: Remembered session is refreshed before authenticated requests +- **WHEN** a remembered auth session is near expiry and the user triggers an authenticated request +- **THEN** the system refreshes the auth session before sending the request +- **THEN** the request uses the refreshed auth token + +#### Scenario: Invalid remembered session is cleared +- **WHEN** the page loads or sends an authenticated request and the remembered auth session can no longer be refreshed or is no longer valid +- **THEN** stored auth data is cleared +- **THEN** the user is redirected to the login page diff --git a/ui/src/lib/pb.ts b/ui/src/lib/pb.ts index c1a861c..b3fc179 100644 --- a/ui/src/lib/pb.ts +++ b/ui/src/lib/pb.ts @@ -1,5 +1,114 @@ -import PocketBase from 'pocketbase'; +import PocketBase, { isTokenExpired } from 'pocketbase'; import { authStore } from './auth-store'; +const SUPERUSER_COLLECTION = '_superusers'; +const AUTH_REFRESH_PATH = `/api/collections/${SUPERUSER_COLLECTION}/auth-refresh`; +const AUTO_REFRESH_THRESHOLD_SECONDS = 60 * 60; + const pb = new PocketBase('/', authStore); + +let refreshPromise: Promise | null = null; + +function isSuperuserSession(): boolean { + return pb.authStore.record?.collectionName === SUPERUSER_COLLECTION; +} + +function shouldClearAuth(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('status' in error)) { + return false; + } + + const status = error.status; + return status === 401 || status === 403; +} + +async function refreshSuperuserAuth(): Promise { + if (!pb.authStore.token || !isSuperuserSession()) { + return; + } + + if (!refreshPromise) { + refreshPromise = pb + .collection(SUPERUSER_COLLECTION) + .authRefresh() + .then(() => undefined) + .catch((error) => { + if (shouldClearAuth(error)) { + pb.authStore.clear(); + } + throw error; + }) + .finally(() => { + refreshPromise = null; + }); + } + + await refreshPromise; +} + +async function maybeRefreshAuth(url: string): Promise { + if (url.includes(AUTH_REFRESH_PATH)) { + return; + } + + if (!pb.authStore.isValid || !isSuperuserSession()) { + return; + } + + if (!isTokenExpired(pb.authStore.token, AUTO_REFRESH_THRESHOLD_SECONDS)) { + return; + } + + await refreshSuperuserAuth(); +} + +function syncAuthorizationHeader(options: Record, previousToken: string): void { + if (!pb.authStore.token) { + return; + } + + const headers = { ...(options.headers || {}) }; + const headerKey = Object.keys(headers).find((key) => key.toLowerCase() === 'authorization'); + + if (!headerKey) { + headers.Authorization = pb.authStore.token; + options.headers = headers; + return; + } + + if (!previousToken || headers[headerKey] === previousToken) { + headers[headerKey] = pb.authStore.token; + options.headers = headers; + } +} + +export async function refreshAuthSession(): Promise { + if (!pb.authStore.isValid || !isSuperuserSession()) { + return pb.authStore.isValid; + } + + try { + await refreshSuperuserAuth(); + return true; + } catch { + return false; + } +} + +pb.beforeSend = async (url, options) => { + const previousToken = pb.authStore.token; + + try { + await maybeRefreshAuth(url); + } catch (error) { + if (!pb.authStore.isValid) { + throw error; + } + } + + syncAuthorizationHeader(options, previousToken); + + return { url, options }; +}; + export default pb; diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 88b76fb..671e2f8 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -4,7 +4,7 @@ import { goto } from '$app/navigation'; import { onMount } from 'svelte'; import Nav from '$lib/components/Nav.svelte'; - import pb from '$lib/pb'; + import pb, { refreshAuthSession } from '$lib/pb'; import { initTheme } from '$lib/theme'; let { children } = $props(); @@ -27,7 +27,11 @@ ready = true; }; - checkAuth(); + const initialize = async () => { + await refreshAuthSession(); + checkAuth(); + }; + void initialize(); // Listen for auth changes (login/logout) to update ready state const unsub = pb.authStore.onChange(() => {