ADR-061: OAuth2 Token Manager with Automatic Refresh
Status: Proposed Date: 2025-12-17 Context: Schema Cache v1.74.0, OAuth2 JWT Authentication Related: ADR-054 (AI Tool Architecture), v1.74.0 (Startup Schema Cache)
Context
The iDempiere Hub requires OAuth2 JWT authentication for REST API access across all three interfaces (CLI, MCP Server, Chat API). Currently, authentication is manual - users must obtain and manage JWT tokens themselves.
Current State (v1.74.0)
What Works:
- ✅ OpenAPI contract with
bearerAuthsecurity scheme - ✅ Generated
AuthenticationApiclient for login/refresh - ✅
IdempiereConfigstores token in~/.idempiere-cli/config.properties - ✅
GeneratedOpenApiFactoryinjects Bearer token in requests - ✅ Token can be provided via
IDEMPIERE_TOKENenvironment variable
What's Missing:
- ❌ Automatic login from username/password
- ❌ Automatic token refresh before expiry
- ❌ Graceful 401 error handling with retry
- ❌ Thread-safe token access for concurrent operations
- ❌ JWT expiry parsing and validation
Pain Points
- Manual Token Management: Users must manually login via REST API or CLI to get tokens
- Token Expiration: Access tokens expire after 1 hour, requiring manual refresh
- Schema Cache: Cannot load on startup without pre-existing valid token
- MCP Server: Long-running process needs automatic token refresh
- Chat API: Concurrent requests need thread-safe token access
iDempiere OAuth2 Flow
POST /api/v1/auth/tokens
{
"userName": "user",
"password": "pass",
"parameters": {
"clientId": 0,
"roleId": 0,
"organizationId": 0
}
}
Response:
{
"token": "eyJ...", // Access token (1 hour expiry)
"refreshToken": "eyJ...", // Refresh token (24 hours expiry)
"clients": [...],
"userId": 100
}
POST /api/v1/auth/refresh
{
"refreshToken": "eyJ...",
"clientId": 0,
"userId": 100
}
Response:
{
"token": "eyJ...", // New access token
"refreshToken": "eyJ..." // New refresh token
}
Decision
Implement a custom OAuth2 Token Manager service that handles automatic token refresh, rather than using Quarkus OIDC extensions.
Why Custom Implementation?
Based on consultation with Quarkus experts and analysis of available extensions:
-
Non-OIDC OAuth2 Server: iDempiere uses pure OAuth2, not OpenID Connect
- No OIDC discovery endpoint (
/.well-known/openid-configuration) - Custom token endpoint structure
- Non-standard refresh flow (requires
clientId+userId)
- No OIDC discovery endpoint (
-
Quarkus OIDC Extensions Not Suitable:
quarkus-oidc-client: Designed for OIDC-compliant servers (Keycloak, Auth0)quarkus-rest-client-oidc-token-propagation: For microservices, requires web context- Both add complexity without benefit for CLI/batch use case
-
Existing Infrastructure: Already have OpenAPI-generated client
AuthenticationApiwithauthTokensPost()andauthRefreshPost()- No additional dependencies needed
- Full control over token lifecycle
Architecture: IdempiereTokenManager
@ApplicationScoped
public class IdempiereTokenManager {
@Inject IdempiereConfig config;
@Inject GeneratedOpenApiFactory apiFactory;
// Token state (thread-safe)
private volatile String accessToken;
private volatile String refreshToken;
private volatile Instant tokenExpiry;
private volatile Integer clientId;
private volatile Integer userId;
// Constants
private static final Duration TOKEN_LIFETIME = Duration.ofHours(1);
private static final Duration REFRESH_BUFFER = Duration.ofMinutes(5);
/**
* Get valid access token, auto-refreshing if needed.
* Thread-safe for concurrent API calls.
*/
public String getAccessToken() throws AuthenticationException {
synchronized (this) {
if (needsRefresh()) {
refresh();
}
return accessToken;
}
}
/**
* Login with username/password.
* Stores tokens for automatic refresh.
*/
public AuthenticationResponse login(
String username,
String password,
AuthenticationParameters params
) throws ApiException, IOException {
// Call POST /auth/tokens
// Store access token, refresh token, expiry, clientId, userId
// Persist to IdempiereConfig
}
/**
* Refresh access token using refresh token.
* Called automatically by getAccessToken().
*/
private void refresh() throws AuthenticationException {
// Call POST /auth/refresh
// Update access token, refresh token, expiry
// Persist to IdempiereConfig
}
/**
* Check if token needs refresh (5 min buffer before expiry).
*/
private boolean needsRefresh() {
if (accessToken == null) return true;
if (tokenExpiry == null) return true;
Instant refreshTime = Instant.now().plus(REFRESH_BUFFER);
return refreshTime.isAfter(tokenExpiry);
}
/**
* Initialize from stored token on startup.
*/
@PostConstruct
void init() {
String storedToken = config.getToken();
if (storedToken != null) {
this.accessToken = storedToken;
this.tokenExpiry = parseJwtExpiry(storedToken);
}
String storedRefreshToken = config.getRefreshToken();
if (storedRefreshToken != null) {
this.refreshToken = storedRefreshToken;
this.clientId = config.getTokenClientId();
this.userId = config.getTokenUserId();
}
}
/**
* Parse JWT "exp" claim to get expiry time.
*/
private Instant parseJwtExpiry(String jwt) {
try {
String[] parts = jwt.split("\\.");
if (parts.length < 2) return null;
String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
JsonObject json = parseJson(payload);
long exp = json.getLong("exp");
return Instant.ofEpochSecond(exp);
} catch (Exception e) {
log.warn("Could not parse JWT expiry, will refresh on first use", e);
return null;
}
}
}
Integration Points
1. GeneratedOpenApiFactory - Dynamic Token Injection
@ApplicationScoped
public class GeneratedOpenApiFactory {
@Inject IdempiereTokenManager tokenManager;
private ApiClient createApiClient() {
ApiClient client = new ApiClient();
client.updateBaseUri(config.getApiUrl());
// Dynamic token with automatic refresh
client.setRequestInterceptor(builder -> {
try {
String token = tokenManager.getAccessToken(); // Auto-refreshes
builder.header("Authorization", "Bearer " + token);
} catch (AuthenticationException e) {
throw new RuntimeException("Authentication failed", e);
}
});
return client;
}
}
2. IdempiereConfig - Persist Refresh Token
@ApplicationScoped
public class IdempiereConfig {
// Add fields
private String refreshToken;
private Integer tokenClientId;
private Integer tokenUserId;
// Add to load()
config.refreshToken = props.getProperty("refreshToken");
config.tokenClientId = Integer.valueOf(props.getProperty("token.clientId", "0"));
config.tokenUserId = Integer.valueOf(props.getProperty("token.userId", "0"));
// Add to save()
if (refreshToken != null) props.setProperty("refreshToken", refreshToken);
if (tokenClientId != null) props.setProperty("token.clientId", tokenClientId.toString());
if (tokenUserId != null) props.setProperty("token.userId", tokenUserId.toString());
// Getters/setters
}
3. SchemaCache - Automatic Authentication
@ApplicationScoped
public class SchemaCache {
@Inject IdempiereTokenManager tokenManager;
void onStart(@Observes StartupEvent event) {
if (!enabled) return;
try {
// Attempt authentication if username/password available
authenticateIfNeeded();
// Load schema (uses tokenManager for auth)
loadSchema();
} catch (AuthenticationException e) {
log.warn("Schema cache authentication failed: " + e.getMessage());
if (failOnMissing) {
throw new RuntimeException("Schema cache authentication required", e);
}
}
}
private void authenticateIfNeeded() {
String username = System.getenv("IDEMPIERE_USERNAME");
String password = System.getenv("IDEMPIERE_PASSWORD");
if (username != null && password != null) {
// Auto-login at startup
tokenManager.login(username, password, getDefaultParameters());
}
}
}
Consequences
Positive
- Automatic Token Refresh: No manual intervention for long-running processes
- Thread-Safe: Concurrent API calls from Chat API/MCP safely share token
- Startup Authentication: Schema cache can auto-login with env vars
- Graceful Degradation: 5-minute buffer prevents mid-request expiry
- Reusable: All API clients benefit from token manager
- No New Dependencies: Uses existing OpenAPI client
- JWT Parsing: Accurate expiry detection, not just assumptions
Negative
- Custom Implementation: Must maintain refresh logic ourselves
- Token Storage: Refresh tokens stored in plaintext config file (security consideration)
- Complexity: Additional service layer compared to manual token management
- Error Handling: Must handle refresh failures gracefully
Risks
-
Circular Dependency:
IdempiereTokenManagerusesGeneratedOpenApiFactorywhich usesIdempiereTokenManager- Mitigation: Use direct
AuthenticationApiinstance without token injection for login/refresh calls
- Mitigation: Use direct
-
Token Rotation: Refresh token changes on each refresh
- Mitigation: Always save new refresh token after refresh
-
Concurrent Refresh: Multiple threads might trigger refresh simultaneously
- Mitigation: Synchronize refresh logic, check again after acquiring lock
Implementation Plan
Phase 1: Core Token Manager (v1.75.0)
Files to Create:
src/main/java/org/idempiere/cli/api/auth/IdempiereTokenManager.java- Token lifecycle servicesrc/main/java/org/idempiere/cli/api/auth/AuthenticationException.java- Custom exception
Files to Modify:
src/main/java/org/idempiere/cli/api/IdempiereConfig.java- Add refresh token fieldssrc/main/java/org/idempiere/cli/api/rest/GeneratedOpenApiFactory.java- Use token managersrc/main/resources/application.properties- Add auth config
Configuration Properties:
# OAuth2 Authentication
idempiere.auth.auto-login=true
idempiere.auth.token-lifetime=1h
idempiere.auth.refresh-buffer=5m
idempiere.auth.username=${IDEMPIERE_USERNAME:}
idempiere.auth.password=${IDEMPIERE_PASSWORD:}
idempiere.auth.default-client-id=${IDEMPIERE_CLIENT_ID:0}
idempiere.auth.default-role-id=${IDEMPIERE_ROLE_ID:0}
idempiere.auth.default-org-id=${IDEMPIERE_ORG_ID:0}
Tests:
IdempiereTokenManagerTest- Unit tests with mock APITokenRefreshIntegrationTest- Integration test with live server
Phase 2: Schema Cache Integration (v1.75.0)
Files to Modify:
src/main/java/org/idempiere/cli/ai/shared/SchemaCache.java- Auto-authenticate on startup
Environment Variables:
# For automatic authentication at startup
export IDEMPIERE_URL=https://staging.cloudempiere.com/
export IDEMPIERE_USERNAME=your-user
export IDEMPIERE_PASSWORD=your-password
export IDEMPIERE_CLIENT_ID=0
export IDEMPIERE_ROLE_ID=0
export IDEMPIERE_ORG_ID=0
Behavior:
- If
IDEMPIERE_USERNAME+IDEMPIERE_PASSWORDset → Auto-login at startup - If only
IDEMPIERE_TOKENset → Use token, no refresh (backward compatible) - If neither set → Skip authentication, cache fails gracefully (if
fail-on-missing=false)
Phase 3: CLI Commands (v1.76.0)
New Commands:
# Login and save credentials
idempiere-cli auth login --username user --password pass
# Manually refresh token
idempiere-cli auth refresh
# Show token status
idempiere-cli auth status
# Logout (clear stored tokens)
idempiere-cli auth logout
Alternatives Considered
Alternative 1: Quarkus OIDC Client Extension
Pros:
- Standard Quarkus extension
- Battle-tested token refresh logic
- Integration with Quarkus security
Cons:
- Requires OIDC-compliant server (iDempiere is pure OAuth2)
- Overhead for CLI/batch use case
- Complex configuration for non-OIDC servers
- HTTP server context required
Decision: ❌ Rejected - Not suitable for iDempiere's OAuth2 implementation
Alternative 2: Manual Token Management (Current)
Pros:
- Simple, no additional code
- User controls authentication
- No token storage security concerns
Cons:
- Poor user experience
- Cannot use schema cache on startup
- MCP server fails after 1 hour
- Chat API requires manual token refresh
Decision: ❌ Rejected - Does not meet requirements for long-running processes
Alternative 3: Token Refresh on 401 Response
Pros:
- Reactive, only refreshes when needed
- No proactive token management
Cons:
- API call fails first, then retries (poor UX)
- Difficult to distinguish between expired token and invalid credentials
- Race conditions if multiple threads get 401 simultaneously
Decision: ❌ Rejected - Proactive refresh (5-min buffer) is better UX
Security Considerations
Token Storage
Current:
- Access token: Stored in
~/.idempiere-cli/config.properties(plaintext) - Refresh token: Will also be stored in config file (plaintext)
Risks:
- Config file readable by user's processes
- Tokens not encrypted at rest
- Refresh token has 24-hour lifetime
Mitigations:
- File Permissions: Ensure config file is
600(read/write owner only) - Short-Lived Tokens: 1-hour access token limits exposure window
- Token Rotation: Refresh token changes on each refresh
- Environment Variables: Support
IDEMPIERE_TOKENfor container deployments (no file storage)
Future Enhancement (v1.76.0+):
- Implement encrypted token storage using OS keychain
- macOS: Keychain Access
- Linux: Secret Service API (libsecret)
- Windows: Credential Manager
Token Leakage Prevention
- Logging: Never log tokens
- Error Messages: Redact tokens in exception messages
- Configuration Validation: Warn if config file has overly permissive permissions
Testing Strategy
Unit Tests
@QuarkusTest
class IdempiereTokenManagerTest {
@InjectMock
AuthenticationApi authApi;
@Inject
IdempiereTokenManager tokenManager;
@Test
void testLogin() {
// Mock authTokensPost()
// Verify token stored
// Verify config saved
}
@Test
void testAutomaticRefresh() {
// Set token expiry to 2 minutes from now
// Call getAccessToken()
// Verify refresh was triggered
}
@Test
void testConcurrentAccess() throws Exception {
// Spawn 10 threads
// All call getAccessToken() simultaneously
// Verify only one refresh call made
}
}
Integration Tests
@QuarkusTest
@TestProfile(IntegrationTestProfile.class)
class TokenRefreshIntegrationTest {
@Inject
IdempiereTokenManager tokenManager;
@Test
void testFullAuthenticationFlow() {
// Login with real credentials
// Wait 1 minute
// Trigger refresh
// Verify new token works
}
}
Manual Testing Checklist
- [ ] Login with valid credentials saves tokens
- [ ] Token auto-refreshes 5 minutes before expiry
- [ ] Schema cache auto-authenticates on startup
- [ ] MCP server runs for >1 hour without manual refresh
- [ ] 401 errors trigger refresh and retry
- [ ] Concurrent API calls share token safely
- [ ] Config file has correct permissions (600)
- [ ] Logout clears stored tokens
Documentation
User Documentation
USER_GUIDE.md - Add authentication section:
## Authentication
### Option 1: Environment Variables (Recommended)
```bash
export IDEMPIERE_URL=https://your-server.com/
export IDEMPIERE_USERNAME=your-user
export IDEMPIERE_PASSWORD=your-password
# Start MCP server (auto-authenticates)
idempiere-hub server mcp
Option 2: Manual Token
# Get token manually
curl -X POST https://your-server.com/api/v1/auth/tokens \
-H "Content-Type: application/json" \
-d '{"userName":"user","password":"pass"}'
# Set token
export IDEMPIERE_TOKEN=eyJ...
# Or add to config
echo "token=eyJ..." >> ~/.idempiere-cli/config.properties
Token Refresh
Tokens are automatically refreshed 5 minutes before expiry. No manual intervention required for long-running processes.
### Developer Documentation
**docs/auth/TOKEN_MANAGER.md** - Implementation guide for developers
---
## Monitoring & Observability
### Metrics (ADR-058 Integration)
```java
@Counted(name = "auth.login.count", description = "Number of login attempts")
@Counted(name = "auth.refresh.count", description = "Number of token refreshes")
@Counted(name = "auth.failure.count", description = "Number of authentication failures")
@Timed(name = "auth.login.duration", description = "Login duration")
@Timed(name = "auth.refresh.duration", description = "Token refresh duration")
Logging
log.info("Authenticated as user {} (client={}, role={})", userId, clientId, roleId);
log.info("Token refreshed successfully (expires in {} minutes)", minutesUntilExpiry);
log.warn("Token refresh failed, will retry on next request: {}", e.getMessage());
log.error("Authentication failed: {}", e.getMessage());
Success Criteria
- ✅ Schema cache loads on startup with username/password env vars
- ✅ MCP server runs for 24+ hours without manual token management
- ✅ Chat API handles concurrent requests with automatic token refresh
- ✅ Token auto-refreshes 5 minutes before expiry
- ✅ Unit tests achieve 80%+ coverage
- ✅ Integration tests pass against live iDempiere server
- ✅ Config file permissions automatically set to 600
- ✅ Documentation complete for users and developers
References
- iDempiere REST API Wiki
- JWT RFC 7519
- OAuth 2.0 RFC 6749
- Quarkus Security Guide
- ADR-054: AI Tool Architecture Clarity
- ADR-058: Logging and Configuration Architecture
- v1.74.0: Startup Schema Cache
Timeline
- v1.75.0 (Target: 2025-12-20): Core Token Manager implementation
- v1.76.0 (Target: 2025-12-23): CLI authentication commands
- v1.77.0 (Target: 2025-12-27): Encrypted token storage (optional enhancement)
Approval
Author: Claude Sonnet 4.5 Reviewers: [To be assigned] Status: Proposed (awaiting review)