Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion cmd/knowledgehub/collections.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
75 changes: 75 additions & 0 deletions cmd/knowledgehub/collections_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
82 changes: 82 additions & 0 deletions openspec/specs/remember-me/spec.md
Original file line number Diff line number Diff line change
@@ -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
111 changes: 110 additions & 1 deletion ui/src/lib/pb.ts
Original file line number Diff line number Diff line change
@@ -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<void> | 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<void> {
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<void> {
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<string, any>, 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<boolean> {
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;
8 changes: 6 additions & 2 deletions ui/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(() => {
Expand Down