Compare commits

...

5 Commits

Author SHA1 Message Date
ParthSareen
b978dc28d2 auth: fix tests for Windows compatibility
- Set USERPROFILE env var on Windows (HOME on Unix)
- Add cross-platform DirectoryIsFile test for write failures
2026-01-07 18:51:56 -08:00
ParthSareen
106640b978 auth: fix lint and formatting issues 2026-01-07 16:33:02 -08:00
ParthSareen
f4e5978e4f auth: add tests for sign-in state functions
Add comprehensive test coverage for local sign-in state storage:

signin_state_test.go:
- Basic set/get/clear operations
- File not found and invalid JSON handling
- Atomic write verification
- File permissions check
- Round-trip test

signin_flow_test.go:
- WhoamiHandler flow simulation
- Offline scenarios (with/without cache)
- Session overwrite behavior
- Edge cases: empty name, unicode, special chars, long values
- Concurrent read/write access
- Filesystem edge cases (read-only directory)
2026-01-07 15:41:36 -08:00
ParthSareen
528de77b4b server: use local cache for sign-in status
Update WhoamiHandler and SignoutHandler to use local sign-in cache:

WhoamiHandler:
- Check local cache first before making network call
- Cache successful sign-in response from ollama.com
- Return cached user data when available (no network needed)

SignoutHandler:
- Clear local cache after successful sign-out
2026-01-07 15:36:31 -08:00
ParthSareen
eed58a312d auth: add local sign-in state storage
Add functions to cache sign-in state locally at ~/.ollama/signin.json,
enabling offline sign-in status checks without network calls.

- SignInState struct with Name, Email, and CachedAt fields
- GetSignInState() reads cached state from disk
- SetSignInState() atomically writes state (temp file + rename)
- ClearSignInState() removes the cached state file
- IsSignedIn() quick check for valid cached sign-in
2026-01-07 15:35:48 -08:00
4 changed files with 746 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -12,11 +13,22 @@ import (
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const defaultPrivateKey = "id_ed25519"
const (
defaultPrivateKey = "id_ed25519"
signInStateFile = "signin.json"
)
// SignInState represents the locally cached sign-in state
type SignInState struct {
Name string `json:"name"`
Email string `json:"email"`
CachedAt time.Time `json:"cached_at"`
}
func GetPublicKey() (string, error) {
home, err := os.UserHomeDir()
@@ -83,3 +95,75 @@ func Sign(ctx context.Context, bts []byte) (string, error) {
// signature is <pubkey>:<signature>
return fmt.Sprintf("%s:%s", bytes.TrimSpace(parts[1]), base64.StdEncoding.EncodeToString(signedData.Blob)), nil
}
// GetSignInState reads the locally cached sign-in state from ~/.ollama/signin.json
func GetSignInState() (*SignInState, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
statePath := filepath.Join(home, ".ollama", signInStateFile)
data, err := os.ReadFile(statePath)
if err != nil {
return nil, err
}
var state SignInState
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
// SetSignInState atomically writes the sign-in state to ~/.ollama/signin.json
func SetSignInState(state *SignInState) error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
ollamaDir := filepath.Join(home, ".ollama")
statePath := filepath.Join(ollamaDir, signInStateFile)
tmpPath := statePath + ".tmp"
state.CachedAt = time.Now()
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
// Write to temp file first
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
return err
}
// Atomic rename
return os.Rename(tmpPath, statePath)
}
// ClearSignInState removes the locally cached sign-in state
func ClearSignInState() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
statePath := filepath.Join(home, ".ollama", signInStateFile)
err = os.Remove(statePath)
if errors.Is(err, os.ErrNotExist) {
return nil // Already cleared
}
return err
}
// IsSignedIn returns true if there is a valid locally cached sign-in state
func IsSignedIn() bool {
state, err := GetSignInState()
if err != nil {
return false
}
return state.Name != ""
}

294
auth/signin_flow_test.go Normal file
View File

