Skip to content

Commit c02d3af

Browse files
authored
chore: Add GetGoogleIDToken method (#24)
* chore: Add GetGoogleIDToken method * Use GetGoogleIDToken in the E2E test * Remove GetGoogleIDToken in the E2E test * Add documentation for GetGoogleIDToken
1 parent 73236b2 commit c02d3af

File tree

3 files changed

+226
-1
lines changed

3 files changed

+226
-1
lines changed

core/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,21 @@ For Toolbox servers hosted on Google Cloud (e.g., Cloud Run) and requiring
247247
Engine, GKE, another Cloud Run service, Cloud Functions), ADC is typically
248248
configured automatically, using the environment's default service account.
249249
3. **Connect to the Toolbox Server**
250+
```go
251+
import "github.com/googleapis/mcp-toolbox-sdk-go/core"
252+
import "context"
250253

251-
- TODO
254+
ctx := context.Background()
255+
256+
token, err := core.GetGoogleIDToken(ctx, URL)
257+
258+
client, err := core.NewToolboxClient(
259+
URL,
260+
core.WithClientHeaderString("Authorization", token),
261+
)
262+
263+
// Now, you can use the client as usual.
264+
```
252265

253266
## Authenticating Tools
254267

core/auth.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package core
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"sync"
21+
22+
"golang.org/x/oauth2"
23+
"google.golang.org/api/idtoken"
24+
)
25+
26+
var (
27+
// Caches the underlying token mechanism, keyed by audience, for efficiency.
28+
tokenSourceCache = make(map[string]oauth2.TokenSource)
29+
cacheMutex = &sync.Mutex{}
30+
// By assigning the real function to a variable, we can replace it
31+
// during tests with a mock function.
32+
newTokenSource = idtoken.NewTokenSource
33+
)
34+
35+
// GetGoogleIDToken fetches a Google ID token for a specific audience.
36+
//
37+
// Inputs:
38+
//
39+
// - ctx: The context for the request, which can be used for cancellation or deadlines.
40+
// - audience: The recipient of the token, typically the URL of the secured service
41+
//
42+
// Returns:
43+
//
44+
// A string in the format "Bearer <token>" on success, or an error if
45+
// the token could not be fetched.
46+
func GetGoogleIDToken(ctx context.Context, audience string) (string, error) {
47+
cacheMutex.Lock()
48+
ts, ok := tokenSourceCache[audience]
49+
if !ok {
50+
// If not found in cache, create a new token source.
51+
var err error
52+
ts, err = newTokenSource(ctx, audience)
53+
if err != nil {
54+
cacheMutex.Unlock() // Unlock before returning the error.
55+
return "", fmt.Errorf("failed to create new token source: %w", err)
56+
}
57+
// Store the new source in the cache.
58+
tokenSourceCache[audience] = ts
59+
}
60+
cacheMutex.Unlock()
61+
62+
// Use the token source to get a valid token.
63+
token, err := ts.Token()
64+
if err != nil {
65+
return "", fmt.Errorf("failed to retrieve token from source: %w", err)
66+
}
67+
68+
// Return the token with the "Bearer " prefix.
69+
return "Bearer " + token.AccessToken, nil
70+
}

