From 0bd03ef1799bd1a06f571d8cf469c6445ca3978e Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Mon, 18 May 2026 15:41:10 +0000 Subject: [PATCH] fix(claude): use far-future expiry for setup-token credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup-token grants don't set ExpiresAt on the credential, leaving it as the zero time.Time. WriteCredentialsFile called UnixMilli() on this, producing -62135596800000 (year 0001) in .credentials.json. Claude Code read that as an expired credential, showing "not logged in" and "API Usage Billing" in the status line instead of recognizing the session. Use a 1-year-ahead timestamp when ExpiresAt is zero. Setup-tokens are long-lived and the placeholder credential just needs to look valid locally — the real token is injected by the proxy at the network layer. --- internal/providers/claude/config.go | 13 ++++++++- internal/providers/claude/provider_test.go | 31 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/internal/providers/claude/config.go b/internal/providers/claude/config.go index c9474fb8..223929e3 100644 --- a/internal/providers/claude/config.go +++ b/internal/providers/claude/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/majorcontext/moat/internal/provider" ) @@ -157,10 +158,20 @@ func WriteCredentialsFile(cred *provider.Credential, stagingDir string) error { // the proxy at the network layer. Claude Code needs this file to exist // with valid structure to function, but the actual authentication is // handled transparently by the TLS-intercepting proxy. + // + // ExpiresAt handling: Setup-token grants are long-lived and don't carry + // an expiry, so cred.ExpiresAt is the zero time.Time. UnixMilli() on the + // zero value returns -62135596800000 (year 0001), which Claude Code reads + // as an expired credential — the status line shows "not logged in" and + // "API Usage Billing". Substitute a far-future expiry in that case. + expiresAtMs := cred.ExpiresAt.UnixMilli() + if cred.ExpiresAt.IsZero() { + expiresAtMs = time.Now().Add(365 * 24 * time.Hour).UnixMilli() + } creds := oauthCredentials{ ClaudeAiOauth: &oauthToken{ AccessToken: ProxyInjectedPlaceholder, - ExpiresAt: cred.ExpiresAt.UnixMilli(), + ExpiresAt: expiresAtMs, Scopes: cred.Scopes, }, } diff --git a/internal/providers/claude/provider_test.go b/internal/providers/claude/provider_test.go index 0bf855fc..b0ceb023 100644 --- a/internal/providers/claude/provider_test.go +++ b/internal/providers/claude/provider_test.go @@ -656,6 +656,37 @@ func TestWriteCredentialsFile(t *testing.T) { } }) + t.Run("zero ExpiresAt uses far-future expiry", func(t *testing.T) { + stagingDir := t.TempDir() + cred := &provider.Credential{ + Provider: "claude", + Token: "sk-ant-oat01-abc123", + // ExpiresAt intentionally zero — simulates setup-token grants + } + + err := WriteCredentialsFile(cred, stagingDir) + if err != nil { + t.Fatalf("WriteCredentialsFile() error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(stagingDir, ".credentials.json")) + if err != nil { + t.Fatalf("failed to read credentials file: %v", err) + } + + var creds oauthCredentials + if err := json.Unmarshal(data, &creds); err != nil { + t.Fatalf("failed to parse credentials file: %v", err) + } + + if creds.ClaudeAiOauth == nil { + t.Fatal("ClaudeAiOauth should be present") + } + if creds.ClaudeAiOauth.ExpiresAt <= time.Now().UnixMilli() { + t.Errorf("ExpiresAt = %d, want future timestamp (got past/zero)", creds.ClaudeAiOauth.ExpiresAt) + } + }) + t.Run("anthropic provider does not create file", func(t *testing.T) { stagingDir := t.TempDir() cred := &provider.Credential{