@@ -0,0 +1,294 @@
package auth
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestWhoamiHandlerFlow simulates the WhoamiHandler logic flow
func TestWhoamiHandlerFlow(t *testing.T) {
_ = setupTestDir(t)
// Scenario 1: No local cache - should indicate need for network call
t.Run("NoCache_RequiresNetwork", func(t *testing.T) {
state, err := GetSignInState()
if err == nil && state != nil && state.Name != "" {
t.Error("should not have cached state initially")
}
// In real WhoamiHandler, this would trigger a network call
})
// Scenario 2: Simulate successful sign-in from network
t.Run("CacheAfterNetworkSuccess", func(t *testing.T) {
// Simulate receiving user from ollama.com
networkUser := &SignInState{
Name: "networkuser",
Email: "network@example.com",
}
// Cache the result (as WhoamiHandler would)
if err := SetSignInState(networkUser); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Verify it's cached
if !IsSignedIn() {
t.Error("should be signed in after caching")
}
})
// Scenario 3: Subsequent calls use cache (no network)
t.Run("SubsequentCalls_UseCache", func(t *testing.T) {
state, err := GetSignInState()
if err != nil {
t.Fatalf("GetSignInState failed: %v", err)
}
if state.Name != "networkuser" {
t.Errorf("expected cached name 'networkuser', got '%s'", state.Name)
}
// In real WhoamiHandler, this would skip the network call and return cached data
})
// Scenario 4: Sign-out clears cache
t.Run("SignOut_ClearsCache", func(t *testing.T) {
// Simulate SignoutHandler clearing cache
if err := ClearSignInState(); err != nil {
t.Fatalf("ClearSignInState failed: %v", err)
}
if IsSignedIn() {
t.Error("should not be signed in after sign-out")
}
})
// Scenario 5: After sign-out, next call requires network
t.Run("AfterSignOut_RequiresNetwork", func(t *testing.T) {
state, err := GetSignInState()
if err == nil && state != nil && state.Name != "" {
t.Error("should require network after sign-out")
}
})
}
// TestOfflineScenarios tests behavior when offline
func TestOfflineScenarios(t *testing.T) {
_ = setupTestDir(t)
t.Run("Offline_WithCache_Works", func(t *testing.T) {
// Pre-populate cache (simulate previous sign-in)
state := &SignInState{Name: "cacheduser", Email: "cached@example.com"}
if err := SetSignInState(state); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Simulate offline check - should work with cache
cached, err := GetSignInState()
if err != nil {
t.Fatalf("should work offline with cache: %v", err)
}
if cached.Name != "cacheduser" {
t.Errorf("expected 'cacheduser', got '%s'", cached.Name)
}
if !IsSignedIn() {
t.Error("should report signed in with cache")
}
})
t.Run("Offline_WithoutCache_Fails", func(t *testing.T) {
// Clear cache first
ClearSignInState()
// Offline without cache should indicate not signed in
if IsSignedIn() {
t.Error("should not be signed in offline without cache")
}
_, err := GetSignInState()
if err == nil {
t.Error("should get error when no cache exists")
}
})
}
// TestMultipleSessions tests overwriting sessions
func TestMultipleSessions(t *testing.T) {
_ = setupTestDir(t)
t.Run("NewSignIn_OverwritesOld", func(t *testing.T) {
// First user signs in
user1 := &SignInState{Name: "user1", Email: "user1@example.com"}
SetSignInState(user1)
// Different user signs in (should overwrite)
user2 := &SignInState{Name: "user2", Email: "user2@example.com"}
SetSignInState(user2)
// Should have user2's data
state, _ := GetSignInState()
if state.Name != "user2" {
t.Errorf("expected 'user2', got '%s'", state.Name)
}
})
}
// TestEdgeCases tests various edge cases
func TestEdgeCases(t *testing.T) {
_ = setupTestDir(t)
t.Run("EmptyName_NotSignedIn", func(t *testing.T) {
// User with empty name should not count as signed in
state := &SignInState{Name: "", Email: "noname@example.com"}
SetSignInState(state)
if IsSignedIn() {
t.Error("empty name should not count as signed in")
}
})
t.Run("SpecialCharactersInName", func(t *testing.T) {
state := &SignInState{
Name: "user with spaces & symbols!@#$%",
Email: "special@example.com",
}
if err := SetSignInState(state); err != nil {
t.Fatalf("failed to set state with special chars: %v", err)
}
read, err := GetSignInState()
if err != nil {
t.Fatalf("failed to read state: %v", err)
}
if read.Name != state.Name {
t.Errorf("name mismatch with special chars")
}
})
t.Run("UnicodeInName", func(t *testing.T) {
state := &SignInState{
Name: "用户名 🎉 émojis",
Email: "unicode@example.com",
}
if err := SetSignInState(state); err != nil {
t.Fatalf("failed to set state with unicode: %v", err)
}
read, err := GetSignInState()
if err != nil {
t.Fatalf("failed to read state: %v", err)
}
if read.Name != state.Name {
t.Errorf("name mismatch with unicode")
}
})
t.Run("VeryLongEmail", func(t *testing.T) {
longEmail := ""
for range 1000 {
longEmail += "a"
}
longEmail += "@example.com"
state := &SignInState{Name: "user", Email: longEmail}
if err := SetSignInState(state); err != nil {
t.Fatalf("failed to set state with long email: %v", err)
}
read, err := GetSignInState()
if err != nil {
t.Fatalf("failed to read state: %v", err)
}
if read.Email != longEmail {
t.Error("email mismatch with long value")
}
})
}
// TestConcurrentAccess tests race conditions
func TestConcurrentAccess(t *testing.T) {
_ = setupTestDir(t)
t.Run("ConcurrentReads", func(t *testing.T) {
// Set up initial state
state := &SignInState{Name: "concurrent", Email: "concurrent@example.com"}
SetSignInState(state)
// Multiple concurrent reads should all succeed
done := make(chan bool, 10)
for range 10 {
go func() {
read, err := GetSignInState()
if err != nil {
t.Errorf("concurrent read failed: %v", err)
}
if read.Name != "concurrent" {
t.Errorf("wrong name in concurrent read")
}
done <- true
}()
}
for range 10 {
<-done
}
})
t.Run("ConcurrentWrites", func(t *testing.T) {
// Multiple concurrent writes - last one should win
done := make(chan bool, 10)
for range 10 {
go func() {
state := &SignInState{
Name: "user",
Email: "user@example.com",
}
SetSignInState(state)
done <- true
}()
}
for range 10 {
<-done
}
// Should have some valid state
if !IsSignedIn() {
t.Error("should be signed in after concurrent writes")
}
})
}
// TestFileSystemEdgeCases tests filesystem-related edge cases
func TestFileSystemEdgeCases(t *testing.T) {
t.Run("DirectoryIsFile", func(t *testing.T) {
tmpDir := t.TempDir()
// Create a file where .ollama directory should be
ollamaPath := filepath.Join(tmpDir, ".ollama")
if err := os.WriteFile(ollamaPath, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("failed to create blocking file: %v", err)
}
// Override home directory
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", tmpDir)
} else {
t.Setenv("HOME", tmpDir)
}
// Try to write - should fail because .ollama is a file, not a directory
state := &SignInState{Name: "newuser", Email: "new@example.com"}
err := SetSignInState(state)
if err == nil {
t.Error("should fail when .ollama is a file instead of directory")
}
})
}