core/auth_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package core
16+
17+
import (
18+
"context"
19+
"errors"
20+
"strings"
21+
"testing"
22+
"time"
23+
24+
"golang.org/x/oauth2"
25+
"google.golang.org/api/option"
26+
)
27+
28+
// mockAuthTokenSource is a mock implementation of the oauth2.TokenSource interface.
29+
// It allows us to control the token and error returned during tests.
30+
type mockAuthTokenSource struct {
31+
tokenToReturn *oauth2.Token
32+
errorToReturn error
33+
}
34+
35+
// Token is the method that satisfies the TokenSource interface.
36+
func (m *mockAuthTokenSource) Token() (*oauth2.Token, error) {
37+
return m.tokenToReturn, m.errorToReturn
38+
}
39+
40+
// setup is a helper to reset the cache and the newTokenSource variable for each test.
41+
func setup(t *testing.T) {
42+
// Reset the global cache for a clean state.
43+
cacheMutex.Lock()
44+
tokenSourceCache = make(map[string]oauth2.TokenSource)
45+
cacheMutex.Unlock()
46+
47+
// After the test, restore the original function.
48+
originalNewTokenSource := newTokenSource
49+
t.Cleanup(func() {
50+
newTokenSource = originalNewTokenSource
51+
})
52+
}
53+
54+
func TestGetGoogleIDToken_Success(t *testing.T) {
55+
setup(t)
56+
const mockToken = "mock-id-token-123"
57+
const audience = "https://test-service.com"
58+
59+
// Replace the package-level variable with the mock function.
60+
newTokenSource = func(ctx context.Context, aud string, opts ...option.ClientOption) (oauth2.TokenSource, error) {
61+
// This mock will return our custom token source.
62+
return &mockAuthTokenSource{
63+
tokenToReturn: &oauth2.Token{
64+
AccessToken: mockToken,
65+
Expiry: time.Now().Add(time.Hour),
66+
},
67+
}, nil
68+
}
69+
70+
token, err := GetGoogleIDToken(context.Background(), audience)
71+
72+
if err != nil {
73+
t.Fatalf("Expected no error, but got: %v", err)
74+
}
75+
76+
expectedToken := "Bearer " + mockToken
77+
if token != expectedToken {
78+
t.Errorf("Expected token '%s', but got '%s'", expectedToken, token)
79+
}
80+
}
81+
82+
func TestGetGoogleIDToken_Caching(t *testing.T) {
83+
setup(t)
84+
callCount := 0
85+
86+
// Replace the variable with a mock that tracks how many times it's called.
87+
newTokenSource = func(ctx context.Context, aud string, opts ...option.ClientOption) (oauth2.TokenSource, error) {
88+
callCount++
89+
return &mockAuthTokenSource{
90+
tokenToReturn: &oauth2.Token{AccessToken: "some-token"},
91+
}, nil
92+
}
93+
94+
_, _ = GetGoogleIDToken(context.Background(), "https://some-audience.com")
95+
_, _ = GetGoogleIDToken(context.Background(), "https://some-audience.com")
96+
97+
// The mock should only be called once because the result is cached.
98+
if callCount != 1 {
99+
t.Errorf("Expected newTokenSource to be called 1 time due to caching, but was called %d times", callCount)
100+
}
101+
}
102+
103+
func TestGetGoogleIDToken_NewTokenSourceError(t *testing.T) {
104+
setup(t)
105+
expectedErr := errors.New("failed to create source")
106+
107+
// This mock simulates an error during the creation of the token source itself.
108+
newTokenSource = func(ctx context.Context, aud string, opts ...option.ClientOption) (oauth2.TokenSource, error) {
109+
return nil, expectedErr
110+
}
111+
112+
_, err := GetGoogleIDToken(context.Background(), "https://some-audience.com")
113+
114+
if err == nil {
115+
t.Fatal("Expected an error, but got nil")
116+
}
117+
if !strings.Contains(err.Error(), expectedErr.Error()) {
118+
t.Errorf("Expected error message to contain '%s', but got: %v", expectedErr.Error(), err)
119+
}
120+
}
121+
122+
func TestGetGoogleIDToken_TokenFetchError(t *testing.T) {
123+
setup(t)
124+
expectedErr := errors.New("failed to fetch token")
125+
126+
// This mock successfully creates a source, but the source itself will fail
127+
// when we try to get a token from it.
128+
newTokenSource = func(ctx context.Context, aud string, opts ...option.ClientOption) (oauth2.TokenSource, error) {
129+
return &mockAuthTokenSource{
130+
errorToReturn: expectedErr,
131+
}, nil
132+
}
133+
134+
_, err := GetGoogleIDToken(context.Background(), "https://some-audience.com")
135+
136+
if err == nil {
137+
t.Fatal("Expected an error, but got nil")
138+
}
139+
if !strings.Contains(err.Error(), expectedErr.Error()) {
140+
t.Errorf("Expected error message to contain '%s', but got: %v", expectedErr.Error(), err)
141+
}
142+
}

0 commit comments

Comments
 (0)