Skip to content

Commit 5b0355b

Browse files
authored
Merge pull request #2355 from dgageot/auth
Store OAuth tokens in OS keychain and add silent refresh token support
2 parents b640ee9 + 796ffb1 commit 5b0355b

7 files changed

Lines changed: 288 additions & 2 deletions

File tree

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
charm.land/bubbletea/v2 v2.0.2
88
charm.land/glamour/v2 v2.0.0
99
charm.land/lipgloss/v2 v2.0.2
10+
github.com/99designs/keyring v1.2.2
1011
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0
1112
github.com/Microsoft/go-winio v0.6.2
1213
github.com/a2aproject/a2a-go v0.3.13
@@ -74,7 +75,13 @@ require (
7475
)
7576

7677
require (
78+
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
79+
github.com/danieljoos/wincred v1.2.2 // indirect
80+
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
81+
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
82+
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
7783
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
84+
github.com/mtibben/percent v0.2.1 // indirect
7885
github.com/pb33f/jsonpath v0.8.2 // indirect
7986
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
8087
)

go.sum

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
1414
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
1515
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
1616
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
17+
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
18+
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
19+
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
20+
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
1721
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
1822
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
1923
github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ=
@@ -175,6 +179,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
175179
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
176180
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
177181
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
182+
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
183+
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
178184
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
179185
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
180186
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -204,6 +210,8 @@ github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwW
204210
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
205211
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
206212
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
213+
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
214+
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
207215
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
208216
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
209217
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -239,6 +247,8 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5Nq
239247
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
240248
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
241249
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
250+
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
251+
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
242252
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
243253
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
244254
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@@ -281,6 +291,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
281291
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
282292
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
283293
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
294+
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
295+
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
284296
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
285297
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
286298
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -361,13 +373,16 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
361373
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
362374
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
363375
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
376+
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
377+
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
364378
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
365379
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
366380
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
367381
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
368382
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
369383
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
370384
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
385+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
371386
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
372387
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
373388
github.com/openai/openai-go/v3 v3.30.0 h1:T8VkhqAm6BuvxwpVG+Aw+H4TcYIsbj9nqytjpWcE/aU=
@@ -591,6 +606,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
591606
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
592607
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
593608
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
609+
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
594610
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
595611
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
596612
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=

