diff --git a/pkg/appview/db/oauth_store.go b/pkg/appview/db/oauth_store.go index 3997f6a..40aa8f9 100644 --- a/pkg/appview/db/oauth_store.go +++ b/pkg/appview/db/oauth_store.go @@ -337,6 +337,103 @@ func scopesMatch(stored, desired []string) bool { return true } +// GetSessionStats returns statistics about stored OAuth sessions +// Useful for monitoring and debugging session health +func (s *OAuthStore) GetSessionStats(ctx context.Context) (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + // Total sessions + var totalSessions int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM oauth_sessions`).Scan(&totalSessions) + if err != nil { + return nil, fmt.Errorf("failed to count sessions: %w", err) + } + stats["total_sessions"] = totalSessions + + // Sessions by age + var sessionsOlderThan1Hour, sessionsOlderThan1Day, sessionsOlderThan7Days int + + err = s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM oauth_sessions + WHERE updated_at < datetime('now', '-1 hour') + `).Scan(&sessionsOlderThan1Hour) + if err == nil { + stats["sessions_idle_1h+"] = sessionsOlderThan1Hour + } + + err = s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM oauth_sessions + WHERE updated_at < datetime('now', '-1 day') + `).Scan(&sessionsOlderThan1Day) + if err == nil { + stats["sessions_idle_1d+"] = sessionsOlderThan1Day + } + + err = s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM oauth_sessions + WHERE updated_at < datetime('now', '-7 days') + `).Scan(&sessionsOlderThan7Days) + if err == nil { + stats["sessions_idle_7d+"] = sessionsOlderThan7Days + } + + // Recent sessions (updated in last 5 minutes) + var recentSessions int + err = s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM oauth_sessions + WHERE updated_at > datetime('now', '-5 minutes') + `).Scan(&recentSessions) + if err == nil { + stats["sessions_active_5m"] = recentSessions + } + + return stats, nil +} + +// ListSessionsForMonitoring returns a list of all sessions with basic info for monitoring +// Returns: DID, session age (minutes), last update time +func (s *OAuthStore) ListSessionsForMonitoring(ctx context.Context) ([]map[string]interface{}, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT + account_did, + session_id, + created_at, + updated_at, + CAST((julianday('now') - julianday(updated_at)) * 24 * 60 AS INTEGER) as idle_minutes + FROM oauth_sessions + ORDER BY updated_at DESC + `) + if err != nil { + return nil, fmt.Errorf("failed to query sessions: %w", err) + } + defer rows.Close() + + var sessions []map[string]interface{} + for rows.Next() { + var did, sessionID, createdAt, updatedAt string + var idleMinutes int + + if err := rows.Scan(&did, &sessionID, &createdAt, &updatedAt, &idleMinutes); err != nil { + slog.Warn("Failed to scan session row", "error", err) + continue + } + + sessions = append(sessions, map[string]interface{}{ + "did": did, + "session_id": sessionID, + "created_at": createdAt, + "updated_at": updatedAt, + "idle_minutes": idleMinutes, + }) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating sessions: %w", err) + } + + return sessions, nil +} + // makeSessionKey creates a composite key for session storage func makeSessionKey(did, sessionID string) string { return fmt.Sprintf("%s:%s", did, sessionID) diff --git a/pkg/appview/middleware/registry.go b/pkg/appview/middleware/registry.go index 535b195..1ece9ea 100644 --- a/pkg/appview/middleware/registry.go +++ b/pkg/appview/middleware/registry.go @@ -167,9 +167,21 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name var err error serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint) if err != nil { - slog.Error("Failed to get service token", "component", "registry/middleware", "did", did, "error", err) - slog.Error("User needs to re-authenticate via credential helper", "component", "registry/middleware") - return nil, nr.authErrorMessage("OAuth session expired") + slog.Error("Failed to get service token", + "component", "registry/middleware", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "error", err) + + // Check if this is likely an OAuth session expiration + errMsg := err.Error() + if strings.Contains(errMsg, "OAuth session") || strings.Contains(errMsg, "OAuth validation") { + return nil, nr.authErrorMessage("OAuth session expired or invalidated by PDS. Your session has been cleared") + } + + // Generic service token error + return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", err)) } } diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index 5f0150a..4d2f819 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -231,3 +231,53 @@ func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.Clien } return session, nil } + +// DeleteSession removes an OAuth session from storage and optionally invalidates the UI session +// This is called when OAuth authentication fails to force re-authentication +func (r *Refresher) DeleteSession(ctx context.Context, did string) error { + // Parse DID + accountDID, err := syntax.ParseDID(did) + if err != nil { + return fmt.Errorf("failed to parse DID: %w", err) + } + + // Get the session ID before deleting (for logging) + type sessionGetter interface { + GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) + } + + getter, ok := r.clientApp.Store.(sessionGetter) + if !ok { + return fmt.Errorf("store must implement GetLatestSessionForDID") + } + + _, sessionID, err := getter.GetLatestSessionForDID(ctx, did) + if err != nil { + // No session to delete - this is fine + slog.Debug("No OAuth session to delete", "did", did) + return nil + } + + // Delete OAuth session from database + if err := r.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil { + slog.Warn("Failed to delete OAuth session", "did", did, "sessionID", sessionID, "error", err) + return fmt.Errorf("failed to delete OAuth session: %w", err) + } + + slog.Info("Deleted stale OAuth session", + "component", "oauth/refresher", + "did", did, + "sessionID", sessionID, + "reason", "OAuth authentication failed") + + // Also invalidate the UI session if store is configured + if r.uiSessionStore != nil { + r.uiSessionStore.DeleteByDID(did) + slog.Info("Invalidated UI session for DID", + "component", "oauth/refresher", + "did", did, + "reason", "OAuth session deleted") + } + + return nil +} diff --git a/pkg/auth/token/servicetoken.go b/pkg/auth/token/servicetoken.go index 334a10b..d53b226 100644 --- a/pkg/auth/token/servicetoken.go +++ b/pkg/auth/token/servicetoken.go @@ -48,6 +48,23 @@ func GetOrFetchServiceToken( if err != nil { // OAuth session unavailable - fail InvalidateServiceToken(did, holdDID) + slog.Error("Failed to get OAuth session for service token", + "component", "token/servicetoken", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "error", err, + "errorType", fmt.Sprintf("%T", err)) + + // Delete the stale OAuth session to force re-authentication + // This also invalidates the UI session automatically + if delErr := refresher.DeleteSession(ctx, did); delErr != nil { + slog.Warn("Failed to delete stale OAuth session", + "component", "token/servicetoken", + "did", did, + "error", delErr) + } + return "", fmt.Errorf("failed to get OAuth session: %w", err) } @@ -74,6 +91,25 @@ func GetOrFetchServiceToken( if err != nil { // Auth error - may indicate expired tokens or corrupted session InvalidateServiceToken(did, holdDID) + slog.Error("OAuth authentication failed during service token request", + "component", "token/servicetoken", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "url", serviceAuthURL, + "error", err, + "errorType", fmt.Sprintf("%T", err), + "hint", "This likely means the PDS rejected the OAuth session - refresh token may be expired or invalidated") + + // Delete the stale OAuth session to force re-authentication + // This also invalidates the UI session automatically + if delErr := refresher.DeleteSession(ctx, did); delErr != nil { + slog.Warn("Failed to delete stale OAuth session", + "component", "token/servicetoken", + "did", did, + "error", delErr) + } + return "", fmt.Errorf("OAuth validation failed: %w", err) } defer resp.Body.Close() @@ -82,6 +118,14 @@ func GetOrFetchServiceToken( // Service auth failed bodyBytes, _ := io.ReadAll(resp.Body) InvalidateServiceToken(did, holdDID) + slog.Error("Service token request returned non-200 status", + "component", "token/servicetoken", + "did", did, + "holdDID", holdDID, + "pdsEndpoint", pdsEndpoint, + "statusCode", resp.StatusCode, + "responseBody", string(bodyBytes), + "hint", "PDS rejected the service token request - check PDS logs for details") return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes)) }