Skip to content

Commit 3736761

Browse files
committed
Auto-commit
1 parent 3742224 commit 3736761

File tree

4 files changed

+1046
-12
lines changed

4 files changed

+1046
-12
lines changed

docs/integrations/OAUTH_CREDENTIALS_INTEGRATION.md

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,84 @@ const (
130130
- OAuth: `Authorization: Bearer <token>`
131131
- API Key: `x-api-key: <key>` (Claude) or `Authorization: Bearer <key>` (Qwen)
132132

133+
## Auto-Refresh Mechanism
134+
135+
The OAuth implementation includes automatic token refresh to ensure credentials remain valid during long-running operations.
136+
137+
### How It Works
138+
139+
Located at: `internal/auth/oauth_credentials/token_refresh.go`
140+
141+
**Key Constants**:
142+
143+
| Constant | Value | Description |
144+
|----------|-------|-------------|
145+
| `RefreshThreshold` | 10 minutes | Time before expiration when proactive refresh occurs |
146+
| `RefreshTimeout` | 30 seconds | HTTP timeout for refresh requests |
147+
| `ClaudeTokenEndpoint` | `https://claude.ai/api/auth/oauth/token` | Claude OAuth token endpoint |
148+
| `QwenTokenEndpoint` | `https://oauth.aliyun.com/v1/token` | Qwen OAuth token endpoint |
149+
150+
**Refresh Flow**:
151+
152+
1. When credentials are read, the system checks if the token expires within the `RefreshThreshold` (10 minutes)
153+
2. If expiring soon and a refresh token is available, automatic refresh is attempted
154+
3. On successful refresh:
155+
- New access token is obtained
156+
- Credential file is updated on disk
157+
- In-memory cache is refreshed
158+
4. If refresh fails but token is still valid, the existing token continues to be used
159+
5. Rate limiting prevents excessive refresh attempts (minimum 30 seconds between attempts)
160+
161+
### Token Refresh Functions
162+
163+
| Function | Description |
164+
|----------|-------------|
165+
| `NeedsRefresh(expiresAt)` | Check if token needs refresh (expires within 10 min) |
166+
| `IsExpired(expiresAt)` | Check if token is already expired |
167+
| `AutoRefreshClaudeToken(creds)` | Automatically refresh Claude token if needed |
168+
| `AutoRefreshQwenToken(creds)` | Automatically refresh Qwen token if needed |
169+
| `GetRefreshStatus()` | Get refresh status for both providers |
170+
| `StartBackgroundRefresh(stopChan)` | Start background refresh goroutine |
171+
172+
### Background Refresh
173+
174+
For long-running applications, you can enable background token refresh:
175+
176+
```go
177+
import "dev.helix.agent/internal/auth/oauth_credentials"
178+
179+
// Create stop channel
180+
stopChan := make(chan struct{})
181+
182+
// Start background refresh (checks every 5 minutes)
183+
oauth_credentials.StartBackgroundRefresh(stopChan)
184+
185+
// When shutting down
186+
close(stopChan)
187+
```
188+
189+
### Getting Refresh Status
190+
191+
```go
192+
status := oauth_credentials.GetRefreshStatus()
193+
fmt.Printf("Refresh threshold: %s\n", status["refresh_threshold"])
194+
195+
if claude, ok := status["claude"].(map[string]interface{}); ok {
196+
fmt.Printf("Claude needs refresh: %v\n", claude["needs_refresh"])
197+
fmt.Printf("Claude has refresh token: %v\n", claude["has_refresh_token"])
198+
fmt.Printf("Claude expires at: %s\n", claude["expires_at"])
199+
}
200+
```
201+
202+
### Credential File Updates
203+
204+
When tokens are refreshed, the credential files are automatically updated:
205+
206+
- `~/.claude/.credentials.json` - Updated with new Claude tokens
207+
- `~/.qwen/oauth_creds.json` - Updated with new Qwen tokens
208+
209+
File permissions are preserved (0600 - user read/write only).
210+
133211
## Provider Registry Integration
134212

135213
The provider registry in `internal/services/provider_registry.go` automatically:
@@ -320,8 +398,10 @@ if available, ok := info["available"].(bool); ok && available {
320398

321399
| File | Action | Description |
322400
|------|--------|-------------|
323-
| `internal/auth/oauth_credentials/oauth_credentials.go` | Created | OAuth credential reader |
401+
| `internal/auth/oauth_credentials/oauth_credentials.go` | Created | OAuth credential reader with auto-refresh |
402+
| `internal/auth/oauth_credentials/token_refresh.go` | Created | Token auto-refresh mechanism |
324403
| `internal/auth/oauth_credentials/oauth_credentials_test.go` | Created | Unit tests |
404+
| `internal/auth/oauth_credentials/token_refresh_test.go` | Created | Auto-refresh tests |
325405
| `internal/llm/providers/claude/claude.go` | Modified | OAuth support |
326406
| `internal/llm/providers/qwen/qwen.go` | Modified | OAuth support |
327407
| `internal/services/provider_registry.go` | Modified | Auto OAuth selection |
@@ -347,7 +427,9 @@ if available, ok := info["available"].(bool); ok && available {
347427

348428
### Token Expired
349429

350-
OAuth tokens typically expire after several hours. Re-authenticate via:
430+
OAuth tokens typically expire after several hours. The auto-refresh mechanism will automatically refresh tokens before they expire if a refresh token is available.
431+
432+
If auto-refresh fails or no refresh token exists, re-authenticate via:
351433
```bash
352434
# Claude Code
353435
claude --login
@@ -356,6 +438,13 @@ claude --login
356438
qwen --login
357439
```
358440

441+
### Checking Refresh Status
442+
443+
```go
444+
status := oauth_credentials.GetRefreshStatus()
445+
// Check if tokens need refresh or have refresh tokens available
446+
```
447+
359448
### Environment Variable Not Working
360449

361450
Both spellings are supported:
@@ -381,6 +470,17 @@ Some providers may return 401 on health check endpoints even with valid OAuth cr
381470
OAuth2 credential integration provides seamless authentication for users already logged into Claude Code or Qwen Code CLI agents. The implementation is:
382471

383472
- **Backward Compatible**: Falls back to API keys when OAuth unavailable
384-
- **Secure**: Respects token expiration and doesn't store credentials
473+
- **Auto-Refreshing**: Automatically refreshes tokens before expiration
474+
- **Secure**: Respects token expiration and safely updates credential files
385475
- **Transparent**: Logs which authentication method is active
386-
- **Tested**: Comprehensive unit and integration test coverage
476+
- **Tested**: Comprehensive unit and integration test coverage (35+ tests)
477+
478+
### Auto-Refresh Summary
479+
480+
| Feature | Claude | Qwen |
481+
|---------|--------|------|
482+
| Proactive refresh | 10 min before expiry | 10 min before expiry |
483+
| Refresh rate limit | 30 sec minimum | 30 sec minimum |
484+
| File update on refresh | Yes | Yes |
485+
| Background refresh | Supported | Supported |
486+
| Graceful fallback | Yes | Yes |

internal/auth/oauth_credentials/oauth_credentials.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,16 @@ func GetQwenCredentialsPath() string {
7474
}
7575

7676
// ReadClaudeCredentials reads and returns Claude OAuth credentials from the CLI config
77+
// It automatically refreshes the token if it's about to expire
7778
func (r *OAuthCredentialReader) ReadClaudeCredentials() (*ClaudeOAuthCredentials, error) {
7879
r.mu.Lock()
7980
defer r.mu.Unlock()
8081

8182
// Check cache validity
8283
if r.claudeCredentials != nil && time.Since(r.claudeLastRead) < r.cacheDuration {
83-
// Verify token is not expired
84+
// Verify token is not expired and doesn't need refresh
8485
if r.claudeCredentials.ClaudeAiOauth != nil &&
85-
r.claudeCredentials.ClaudeAiOauth.ExpiresAt > time.Now().UnixMilli() {
86+
!NeedsRefresh(r.claudeCredentials.ClaudeAiOauth.ExpiresAt) {
8687
return r.claudeCredentials, nil
8788
}
8889
}
@@ -114,8 +115,23 @@ func (r *OAuthCredentialReader) ReadClaudeCredentials() (*ClaudeOAuthCredentials
114115
return nil, fmt.Errorf("empty access token in Claude Code credentials")
115116
}
116117

117-
// Check expiration
118-
if creds.ClaudeAiOauth.ExpiresAt > 0 && creds.ClaudeAiOauth.ExpiresAt <= time.Now().UnixMilli() {
118+
// Auto-refresh if token is expiring soon or expired
119+
if NeedsRefresh(creds.ClaudeAiOauth.ExpiresAt) {
120+
refreshedCreds, err := AutoRefreshClaudeToken(&creds)
121+
if err != nil {
122+
// If refresh failed and token is already expired, return error
123+
if IsExpired(creds.ClaudeAiOauth.ExpiresAt) {
124+
return nil, fmt.Errorf("Claude OAuth token has expired and refresh failed: %w", err)
125+
}
126+
// Token not expired yet, log warning and continue with existing token
127+
fmt.Fprintf(os.Stderr, "Warning: Claude token refresh failed (token still valid): %v\n", err)
128+
} else {
129+
creds = *refreshedCreds
130+
}
131+
}
132+
133+
// Final expiration check
134+
if IsExpired(creds.ClaudeAiOauth.ExpiresAt) {
119135
return nil, fmt.Errorf("Claude OAuth token has expired (expired at %s)", time.UnixMilli(creds.ClaudeAiOauth.ExpiresAt).Format(time.RFC3339))
120136
}
121137

@@ -127,14 +143,15 @@ func (r *OAuthCredentialReader) ReadClaudeCredentials() (*ClaudeOAuthCredentials
127143
}
128144

129145
// ReadQwenCredentials reads and returns Qwen OAuth credentials from the CLI config
146+
// It automatically refreshes the token if it's about to expire
130147
func (r *OAuthCredentialReader) ReadQwenCredentials() (*QwenOAuthCredentials, error) {
131148
r.mu.Lock()
132149
defer r.mu.Unlock()
133150

134151
// Check cache validity
135152
if r.qwenCredentials != nil && time.Since(r.qwenLastRead) < r.cacheDuration {
136-
// Verify token is not expired
137-
if r.qwenCredentials.ExpiryDate > time.Now().UnixMilli() {
153+
// Verify token is not expired and doesn't need refresh
154+
if !NeedsRefresh(r.qwenCredentials.ExpiryDate) {
138155
return r.qwenCredentials, nil
139156
}
140157
}
@@ -162,8 +179,23 @@ func (r *OAuthCredentialReader) ReadQwenCredentials() (*QwenOAuthCredentials, er
162179
return nil, fmt.Errorf("empty access token in Qwen Code credentials")
163180
}
164181

165-
// Check expiration
166-
if creds.ExpiryDate > 0 && creds.ExpiryDate <= time.Now().UnixMilli() {
182+
// Auto-refresh if token is expiring soon or expired
183+
if NeedsRefresh(creds.ExpiryDate) {
184+
refreshedCreds, err := AutoRefreshQwenToken(&creds)
185+
if err != nil {
186+
// If refresh failed and token is already expired, return error
187+
if IsExpired(creds.ExpiryDate) {
188+
return nil, fmt.Errorf("Qwen OAuth token has expired and refresh failed: %w", err)
189+
}
190+
// Token not expired yet, log warning and continue with existing token
191+
fmt.Fprintf(os.Stderr, "Warning: Qwen token refresh failed (token still valid): %v\n", err)
192+
} else {
193+
creds = *refreshedCreds
194+
}
195+
}
196+
197+
// Final expiration check
198+
if IsExpired(creds.ExpiryDate) {
167199
return nil, fmt.Errorf("Qwen OAuth token has expired (expired at %s)", time.UnixMilli(creds.ExpiryDate).Format(time.RFC3339))
168200
}
169201

0 commit comments

Comments
 (0)