Skip to content

Commit bef6f7f

Browse files
authored
Merge pull request #32 from linuxfoundation/jme/fix-m2m-token-exchange
fix(lfxv2): use client_credentials grant for M2M token callers
2 parents 0e8a0af + 59962a1 commit bef6f7f

2 files changed

Lines changed: 82 additions & 23 deletions

File tree

internal/lfxv2/client.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -414,41 +414,62 @@ func (a *authInterceptor) RoundTrip(req *http.Request) (*http.Response, error) {
414414
return a.base.RoundTrip(reqClone)
415415
}
416416

417-
// getOrExchangeToken gets a cached LFX token or performs token exchange.
417+
// m2mCacheKey is the cache key used for all M2M token requests. Since M2M callers
418+
// all receive the same LFX token (minted via client_credentials), a single entry
419+
// covers them all rather than one entry per unique M2M subject token.
420+
const m2mCacheKey = "__m2m__"
421+
422+
// getOrExchangeToken gets a cached LFX token or obtains a new one.
423+
// For user tokens, it performs RFC 8693 token exchange.
424+
// For M2M tokens (Auth0 @clients subject), it uses the client_credentials grant
425+
// because Auth0 cannot exchange M2M tokens via token exchange (no user to propagate).
418426
func (c *Clients) getOrExchangeToken(ctx context.Context, mcpToken string) (string, error) {
419427
if c.tokenExchangeClient == nil {
420428
return "", fmt.Errorf("token exchange client not configured")
421429
}
422430

431+
// M2M tokens all share a single cached LFX token obtained via client_credentials.
432+
useClientCredentials := isM2MToken(mcpToken)
433+
cacheKey := mcpToken
434+
if useClientCredentials {
435+
cacheKey = m2mCacheKey
436+
}
437+
423438
// Check cache first (read lock).
424439
c.mu.RLock()
425-
cached, exists := c.tokenCache[mcpToken]
440+
cached, exists := c.tokenCache[cacheKey]
426441
c.mu.RUnlock()
427442

428443
// Return cached token if valid.
429444
if exists && time.Now().Before(cached.expiry) {
430445
return cached.accessToken, nil
431446
}
432447

433-
// Need to exchange token (write lock).
448+
// Need to fetch token (write lock).
434449
c.mu.Lock()
435450
defer c.mu.Unlock()
436451

437452
// Double-check cache in case another goroutine just updated it.
438-
cached, exists = c.tokenCache[mcpToken]
453+
cached, exists = c.tokenCache[cacheKey]
439454
if exists && time.Now().Before(cached.expiry) {
440455
return cached.accessToken, nil
441456
}
442457

443-
// Perform token exchange.
444-
resp, err := c.tokenExchangeClient.ExchangeToken(ctx, mcpToken)
458+
// Obtain LFX token via the appropriate grant type.
459+
var resp *TokenExchangeResponse
460+
var err error
461+
if useClientCredentials {
462+
resp, err = c.tokenExchangeClient.ClientCredentials(ctx)
463+
} else {
464+
resp, err = c.tokenExchangeClient.ExchangeToken(ctx, mcpToken)
465+
}
445466
if err != nil {
446467
return "", err
447468
}
448469

449-
// Cache the exchanged token with 5-minute buffer.
470+
// Cache the token with a 5-minute buffer before expiry.
450471
expiryBuffer := 5 * time.Minute
451-
c.tokenCache[mcpToken] = &cachedToken{
472+
c.tokenCache[cacheKey] = &cachedToken{
452473
accessToken: resp.AccessToken,
453474
expiry: time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second - expiryBuffer),
454475
}

internal/lfxv2/token_exchange.go

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -149,44 +149,49 @@ func (c *TokenExchangeClient) generateClientAssertion() (string, error) {
149149
return string(signed), nil
150150
}
151151

152-
// ExchangeToken exchanges a subject token for a new access token per RFC 8693.
153-
func (c *TokenExchangeClient) ExchangeToken(ctx context.Context, subjectToken string) (*TokenExchangeResponse, error) {
154-
// Build token exchange request per RFC 8693 Section 2.1.
155-
data := url.Values{}
156-
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
157-
data.Set("client_id", c.config.ClientID)
158-
data.Set("subject_token", subjectToken)
159-
data.Set("subject_token_type", c.config.SubjectTokenType)
160-
data.Set("audience", c.config.Audience)
152+
// isM2MToken reports whether token is a machine-to-machine (client credentials)
153+
// JWT, identified by Auth0's convention of a subject claim ending in "@clients".
154+
func isM2MToken(token string) bool {
155+
parsed, err := jwt.ParseInsecure([]byte(token))
156+
if err != nil {
157+
return false
158+
}
159+
return strings.HasSuffix(parsed.Subject(), "@clients")
160+
}
161161

162-
// Use client assertion if signing key is provided, otherwise use client secret.
162+
// addClientAuth adds client authentication fields (secret or JWT assertion) to data.
163+
func (c *TokenExchangeClient) addClientAuth(data url.Values) error {
163164
if c.config.ClientAssertionSigningKey != "" {
164165
assertion, err := c.generateClientAssertion()
165166
if err != nil {
166-
return nil, fmt.Errorf("failed to generate client assertion: %w", err)
167+
return fmt.Errorf("failed to generate client assertion: %w", err)
167168
}
168169
data.Set("client_assertion", assertion)
169170
data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
170171
} else {
171172
data.Set("client_secret", c.config.ClientSecret)
172173
}
174+
return nil
175+
}
173176

177+
// postTokenRequest sends a token request and returns the parsed response.
178+
func (c *TokenExchangeClient) postTokenRequest(ctx context.Context, data url.Values) (*TokenExchangeResponse, error) {
174179
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.TokenEndpoint, strings.NewReader(data.Encode()))
175180
if err != nil {
176-
return nil, fmt.Errorf("failed to create token exchange request: %w", err)
181+
return nil, fmt.Errorf("failed to create token request: %w", err)
177182
}
178183

179184
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
180185

181186
resp, err := c.client.Do(req)
182187
if err != nil {
183-
return nil, fmt.Errorf("failed to exchange token: %w", err)
188+
return nil, fmt.Errorf("failed to execute token request: %w", err)
184189
}
185190
defer resp.Body.Close()
186191

187192
body, err := io.ReadAll(resp.Body)
188193
if err != nil {
189-
return nil, fmt.Errorf("failed to read token exchange response: %w", err)
194+
return nil, fmt.Errorf("failed to read token response: %w", err)
190195
}
191196

192197
if resp.StatusCode != http.StatusOK {
@@ -195,8 +200,41 @@ func (c *TokenExchangeClient) ExchangeToken(ctx context.Context, subjectToken st
195200

196201
var tokenResp TokenExchangeResponse
197202
if err := json.Unmarshal(body, &tokenResp); err != nil {
198-
return nil, fmt.Errorf("failed to parse token exchange response: %w", err)
203+
return nil, fmt.Errorf("failed to parse token response: %w", err)
199204
}
200205

201206
return &tokenResp, nil
202207
}
208+
209+
// ExchangeToken exchanges a subject token for a new access token per RFC 8693.
210+
func (c *TokenExchangeClient) ExchangeToken(ctx context.Context, subjectToken string) (*TokenExchangeResponse, error) {
211+
data := url.Values{}
212+
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
213+
data.Set("client_id", c.config.ClientID)
214+
data.Set("subject_token", subjectToken)
215+
data.Set("subject_token_type", c.config.SubjectTokenType)
216+
data.Set("audience", c.config.Audience)
217+
218+
if err := c.addClientAuth(data); err != nil {
219+
return nil, err
220+
}
221+
222+
return c.postTokenRequest(ctx, data)
223+
}
224+
225+
// ClientCredentials obtains an LFX API token using the client_credentials grant.
226+
// This is used when the caller presents an M2M token, which Auth0 cannot exchange
227+
// via RFC 8693 token exchange (it requires a user subject). Instead, the MCP server
228+
// mints a fresh LFX token using its own client identity.
229+
func (c *TokenExchangeClient) ClientCredentials(ctx context.Context) (*TokenExchangeResponse, error) {
230+
data := url.Values{}
231+
data.Set("grant_type", "client_credentials")
232+
data.Set("client_id", c.config.ClientID)
233+
data.Set("audience", c.config.Audience)
234+
235+
if err := c.addClientAuth(data); err != nil {
236+
return nil, err
237+
}
238+
239+
return c.postTokenRequest(ctx, data)
240+
}

0 commit comments

Comments
 (0)