LogseqSpringThing implements a comprehensive authentication system using Nostr (Notes and Other Stuff Transmitted by Relays) protocol for decentralized identity verification, combined with session-based authentication and role-based access control.
-
Nostr Authentication Service (
src/services/nostr_service.rs)- Verifies Nostr event signatures
- Manages user sessions
- Handles power user identification
- Session cleanup and expiration
-
Nostr Handler (
src/handlers/nostr_handler.rs)- HTTP endpoints for authentication operations, delegating core logic to
NostrService. - Handles session validation and refresh requests.
- Manages API key updates by interacting with
NostrService. - Controls feature access by querying
FeatureAccess.
- HTTP endpoints for authentication operations, delegating core logic to
-
Client Authentication Service (
client/src/services/nostrAuthService.ts)- NIP-07 browser extension integration
- Session persistence
- Authentication state management
- Automatic session restoration
-
Protected Settings (
src/models/protected_settings.rs)- User data storage
- API key management
- Session token storage
- Security configuration
sequenceDiagram
participant User
participant Browser
participant NIP07Ext as NIP-07 Extension
participant Client
participant Server
participant NostrService
User->>Browser: Click Login
Browser->>Client: Trigger login()
Client->>NIP07Ext: window.nostr.getPublicKey()
NIP07Ext->>User: Request permission
User->>NIP07Ext: Approve
NIP07Ext->>Client: Return pubkey
Client->>Client: Create NIP-42 Auth Event
Client->>NIP07Ext: window.nostr.signEvent()
NIP07Ext->>Client: Return signed event
Client->>Server: POST /api/auth/nostr
Server->>NostrService: verify_auth_event()
NostrService->>NostrService: Verify signature
NostrService->>NostrService: Generate session token
NostrService->>Server: Return user + token
Server->>Client: AuthResponse
Client->>Client: Store session
Client->>Browser: Update UI
sequenceDiagram
participant Client
participant Server
participant NostrService
Client->>Client: Load stored session
Client->>Server: POST /api/auth/nostr/verify
Server->>NostrService: validate_session()
NostrService->>NostrService: Check token & expiry
NostrService->>Server: Return validation result
Server->>Client: VerifyResponse
Client->>Client: Update auth state
sequenceDiagram
participant User
participant Client
participant Server
participant NostrService
User->>Client: Click Logout
Client->>Client: Clear local session
Client->>Server: DELETE /api/auth/nostr
Server->>NostrService: logout()
NostrService->>NostrService: Clear session token
NostrService->>Server: Success
Server->>Client: Response
Client->>Client: Update UI
The system uses NIP-42 specification for authentication with Kind 22242 events:
interface AuthEvent {
id: string; // Event ID
pubkey: string; // User's public key
content: string; // Authentication message
sig: string; // Event signature
created_at: number; // Unix timestamp
kind: 22242; // NIP-42 auth event kind
tags: string[][]; // Event tags
}Example authentication event:
{
"id": "event-id-hash",
"pubkey": "user-public-key-hex",
"content": "Authenticate to LogseqSpringThing",
"sig": "event-signature",
"created_at": 1234567890,
"kind": 22242,
"tags": [
["relay", "wss://relay.damus.io"],
["challenge", "uuid-v4-challenge"]
]
}The server verifies the authenticity of authentication events using the Nostr SDK:
pub async fn verify_auth_event(&self, event: AuthEvent) -> Result<NostrUser, NostrError> {
// Convert to Nostr Event for verification
let json_str = serde_json::to_string(&event)?;
let nostr_event = Event::from_json(&json_str)?;
// Verify signature
if let Err(e) = nostr_event.verify() {
return Err(NostrError::InvalidSignature);
}
// Register new user if not already registered
let mut feature_access = self.feature_access.write().await;
if feature_access.register_new_user(&event.pubkey) {
info!("Registered new user with basic access: {}", event.pubkey);
}
// Create/update user session
let session_token = Uuid::new_v4().to_string();
let user = NostrUser {
pubkey: event.pubkey.clone(),
npub: nostr_event.pubkey.to_bech32()?,
is_power_user: self.power_user_pubkeys.contains(&event.pubkey),
session_token: Some(session_token),
// ... other fields
};
Ok(user)
}- Generated using UUID v4 for uniqueness
- Stored in memory with user data
- Configurable expiration (default: 1 hour)
- Automatically cleaned up after 24 hours of inactivity (via
cleanup_sessionsmethod, which needs to be explicitly called or scheduled)
Client-side:
localStorage.setItem('nostr_session_token', token);
localStorage.setItem('nostr_user', JSON.stringify(user));Server-side:
pub struct NostrUser {
pub pubkey: String,
pub npub: String,
pub is_power_user: bool,
pub session_token: Option<String>,
pub last_seen: i64,
// ... other fields
}Sessions are validated on each authenticated request:
pub async fn validate_session(&self, pubkey: &str, token: &str) -> bool {
if let Some(user) = self.get_user(pubkey).await {
if let Some(session_token) = user.session_token {
let now = Utc::now().timestamp();
if now - user.last_seen <= self.token_expiry {
return session_token == token;
}
}
}
false
}-
Regular Users
- Basic feature access
- Can store their own API keys
- Limited to configured features
-
Power Users
- Full feature access
- Use environment-based API keys
- Cannot modify API keys via API
- Identified by pubkey in
POWER_USER_PUBKEYSenv var
Features are controlled through the FeatureAccess configuration:
pub struct FeatureAccess {
basic_features: Vec<String>,
power_user_features: Vec<String>,
user_features: HashMap<String, Vec<String>>,
}Example feature check:
async fn check_feature_access(
req: HttpRequest,
feature_access: web::Data<FeatureAccess>,
feature: web::Path<String>,
) -> Result<HttpResponse, Error> {
let pubkey = req.headers()
.get("X-Nostr-Pubkey")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
Ok(HttpResponse::Ok().json(json!({
"has_access": feature_access.has_feature_access(pubkey, &feature)
})))
}API keys are stored differently based on user type:
pub async fn get_api_keys(protected_settings_addr: Addr<ProtectedSettingsActor>, pubkey: &str) -> ApiKeys {
match protected_settings_addr.send(GetApiKeys { pubkey: pubkey.to_string() }).await {
Ok(api_keys) => api_keys,
Err(e) => {
error!("Failed to get API keys from ProtectedSettingsActor: {}", e);
ApiKeys::default() // Return default if actor call fails
}
}
}Only non-power users can update their API keys:
async fn update_api_keys(
req: web::Json<ApiKeysRequest>,
app_state: web::Data<AppState>,
pubkey: web::Path<String>,
) -> Result<HttpResponse, Error> {
let api_keys = ApiKeys {
perplexity: req.perplexity.clone(),
openai: req.openai.clone(),
ragflow: req.ragflow.clone(),
};
match app_state.update_nostr_user_api_keys(&pubkey, api_keys).await {
Ok(user) => Ok(HttpResponse::Ok().json(user)),
Err(e) => {
// Handle specific errors from update_nostr_user_api_keys
let error_response = match e.as_str() {
"Power user operation" => HttpResponse::Forbidden().json(json!({ "error": "Cannot update API keys for power users" })),
"User not found" => HttpResponse::NotFound().json(json!({ "error": "User not found" })),
_ => HttpResponse::InternalServerError().json(json!({ "error": format!("Failed to update API keys: {}", e) })),
};
Ok(error_response)
}
}
}Required environment variables for security:
# Authentication
AUTH_TOKEN_EXPIRY=3600 # Session expiry in seconds
POWER_USER_PUBKEYS=pubkey1,pubkey2,pubkey3 # Comma-separated list
# API Keys (for power users)
PERPLEXITY_API_KEY=your-key
OPENAI_API_KEY=your-key
RAGFLOW_API_KEY=your-keyAuthenticated requests must include:
X-Nostr-Pubkey: user-public-key
Authorization: Bearer session-token
Configure allowed origins in protected settings:
{
"security": {
"allowed_origins": [
"http://localhost:3000",
"https://your-domain.com"
]
}
}- Use HTTPS in production to protect session tokens
- Implement rate limiting on authentication endpoints
- Regular session cleanup to prevent memory bloat
- Short session expiry times (default: 1 hour)
- Store sessions in localStorage (not accessible to other domains)
- Clear sessions on logout
- Validate sessions on app initialization
- Handle session expiry gracefully
| Endpoint | Method | Description | Auth Required |
|---|---|---|---|
/api/auth/nostr |
POST | Login with Nostr event | No |
/api/auth/nostr |
DELETE | Logout | Yes |
/api/auth/nostr/verify |
POST | Verify session | No |
/api/auth/nostr/refresh |
POST | Refresh session | Yes |
/api/auth/nostr/api-keys |
GET | Get user's API keys | Yes |
/api/auth/nostr/api-keys |
POST | Update API keys | Yes |
/api/auth/nostr/power-user-status |
GET | Check power user status | Yes |
/api/auth/nostr/features |
GET | Get available features | Yes |
/api/auth/nostr/features/{feature} |
GET | Check specific feature access | Yes |
POST /api/auth/nostr
Content-Type: application/json
{
"id": "event-id",
"pubkey": "user-pubkey",
"content": "Authenticate to LogseqSpringThing",
"sig": "signature",
"created_at": 1234567890,
"kind": 22242,
"tags": [["relay", "wss://relay.damus.io"], ["challenge", "uuid"]]
}{
"user": {
"pubkey": "user-pubkey",
"npub": "npub1...",
"isPowerUser": false
},
"token": "session-token-uuid",
"expiresAt": 1234567890,
"features": ["feature1", "feature2"]
}POST /api/auth/nostr/verify
Content-Type: application/json
{
"pubkey": "user-pubkey",
"token": "session-token"
}{
"valid": true,
"user": {
"pubkey": "user-pubkey",
"npub": "npub1...",
"isPowerUser": false
},
"features": ["feature1", "feature2"]
}// Invalid signature
{
"error": "Invalid signature"
}
// Session expired
{
"error": "Session expired"
}
// User not found
{
"error": "User not found"
}
// Power user operation
{
"error": "Cannot update API keys for power users"
}try {
await nostrAuth.login();
} catch (error) {
if (error.message.includes('User rejected')) {
// User cancelled in extension
} else if (error.message.includes('Invalid signature')) {
// Authentication failed
} else if (error.message.includes('extension')) {
// No NIP-07 extension found
}
}- Install a NIP-07 compatible browser extension (e.g., Alby, nos2x)
- Generate or import a Nostr keypair
- Navigate to the application
- Click login and approve the authentication request
- Verify session persistence across page refreshes
- Test logout functionality
Example test for session validation:
#[tokio::test]
async fn test_session_validation() {
let service = NostrService::new();
// Create test user
let event = create_test_auth_event();
let user = service.verify_auth_event(event).await.unwrap();
let token = user.session_token.unwrap();
// Test valid session
assert!(service.validate_session(&user.pubkey, &token).await);
// Test invalid token
assert!(!service.validate_session(&user.pubkey, "invalid").await);
}-
"Nostr NIP-07 provider not found"
- Install a NIP-07 compatible browser extension
- Ensure the extension is enabled for the site
-
"Invalid signature"
- Check system time synchronization
- Verify the extension is signing with the correct key
-
"Session expired"
- Sessions expire after configured time (default: 1 hour)
- Implement automatic refresh or re-authentication
-
"Cannot update API keys for power users"
- Power users must use environment variables for API keys
- Remove pubkey from
POWER_USER_PUBKEYSto allow API key updates
Enable debug logging for authentication:
RUST_LOG=logseq_spring_thing::services::nostr_service=debug,logseq_spring_thing::handlers::nostr_handler=debugClient-side debug logging:
const logger = createLogger('NostrAuthService');
logger.setLevel('debug');