From cf2a1b74f2ea6c195aaaf4c866328cee6ac922b4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 20:47:27 +0000 Subject: [PATCH 1/3] test(adapters/linear): token without expires_in must refresh after 30 days Both the single-install and the multi-tenant path keep a client-credentials token forever when the token response has no expires_in. These tests advance the adapter clock past 30 days minus the 1-hour refresh buffer and expect a new token request. They fail on this commit. --- adapters/linear/token_lifetime_test.go | 119 +++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 adapters/linear/token_lifetime_test.go diff --git a/adapters/linear/token_lifetime_test.go b/adapters/linear/token_lifetime_test.go new file mode 100644 index 0000000..733002d --- /dev/null +++ b/adapters/linear/token_lifetime_test.go @@ -0,0 +1,119 @@ +package linear_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/coder/chat" + "github.com/coder/chat/adapters/linear" +) + +// tokenWithoutExpiryServer mints client-credentials tokens whose response has +// no expires_in, and answers any GraphQL query with an empty data object. +type tokenWithoutExpiryServer struct { + *httptest.Server + mu sync.Mutex + tokens int +} + +func newTokenWithoutExpiryServer(t *testing.T) *tokenWithoutExpiryServer { + t.Helper() + api := &tokenWithoutExpiryServer{} + api.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/oauth/token": + api.mu.Lock() + api.tokens++ + api.mu.Unlock() + writeJSON(t, w, map[string]any{"access_token": "token", "scope": "read write app:mentionable app:assignable"}) + case "/graphql": + writeJSON(t, w, map[string]any{"data": map[string]any{}}) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + t.Cleanup(api.Close) + return api +} + +func (api *tokenWithoutExpiryServer) tokenRequests() int { + api.mu.Lock() + defer api.mu.Unlock() + return api.tokens +} + +// assertTokenRefreshedAfterDefaultLifetime drives query across Linear's 30-day +// client-credentials lifetime: the token is reused within it and re-fetched +// once the clock passes 30 days minus the 1-hour refresh buffer. +func assertTokenRefreshedAfterDefaultLifetime(t *testing.T, api *tokenWithoutExpiryServer, clock *mutableClock, query func() error) { + t.Helper() + if err := query(); err != nil { + t.Fatalf("first query: %v", err) + } + if got := api.tokenRequests(); got != 1 { + t.Fatalf("token requests after first query = %d, want 1", got) + } + clock.advance(29 * 24 * time.Hour) + if err := query(); err != nil { + t.Fatalf("query within lifetime: %v", err) + } + if got := api.tokenRequests(); got != 1 { + t.Fatalf("token requests within lifetime = %d, want 1 (cached)", got) + } + clock.advance(24*time.Hour - time.Hour + time.Minute) + if err := query(); err != nil { + t.Fatalf("query past lifetime: %v", err) + } + if got := api.tokenRequests(); got != 2 { + t.Fatalf("token requests past 30 days minus refresh buffer = %d, want 2 (refreshed)", got) + } +} + +func TestSingleInstallRefreshesTokenWithoutExpiresIn(t *testing.T) { + t.Parallel() + api := newTokenWithoutExpiryServer(t) + clock := newMutableClock(time.UnixMilli(1_700_000_000_000)) + adapter, err := linear.New(context.Background(), linear.Options{ + WebhookSecret: "whsec", + ClientCredentials: linear.ClientCredentials{ClientID: "client", ClientSecret: "secret"}, + APIBaseURL: api.URL, + Client: api.Client(), + Now: clock.now, + }) + if err != nil { + t.Fatalf("adapter: %v", err) + } + assertTokenRefreshedAfterDefaultLifetime(t, api, clock, func() error { + var dest map[string]any + return adapter.GraphQL(context.Background(), `query Q { viewer { id } }`, nil, &dest) + }) +} + +func TestMultiTenantRefreshesTokenWithoutExpiresIn(t *testing.T) { + t.Parallel() + api := newTokenWithoutExpiryServer(t) + clock := newMutableClock(time.UnixMilli(1_700_000_000_000)) + store := newFakeInstallStore() + store.set("ORG_A", chat.Install{ + Tenant: "ORG_A", + BotActorID: "APP_A", + Credential: linear.LinearInstall{WebhookSecret: "secretA", ClientCredentials: linear.ClientCredentials{ClientID: "clientA", ClientSecret: "csA"}}, + }) + adapter, err := linear.New(context.Background(), linear.Options{ + InstallStore: store, + APIBaseURL: api.URL, + Client: api.Client(), + Now: clock.now, + }) + if err != nil { + t.Fatalf("adapter: %v", err) + } + assertTokenRefreshedAfterDefaultLifetime(t, api, clock, func() error { + var dest map[string]any + return adapter.GraphQLForTenant(context.Background(), "ORG_A", `query Q { viewer { id } }`, nil, &dest) + }) +} From dea255cb1fa894d81f0b51b82aaf2241a501d2e9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 20:48:14 +0000 Subject: [PATCH 2/3] fix(adapters/linear): default a token without expires_in to 30 days When the client-credentials response has no positive expires_in, set the expiry to Linear's documented 30-day lifetime. The existing 1-hour refresh buffer then refreshes the token before Linear expires it. Before this, the expiry was the zero time and both ensureToken and tenantToken kept the token until the process restarted. The zero-expiry branches in both checks are now unreachable, so they are removed. Update the README token contract to match. --- adapters/linear/README.md | 2 +- adapters/linear/linear.go | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/adapters/linear/README.md b/adapters/linear/README.md index 58d6588..c79beeb 100644 --- a/adapters/linear/README.md +++ b/adapters/linear/README.md @@ -92,7 +92,7 @@ The adapter owns its own rate-limit retry (`RetryPolicy`) and Linear-specific ex ### Tokens and tenants - The adapter refreshes a token when it is empty or within 1 hour of expiry. [linear.go:ensureToken,tokenRefreshBuffer] -- A token response without a positive `expires_in` gets no expiry, so the adapter never refreshes that token. This holds for the single-install token and for cached per-tenant tokens. [linear.go:exchangeClientCredentials,ensureToken,tenantToken] +- A token response without a positive `expires_in` gets Linear's documented 30-day lifetime, so the adapter refreshes it 1 hour before 30 days pass. This holds for the single-install token and for cached per-tenant tokens. The adapter does not refresh a token after a 401. [linear.go:exchangeClientCredentials,defaultTokenLifetime,ensureToken,tenantToken] - A client-credentials response must contain `access_token` and grant every requested scope. [linear.go:exchangeClientCredentials,verifyGrantedScopes] - An install credential must be a `LinearInstall` or a non-nil `*LinearInstall`. A multi-tenant install needs a webhook secret. A pre-supplied `AccessToken` is used as is. [linear.go:linearCredential,resolveWebhookInstall,tenantToken] - `GraphQL`, `CreateSessionOnIssue`, and `CreateSessionOnComment` fail in multi-tenant mode. Use the `ForTenant` forms. [session.go:GraphQL,CreateSessionOnIssue,CreateSessionOnComment] diff --git a/adapters/linear/linear.go b/adapters/linear/linear.go index 9363fa7..5148387 100644 --- a/adapters/linear/linear.go +++ b/adapters/linear/linear.go @@ -40,6 +40,7 @@ const ( defaultSignatureTolerance = time.Minute maxWebhookBodyBytes = 1 << 20 tokenRefreshBuffer = time.Hour + defaultTokenLifetime = 30 * 24 * time.Hour ) // ClientCredentials configures Linear app-actor authentication. @@ -638,7 +639,7 @@ func (a *Adapter) verifyTimestamp(timestamp int64) error { func (a *Adapter) ensureToken(ctx context.Context) error { a.mu.Lock() - needsRefresh := a.accessToken == "" || (!a.tokenExpiry.IsZero() && a.now().After(a.tokenExpiry.Add(-tokenRefreshBuffer))) + needsRefresh := a.accessToken == "" || a.now().After(a.tokenExpiry.Add(-tokenRefreshBuffer)) a.mu.Unlock() if !needsRefresh { return nil @@ -686,11 +687,13 @@ func (a *Adapter) exchangeClientCredentials(ctx context.Context, creds ClientCre if err := verifyGrantedScopes(scopes, resp.Scope); err != nil { return "", time.Time{}, err } - var expiry time.Time + // expires_in is optional (RFC 6749 section 5.1), so fall back to Linear's + // documented lifetime: https://linear.app/developers/oauth-2-0-authentication + lifetime := defaultTokenLifetime if resp.ExpiresIn > 0 { - expiry = a.now().Add(time.Duration(resp.ExpiresIn) * time.Second) + lifetime = time.Duration(resp.ExpiresIn) * time.Second } - return resp.AccessToken, expiry, nil + return resp.AccessToken, a.now().Add(lifetime), nil } // tenantToken resolves the access token for one Platform Tenant in multi-tenant @@ -706,7 +709,7 @@ func (a *Adapter) tenantToken(ctx context.Context, tenant string, cred LinearIns } a.tenantTokensMu.Lock() cached := a.tenantTokens[tenant] - if cached != nil && cached.accessToken != "" && (cached.expiry.IsZero() || a.now().Before(cached.expiry.Add(-tokenRefreshBuffer))) { + if cached != nil && cached.accessToken != "" && a.now().Before(cached.expiry.Add(-tokenRefreshBuffer)) { token := cached.accessToken a.tenantTokensMu.Unlock() return token, nil From 81cf500d188efc6d8341e6b8e8e2d804d05f1995 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 29 Oct 2026 20:49:16 +0000 Subject: [PATCH 3/3] test(adapters/linear): name the lifetime and buffer in the refresh test --- adapters/linear/token_lifetime_test.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/adapters/linear/token_lifetime_test.go b/adapters/linear/token_lifetime_test.go index 733002d..70ac502 100644 --- a/adapters/linear/token_lifetime_test.go +++ b/adapters/linear/token_lifetime_test.go @@ -12,8 +12,6 @@ import ( "github.com/coder/chat/adapters/linear" ) -// tokenWithoutExpiryServer mints client-credentials tokens whose response has -// no expires_in, and answers any GraphQL query with an empty data object. type tokenWithoutExpiryServer struct { *httptest.Server mu sync.Mutex @@ -46,25 +44,23 @@ func (api *tokenWithoutExpiryServer) tokenRequests() int { return api.tokens } -// assertTokenRefreshedAfterDefaultLifetime drives query across Linear's 30-day -// client-credentials lifetime: the token is reused within it and re-fetched -// once the clock passes 30 days minus the 1-hour refresh buffer. func assertTokenRefreshedAfterDefaultLifetime(t *testing.T, api *tokenWithoutExpiryServer, clock *mutableClock, query func() error) { t.Helper() + const lifetime, refreshBuffer = 30 * 24 * time.Hour, time.Hour if err := query(); err != nil { t.Fatalf("first query: %v", err) } if got := api.tokenRequests(); got != 1 { t.Fatalf("token requests after first query = %d, want 1", got) } - clock.advance(29 * 24 * time.Hour) + clock.advance(lifetime - refreshBuffer - time.Minute) if err := query(); err != nil { t.Fatalf("query within lifetime: %v", err) } if got := api.tokenRequests(); got != 1 { t.Fatalf("token requests within lifetime = %d, want 1 (cached)", got) } - clock.advance(24*time.Hour - time.Hour + time.Minute) + clock.advance(2 * time.Minute) if err := query(); err != nil { t.Fatalf("query past lifetime: %v", err) }