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:

What's Missing:

Pain Points

  1. Manual Token Management: Users must manually login via REST API or CLI to get tokens
  2. Token Expiration: Access tokens expire after 1 hour, requiring manual refresh
  3. Schema Cache: Cannot load on startup without pre-existing valid token
  4. MCP Server: Long-running process needs automatic token refresh
  5. 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:

  1. 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)
  2. 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
  3. Existing Infrastructure: Already have OpenAPI-generated client

    • AuthenticationApi with authTokensPost() and authRefreshPost()
    • 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

  1. Automatic Token Refresh: No manual intervention for long-running processes
  2. Thread-Safe: Concurrent API calls from Chat API/MCP safely share token
  3. Startup Authentication: Schema cache can auto-login with env vars
  4. Graceful Degradation: 5-minute buffer prevents mid-request expiry
  5. Reusable: All API clients benefit from token manager
  6. No New Dependencies: Uses existing OpenAPI client
  7. JWT Parsing: Accurate expiry detection, not just assumptions

Negative

  1. Custom Implementation: Must maintain refresh logic ourselves
  2. Token Storage: Refresh tokens stored in plaintext config file (security consideration)
  3. Complexity: Additional service layer compared to manual token management
  4. Error Handling: Must handle refresh failures gracefully

Risks

  1. Circular Dependency: IdempiereTokenManager uses GeneratedOpenApiFactory which uses IdempiereTokenManager

    • Mitigation: Use direct AuthenticationApi instance without token injection for login/refresh calls
  2. Token Rotation: Refresh token changes on each refresh

    • Mitigation: Always save new refresh token after refresh
  3. 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:

Files to Modify:

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:

Phase 2: Schema Cache Integration (v1.75.0)

Files to Modify:

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:

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:

Cons:

Decision: ❌ Rejected - Not suitable for iDempiere's OAuth2 implementation

Alternative 2: Manual Token Management (Current)

Pros:

Cons:

Decision: ❌ Rejected - Does not meet requirements for long-running processes

Alternative 3: Token Refresh on 401 Response

Pros:

Cons:

Decision: ❌ Rejected - Proactive refresh (5-min buffer) is better UX


Security Considerations

Token Storage

Current:

Risks:

Mitigations:

  1. File Permissions: Ensure config file is 600 (read/write owner only)
  2. Short-Lived Tokens: 1-hour access token limits exposure window
  3. Token Rotation: Refresh token changes on each refresh
  4. Environment Variables: Support IDEMPIERE_TOKEN for container deployments (no file storage)

Future Enhancement (v1.76.0+):

Token Leakage Prevention

  1. Logging: Never log tokens
  2. Error Messages: Redact tokens in exception messages
  3. 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


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

  1. ✅ Schema cache loads on startup with username/password env vars
  2. ✅ MCP server runs for 24+ hours without manual token management
  3. ✅ Chat API handles concurrent requests with automatic token refresh
  4. ✅ Token auto-refreshes 5 minutes before expiry
  5. ✅ Unit tests achieve 80%+ coverage
  6. ✅ Integration tests pass against live iDempiere server
  7. ✅ Config file permissions automatically set to 600
  8. ✅ Documentation complete for users and developers

References


Timeline


Approval

Author: Claude Sonnet 4.5 Reviewers: [To be assigned] Status: Proposed (awaiting review)

Path: /docs/developers/architecture/idempiere-hub/061-oauth2-token-manager