pkg/tools/mcp/mcp.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func NewRemoteToolset(name, urlString, transport string, headers map[string]stri
111111
desc := buildRemoteDescription(urlString, transport)
112112
return &Toolset{
113113
name: name,
114-
mcpClient: newRemoteClient(urlString, transport, headers, NewInMemoryTokenStore()),
114+
mcpClient: newRemoteClient(urlString, transport, headers, NewKeyringTokenStore()),
115115
logID: urlString,
116116
description: desc,
117117
}

pkg/tools/mcp/oauth.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
180180

181181
reqClone := req.Clone(req.Context())
182182

183-
if token, err := t.tokenStore.GetToken(t.baseURL); err == nil && !token.IsExpired() {
183+
// Attach a valid token if available, silently refreshing if expired.
184+
if token := t.getValidToken(req.Context()); token != nil {
184185
reqClone.Header.Set("Authorization", "Bearer "+token.AccessToken)
185186
}
186187

@@ -210,6 +211,46 @@ func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
210211
return resp, nil
211212
}
212213

214+
// getValidToken returns a non-expired token for the server, silently refreshing
215+
// an expired token when a refresh token is available. Returns nil if no usable
216+
// token can be obtained.
217+
func (t *oauthTransport) getValidToken(ctx context.Context) *OAuthToken {
218+
token, err := t.tokenStore.GetToken(t.baseURL)
219+
if err != nil {
220+
return nil
221+
}
222+
223+
if !token.IsExpired() {
224+
return token
225+
}
226+
227+
if token.RefreshToken == "" {
228+
return nil
229+
}
230+
231+
slog.Debug("Attempting silent token refresh", "url", t.baseURL)
232+
233+
o := &oauth{metadataClient: &http.Client{Timeout: 5 * time.Second}}
234+
metadata, err := o.getAuthorizationServerMetadata(ctx, t.baseURL)
235+
if err != nil {
236+
slog.Debug("Failed to fetch auth server metadata for refresh", "error", err)
237+
return nil
238+
}
239+
240+
newToken, err := RefreshAccessToken(ctx, metadata.TokenEndpoint, token.RefreshToken, "", "")
241+
if err != nil {
242+
slog.Debug("Token refresh failed, will require interactive auth", "error", err)
243+
return nil
244+
}
245+
246+
if err := t.tokenStore.StoreToken(t.baseURL, newToken); err != nil {
247+
slog.Warn("Failed to store refreshed token", "error", err)
248+
}
249+
250+
slog.Debug("Token refreshed successfully", "url", t.baseURL)
251+
return newToken
252+
}
253+
213254
// handleOAuthFlow performs the OAuth flow when a 401 response is received
214255
func (t *oauthTransport) handleOAuthFlow(ctx context.Context, authServer, wwwAuth string) error {
215256
if t.managed {

pkg/tools/mcp/oauth_helpers.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,48 @@ func RegisterClient(ctx context.Context, authMetadata *AuthorizationServerMetada
159159
func GeneratePKCEVerifier() string {
160160
return oauth2.GenerateVerifier()
161161
}
162+
163+
// RefreshAccessToken uses a refresh token to obtain a new access token
164+
// without user interaction.
165+
func RefreshAccessToken(ctx context.Context, tokenEndpoint, refreshToken, clientID, clientSecret string) (*OAuthToken, error) {
166+
data := url.Values{}
167+
data.Set("grant_type", "refresh_token")
168+
data.Set("refresh_token", refreshToken)
169+
data.Set("client_id", clientID)
170+
if clientSecret != "" {
171+
data.Set("client_secret", clientSecret)
172+
}
173+
174+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode()))
175+
if err != nil {
176+
return nil, fmt.Errorf("failed to create refresh request: %w", err)
177+
}
178+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
179+
180+
resp, err := http.DefaultClient.Do(req)
181+
if err != nil {
182+
return nil, fmt.Errorf("failed to refresh token: %w", err)
183+
}
184+
defer resp.Body.Close()
185+
186+
if resp.StatusCode != http.StatusOK {
187+
body, _ := io.ReadAll(resp.Body)
188+
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
189+
}
190+
191+
var token OAuthToken
192+
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
193+
return nil, fmt.Errorf("failed to decode refresh response: %w", err)
194+
}
195+
196+
if token.ExpiresIn > 0 {
197+
token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
198+
}
199+
200+
// Preserve the refresh token if the server didn't issue a new one
201+
if token.RefreshToken == "" {
202+
token.RefreshToken = refreshToken
203+
}
204+
205+
return &token, nil
206+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"log/slog"
8+
9+
"github.com/99designs/keyring"
10+
)
11+
12+
const keyringServiceName = "docker-agent-oauth"
13+
14+
// KeyringTokenStore implements OAuthTokenStore using the OS-native credential store
15+
// (macOS Keychain, Windows Credential Manager, Linux Secret Service).
16+
type KeyringTokenStore struct {
17+
ring keyring.Keyring
18+
}
19+
20+
// NewKeyringTokenStore creates a token store backed by the OS keychain.
21+
// Falls back to InMemoryTokenStore if no keyring backend is available.
22+
func NewKeyringTokenStore() OAuthTokenStore {
23+
ring, err := keyring.Open(keyring.Config{
24+
ServiceName: keyringServiceName,
25+
KeychainTrustApplication: true,
26+
KeychainSynchronizable: false,
27+
KeychainAccessibleWhenUnlocked: true,
28+
})
29+
if err != nil {
30+
slog.Warn("OS keyring not available, falling back to in-memory token store", "error", err)
31+
return NewInMemoryTokenStore()
32+
}
33+
34+
// Validate the keyring is actually usable by attempting a get.
35+
// Some backends (e.g. file) open successfully but fail on operations.
36+
_, err = ring.Get("docker-agent-probe")
37+
if err != nil && !errors.Is(err, keyring.ErrKeyNotFound) {
38+
slog.Warn("OS keyring not usable, falling back to in-memory token store", "error", err)
39+
return NewInMemoryTokenStore()
40+
}
41+
42+
return &KeyringTokenStore{ring: ring}
43+
}
44+
45+
// keyringKey returns a stable key for a given resource URL.
46+
func keyringKey(resourceURL string) string {
47+
return "oauth:" + resourceURL
48+
}
49+
50+
func (s *KeyringTokenStore) GetToken(resourceURL string) (*OAuthToken, error) {
51+
item, err := s.ring.Get(keyringKey(resourceURL))
52+
if err != nil {
53+
if errors.Is(err, keyring.ErrKeyNotFound) {
54+
return nil, fmt.Errorf("no token found for resource: %s", resourceURL)
55+
}
56+
return nil, fmt.Errorf("keyring get failed: %w", err)
57+
}
58+
59+
var token OAuthToken
60+
if err := json.Unmarshal(item.Data, &token); err != nil {
61+
return nil, fmt.Errorf("failed to unmarshal token: %w", err)
62+
}
63+
64+
return &token, nil
65+
}
66+
67+
func (s *KeyringTokenStore) StoreToken(resourceURL string, token *OAuthToken) error {
68+
data, err := json.Marshal(token)
69+
if err != nil {
70+
return fmt.Errorf("failed to marshal token: %w", err)
71+
}
72+
73+
return s.ring.Set(keyring.Item{
74+
Key: keyringKey(resourceURL),
75+
Data: data,
76+
Label: "Docker Agent OAuth Token",
77+
Description: "OAuth token for " + resourceURL,
78+
})
79+
}
80+
81+
func (s *KeyringTokenStore) RemoveToken(resourceURL string) error {
82+
err := s.ring.Remove(keyringKey(resourceURL))
83+
if err != nil && !errors.Is(err, keyring.ErrKeyNotFound) {
84+
return fmt.Errorf("keyring remove failed: %w", err)
85+
}
86+
return nil
87+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package mcp
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestKeyringTokenStore_RoundTrip(t *testing.T) {
10+
// Use in-memory fallback for CI environments without a keyring.
11+
// The KeyringTokenStore constructor already falls back to InMemoryTokenStore,
12+
// so this test validates the interface contract regardless of backend.
13+
store := NewKeyringTokenStore()
14+
15+
resourceURL := "https://example.com/mcp"
16+
17+
// Initially no token
18+
_, err := store.GetToken(resourceURL)
19+
if err == nil {
20+
t.Fatal("expected error for missing token")
21+
}
22+
23+
// Store a token
24+
token := &OAuthToken{
25+
AccessToken: "access-123",
26+
TokenType: "Bearer",
27+
RefreshToken: "refresh-456",
28+
ExpiresIn: 3600,
29+
ExpiresAt: time.Now().Add(1 * time.Hour),
30+
}
31+
if err := store.StoreToken(resourceURL, token); err != nil {
32+
t.Fatalf("StoreToken failed: %v", err)
33+
}
34+
35+
// Retrieve it
36+
got, err := store.GetToken(resourceURL)
37+
if err != nil {
38+
t.Fatalf("GetToken failed: %v", err)
39+
}
40+
if got.AccessToken != "access-123" {
41+
t.Errorf("AccessToken = %q, want %q", got.AccessToken, "access-123")
42+
}
43+
if got.RefreshToken != "refresh-456" {
44+
t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, "refresh-456")
45+
}
46+
47+
// Remove it
48+
if err := store.RemoveToken(resourceURL); err != nil {
49+
t.Fatalf("RemoveToken failed: %v", err)
50+
}
51+
52+
_, err = store.GetToken(resourceURL)
53+
if err == nil {
54+
t.Fatal("expected error after RemoveToken")
55+
}
56+
}
57+
58+
func TestKeyringTokenStore_JSONRoundTrip(t *testing.T) {
59+
// Verify that OAuthToken serializes correctly (important for keyring storage)
60+
token := &OAuthToken{
61+
AccessToken: "at",
62+
TokenType: "Bearer",
63+
RefreshToken: "rt",
64+
ExpiresIn: 7200,
65+
ExpiresAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
66+
Scope: "read write",
67+
}
68+
69+
data, err := json.Marshal(token)
70+
if err != nil {
71+
t.Fatalf("Marshal failed: %v", err)
72+
}
73+
74+
var got OAuthToken
75+
if err := json.Unmarshal(data, &got); err != nil {
76+
t.Fatalf("Unmarshal failed: %v", err)
77+
}
78+
79+
if got.AccessToken != token.AccessToken || got.RefreshToken != token.RefreshToken || got.Scope != token.Scope {
80+
t.Errorf("JSON round-trip mismatch: got %+v, want %+v", got, token)
81+
}
82+
}
83+
84+
func TestKeyringTokenStore_RemoveNonExistent(t *testing.T) {
85+
store := NewKeyringTokenStore()
86+
// Should not error when removing a non-existent token
87+
if err := store.RemoveToken("https://nonexistent.example.com"); err != nil {
88+
t.Fatalf("RemoveToken for non-existent key should not error: %v", err)
89+
}
90+
}

0 commit comments

Comments
 (0)