Authentication Architecture¶
This document covers the authentication architecture in JunctionRelay, including how cloud authentication works, token management, and a case study of a bug fix related to backend authentication status.
Overview¶
JunctionRelay supports three authentication modes:
- None: No authentication required
- Local: Username/password authentication stored locally in SQLite
- Cloud: OAuth-based authentication via JunctionRelay Cloud
This document focuses primarily on cloud authentication and how the backend manages cloud sessions.
Cloud Authentication Architecture¶
How Cloud Authentication Works¶
When a user authenticates with JunctionRelay Cloud:
- OAuth Flow: User is redirected to JunctionRelay Cloud for OAuth login
- Token Exchange: After successful login, the cloud returns:
accessToken- Short-lived JWT token for API requestsrefreshToken- Long-lived token for obtaining new access tokensuserId- Cloud user identifier embedded in the JWT- Backend Storage: The backend stores these credentials in its SQLite database
- Persistent Session: Tokens persist across browser sessions and backend restarts
Backend vs Frontend Authentication¶
It's crucial to understand the distinction between two authentication states:
| Property | Meaning | Checked Against |
|---|---|---|
isAuthenticated |
Frontend user has a valid session | Frontend session token or cloud token in auth header |
backendAuthenticated |
Backend has stored cloud credentials and can communicate with cloud API | Backend's stored tokens in database |
Key Concept: The backend does NOT have its own separate cloud account. Instead, it stores and operates using the credentials of whichever user last logged in via OAuth.
Token Storage and Management¶
Database Schema¶
CREATE TABLE CloudSessions (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
UserId TEXT NOT NULL,
BackendId TEXT NOT NULL,
EncryptedRefreshToken TEXT NOT NULL,
CreatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
UpdatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(UserId, BackendId)
);
- UserId: Cloud user ID from the JWT token
- BackendId: Unique identifier for this backend instance
- EncryptedRefreshToken: Encrypted refresh token for token renewal
Service_CloudSessionStore¶
The Service_CloudSessionStore class manages token lifecycle:
Key Methods:
- StoreSession() - Stores user's cloud credentials
- GetValidAccessTokenAsync() - Retrieves access token, auto-refreshing if expired
- RefreshTokenInternalAsync() - Contacts cloud API to refresh tokens
- ClearSession() - Removes stored credentials on logout
Auto-Refresh Logic:
public async Task<string?> GetValidAccessTokenAsync(CancellationToken cancellationToken = default)
{
// If we have a valid access token, return it
if (!string.IsNullOrEmpty(_encryptedAccessToken) &&
_tokenExpiryTime.HasValue &&
_tokenExpiryTime.Value > DateTime.UtcNow.AddMinutes(5))
{
return _secretsService.DecryptSecret(_encryptedAccessToken);
}
// Token expired or missing - trigger refresh
_refreshTask = RefreshTokenInternalAsync(cancellationToken);
var refreshedToken = await _refreshTask;
return refreshedToken;
}
When the access token expires (or is within 5 minutes of expiration), the backend automatically uses the refresh token to obtain a new access token from the cloud API, maintaining continuous cloud connectivity.
Navbar Cloud Sync Icon¶
The cloud sync icon in the navbar (visible in cloud mode) indicates the backend's cloud authentication status:
- Green: Backend has valid cloud credentials and can successfully communicate with cloud API
- Red: Backend does NOT have valid cloud credentials OR cannot reach cloud API
This is determined by the backendAuthenticated property returned from /api/unified-auth/status.
Bug Fix Case Study: Navbar Showing Red After Token Refresh¶
Problem Statement¶
Issue: The navbar cloud sync icon incorrectly displayed red (indicating no backend authentication) even though: - The backend had valid, stored cloud credentials - Token auto-refresh was working correctly - The cloud backend showed an active session refreshed recently - Backend console showed no errors
User Impact: Users were concerned their backend had lost cloud connectivity when it was actually working fine.
Root Cause Analysis¶
The issue was in how the backend validated its cloud connection:
Original Flow:
1. Frontend requests /api/unified-auth/status
2. Backend calls GetAuthStatusAsync()
3. GetAuthStatusAsync() calls GetUserInfoAsync() with the backend's token
4. GetUserInfoAsync() proxies the request to cloud API and caches the result for 2 minutes
5. Cache key is generated based on userId (from JWT token)
The Problem:
When the backend auto-refreshed its token:
1. Old access token expires
2. Backend calls cloud API → gets 400 or 401 error
3. Error response is cached with key proxy:auth/user-info:uid:user_xxx
4. Backend successfully refreshes to new access token
5. Frontend checks status → cache returns old 400 error (same userId, same cache key)
6. Navbar shows red icon even though new token is valid
Why the cache key was the issue:
- Cache keys were based on userId extracted from the JWT token
- When a token is refreshed, the new token has the same userId as the old token
- So the cache key remained identical: proxy:auth/user-info:uid:user_xxx
- The cached error response was returned even with the new, valid token
Solution Implemented¶
Created a non-cached validation method specifically for checking backend authentication status:
File: Services/Service_CloudAuth.cs
// New non-cached version for backend token validation
private async Task<IActionResult> ValidateBackendTokenAsync(string backendToken)
=> await ProxyCloudRequest("auth/user-info", HttpMethod.Get, $"Bearer {backendToken}", cacheDuration: null);
public async Task<IActionResult> GetAuthStatusAsync(string? authHeader)
{
var backendToken = await _cloudSessionStore.GetValidAccessTokenAsync();
var userId = _cloudSessionStore.GetUserId();
if (string.IsNullOrEmpty(backendToken) || string.IsNullOrEmpty(userId))
{
return backendAuthenticated: false;
}
// Use non-cached validation to avoid stale cache after token refresh
var userInfoResult = await ValidateBackendTokenAsync(backendToken);
if (userInfoResult is OkObjectResult okResult && okResult.Value is JsonElement userInfo)
{
return backendAuthenticated: true;
}
else
{
return backendAuthenticated: false;
}
}
Key Changes:
- Added ValidateBackendTokenAsync() that passes cacheDuration: null to skip caching
- Modified GetAuthStatusAsync() to use the non-cached validation method
- Frontend-requested GetUserInfoAsync() still uses caching (2 minutes) for performance
- Backend status checks always use fresh validation
Testing Endpoint¶
A testing endpoint was added to force token refresh on demand:
Endpoint: POST /api/unified-auth/tokens/force-refresh
Purpose: - Clears the current access token (preserves refresh token) - Forces immediate token refresh - Returns information about the refresh result
Implementation:
// File: Services/Service_CloudSessionStore.cs
public async Task<(bool success, string? message, string? newToken)> ForceRefreshTokenAsync()
{
// Clear access token to force refresh
ClearAccessTokenOnly();
try
{
var newToken = await GetValidAccessTokenAsync();
if (!string.IsNullOrEmpty(newToken))
{
return (true, "Token refreshed successfully", newToken);
}
else
{
return (false, "Token refresh failed - no token returned", null);
}
}
catch (Exception ex)
{
return (false, $"Token refresh failed: {ex.Message}", null);
}
}
Usage:
POST http://localhost:7180/api/unified-auth/tokens/force-refresh
Files Modified¶
Services/Service_CloudAuth.cs- Lines 265-266: Added
ValidateBackendTokenAsync()method -
Line 308: Changed to use non-cached validation
-
Services/Service_CloudSessionStore.cs -
Lines 162-189: Added
ForceRefreshTokenAsync()for testing -
Controllers/Controller_UnifiedAuth.cs - Lines 309-342: Added force refresh endpoint
Impact and Benefits¶
Before Fix: - Navbar showed red icon incorrectly after token auto-refresh - Users confused about backend cloud connectivity status - Had to manually log out and back in to clear cached error
After Fix: - Navbar immediately reflects correct backend authentication status - Auto-refresh works seamlessly with accurate status indication - No user intervention required when tokens refresh
Performance Consideration: - Non-cached validation means every status check makes a cloud API call - This is acceptable because: - Status endpoint is only called when navbar loads/refreshes (not frequently) - Accurate real-time status is more important than caching - Frontend user-info requests still use caching for performance
Key Takeaways¶
-
Backend Stores User Credentials: The backend doesn't have its own cloud account; it stores and operates using the logged-in user's credentials
-
Auto-Refresh is Automatic: When access tokens expire, the backend automatically refreshes them using the stored refresh token without user intervention
-
Cache Keys Matter: When caching API responses, ensure cache keys properly account for scenarios where the underlying data changes but the key remains the same
-
Separate Validation Paths: Different use cases (backend status vs frontend user info) may require different caching strategies
-
Testing Endpoints: Adding developer testing endpoints (like force-refresh) helps validate fixes and debug issues
Related Documentation¶
- Security Architecture - Overall security model
- Cloud Backups - How cloud sync works
- Server Authentication Guide - User guide for setting up authentication
Implementation Details¶
For developers working on authentication features:
Adding New Cloud-Authenticated Endpoints¶
When creating endpoints that require backend cloud authentication:
public async Task<IActionResult> MyCloudEndpoint()
{
var backendToken = await _cloudSessionStore.GetValidAccessTokenAsync();
if (string.IsNullOrEmpty(backendToken))
{
return new UnauthorizedObjectResult(new { message = "Backend not authenticated with cloud" });
}
// Use backendToken to make cloud API requests
var result = await _cloudAuthService.GetUserInfoAsync($"Bearer {backendToken}");
return result;
}
Cache Invalidation¶
When tokens are refreshed or updated, invalidate related caches:
// In Service_CloudAuth.cs
private void InvalidateUserCaches(string token)
{
var suffix = GenerateUserKeySuffix(token);
var keysToRemove = new[]
{
$"proxy:auth/user-info:{suffix}",
$"proxy:auth/validate-token:{suffix}",
$"proxy:sessions/sessions:{suffix}"
};
foreach (var key in keysToRemove)
{
_cache.Remove(key);
_singleflightGates.TryRemove(key, out _);
}
}
This is automatically called when storing a new session via StoreSession().