347
auth/signin_state_test.go Normal file
View File

@@ -0,0 +1,347 @@
package auth
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func setupTestDir(t *testing.T) string {
t.Helper()
// Create a temporary directory for testing
tmpDir := t.TempDir()
// Create .ollama subdirectory
ollamaDir := filepath.Join(tmpDir, ".ollama")
if err := os.MkdirAll(ollamaDir, 0o755); err != nil {
t.Fatalf("failed to create .ollama dir: %v", err)
}
// Override home directory for tests (platform-specific)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", tmpDir)
} else {
t.Setenv("HOME", tmpDir)
}
return tmpDir
}
func TestSetSignInState(t *testing.T) {
_ = setupTestDir(t)
state := &SignInState{
Name: "testuser",
Email: "test@example.com",
}
err := SetSignInState(state)
if err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Verify file was created
home, _ := os.UserHomeDir()
statePath := filepath.Join(home, ".ollama", signInStateFile)
data, err := os.ReadFile(statePath)
if err != nil {
t.Fatalf("failed to read state file: %v", err)
}
var savedState SignInState
if err := json.Unmarshal(data, &savedState); err != nil {
t.Fatalf("failed to unmarshal state: %v", err)
}
if savedState.Name != "testuser" {
t.Errorf("expected name 'testuser', got '%s'", savedState.Name)
}
if savedState.Email != "test@example.com" {
t.Errorf("expected email 'test@example.com', got '%s'", savedState.Email)
}
if savedState.CachedAt.IsZero() {
t.Error("expected CachedAt to be set, got zero time")
}
// Verify CachedAt is recent (within last minute)
if time.Since(savedState.CachedAt) > time.Minute {
t.Errorf("CachedAt is too old: %v", savedState.CachedAt)
}
}
func TestSetSignInState_Overwrites(t *testing.T) {
_ = setupTestDir(t)
// Set initial state
state1 := &SignInState{Name: "user1", Email: "user1@example.com"}
if err := SetSignInState(state1); err != nil {
t.Fatalf("first SetSignInState failed: %v", err)
}
// Overwrite with new state
state2 := &SignInState{Name: "user2", Email: "user2@example.com"}
if err := SetSignInState(state2); err != nil {
t.Fatalf("second SetSignInState failed: %v", err)
}
// Verify only new state exists
readState, err := GetSignInState()
if err != nil {
t.Fatalf("GetSignInState failed: %v", err)
}
if readState.Name != "user2" {
t.Errorf("expected name 'user2', got '%s'", readState.Name)
}
}
func TestGetSignInState(t *testing.T) {
_ = setupTestDir(t)
// First set a state
originalState := &SignInState{
Name: "testuser",
Email: "test@example.com",
}
if err := SetSignInState(originalState); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Now read it back
readState, err := GetSignInState()
if err != nil {
t.Fatalf("GetSignInState failed: %v", err)
}
if readState.Name != originalState.Name {
t.Errorf("expected name '%s', got '%s'", originalState.Name, readState.Name)
}
if readState.Email != originalState.Email {
t.Errorf("expected email '%s', got '%s'", originalState.Email, readState.Email)
}
}
func TestGetSignInState_NoFile(t *testing.T) {
_ = setupTestDir(t)
// Try to read without any file existing
state, err := GetSignInState()
if err == nil {
t.Error("expected error when file doesn't exist, got nil")
}
if state != nil {
t.Errorf("expected nil state, got %+v", state)
}
}
func TestGetSignInState_InvalidJSON(t *testing.T) {
tmpDir := setupTestDir(t)
// Write invalid JSON to the state file
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
if err := os.WriteFile(statePath, []byte("not valid json"), 0o600); err != nil {
t.Fatalf("failed to write invalid json: %v", err)
}
state, err := GetSignInState()
if err == nil {
t.Error("expected error for invalid JSON, got nil")
}
if state != nil {
t.Errorf("expected nil state for invalid JSON, got %+v", state)
}
}
func TestClearSignInState(t *testing.T) {
_ = setupTestDir(t)
// First set a state
state := &SignInState{Name: "testuser", Email: "test@example.com"}
if err := SetSignInState(state); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Verify file exists
home, _ := os.UserHomeDir()
statePath := filepath.Join(home, ".ollama", signInStateFile)
if _, err := os.Stat(statePath); os.IsNotExist(err) {
t.Fatal("state file should exist before clearing")
}
// Clear the state
if err := ClearSignInState(); err != nil {
t.Fatalf("ClearSignInState failed: %v", err)
}
// Verify file is gone
if _, err := os.Stat(statePath); !os.IsNotExist(err) {
t.Error("state file should be deleted after clearing")
}
}
func TestClearSignInState_NoFile(t *testing.T) {
_ = setupTestDir(t)
// Clear when no file exists should not error
err := ClearSignInState()
if err != nil {
t.Errorf("ClearSignInState should not error when file doesn't exist: %v", err)
}
}
func TestClearSignInState_Idempotent(t *testing.T) {
_ = setupTestDir(t)
// Set a state first
state := &SignInState{Name: "testuser", Email: "test@example.com"}
if err := SetSignInState(state); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Clear multiple times should not error
for i := range 3 {
if err := ClearSignInState(); err != nil {
t.Errorf("ClearSignInState iteration %d failed: %v", i, err)
}
}
}
func TestIsSignedIn(t *testing.T) {
_ = setupTestDir(t)
// Initially not signed in
if IsSignedIn() {
t.Error("should not be signed in initially")
}
// Set a state with a name
state := &SignInState{Name: "testuser", Email: "test@example.com"}
if err := SetSignInState(state); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Now should be signed in
if !IsSignedIn() {
t.Error("should be signed in after setting state")
}
// Clear the state
if err := ClearSignInState(); err != nil {
t.Fatalf("ClearSignInState failed: %v", err)
}
// Should not be signed in after clearing
if IsSignedIn() {
t.Error("should not be signed in after clearing state")
}
}
func TestIsSignedIn_EmptyName(t *testing.T) {
tmpDir := setupTestDir(t)
// Write a state with empty name directly
state := SignInState{
Name: "",
Email: "test@example.com",
CachedAt: time.Now(),
}
data, _ := json.Marshal(state)
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
if err := os.WriteFile(statePath, data, 0o600); err != nil {
t.Fatalf("failed to write state: %v", err)
}
// Should not be signed in with empty name
if IsSignedIn() {
t.Error("should not be signed in with empty name")
}
}
func TestSetSignInState_AtomicWrite(t *testing.T) {
tmpDir := setupTestDir(t)
state := &SignInState{Name: "testuser", Email: "test@example.com"}
if err := SetSignInState(state); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Verify temp file is cleaned up
tmpPath := filepath.Join(tmpDir, ".ollama", signInStateFile+".tmp")
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Error("temp file should be cleaned up after atomic write")
}
// Verify final file exists
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
if _, err := os.Stat(statePath); os.IsNotExist(err) {
t.Error("final state file should exist")
}
}
func TestSetSignInState_FilePermissions(t *testing.T) {
tmpDir := setupTestDir(t)
state := &SignInState{Name: "testuser", Email: "test@example.com"}
if err := SetSignInState(state); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
statePath := filepath.Join(tmpDir, ".ollama", signInStateFile)
info, err := os.Stat(statePath)
if err != nil {
t.Fatalf("failed to stat state file: %v", err)
}
// Check file permissions (should be 0600 - owner read/write only)
perm := info.Mode().Perm()
if perm != 0o600 {
t.Errorf("expected permissions 0600, got %04o", perm)
}
}
func TestRoundTrip(t *testing.T) {
_ = setupTestDir(t)
// Test full round trip: set -> get -> clear -> get
original := &SignInState{
Name: "roundtrip_user",
Email: "roundtrip@example.com",
}
// Set
if err := SetSignInState(original); err != nil {
t.Fatalf("SetSignInState failed: %v", err)
}
// Get and verify
retrieved, err := GetSignInState()
if err != nil {
t.Fatalf("GetSignInState failed: %v", err)
}
if retrieved.Name != original.Name {
t.Errorf("name mismatch: expected '%s', got '%s'", original.Name, retrieved.Name)
}
if retrieved.Email != original.Email {
t.Errorf("email mismatch: expected '%s', got '%s'", original.Email, retrieved.Email)
}
// Clear
if err := ClearSignInState(); err != nil {
t.Fatalf("ClearSignInState failed: %v", err)
}
// Get should fail now
_, err = GetSignInState()
if err == nil {
t.Error("GetSignInState should fail after clear")
}
}

View File

@@ -1745,6 +1745,14 @@ func streamResponse(c *gin.Context, ch chan any) {
}
func (s *Server) WhoamiHandler(c *gin.Context) {
// Check local cache first
state, err := auth.GetSignInState()
if err == nil && state.Name != "" {
c.JSON(http.StatusOK, &api.UserResponse{Name: state.Name, Email: state.Email})
return
}
// No local cache - try network
// todo allow other hosts
u, err := url.Parse("https://ollama.com")
if err != nil {
@@ -1772,6 +1780,13 @@ func (s *Server) WhoamiHandler(c *gin.Context) {
return
}
// Cache successful result locally
if user != nil && user.Name != "" {
if err := auth.SetSignInState(&auth.SignInState{Name: user.Name, Email: user.Email}); err != nil {
slog.Warn("failed to cache sign-in state", "error", err)
}
}
c.JSON(http.StatusOK, user)
}
@@ -1805,6 +1820,11 @@ func (s *Server) SignoutHandler(c *gin.Context) {
return
}
// Clear local sign-in cache
if err := auth.ClearSignInState(); err != nil {
slog.Warn("failed to clear local sign-in state", "error", err)
}
c.JSON(http.StatusOK, nil)
}