Skip to content

Conversation

@mcoulombe
Copy link
Collaborator

@mcoulombe mcoulombe commented Oct 15, 2025

Updates tailscale/terraform-provider-tailscale#485

See tailscale/terraform-provider-tailscale#567 for usage example. At first I implemented the token exchange in the provider directly but the small GenerateAccessToken is generally useful and aligns with the small OauthConfig utility to help setup different auth options when instantiating the Tailscale client.

Bumped go and some dependencies as a drive-by.

@mcoulombe mcoulombe force-pushed the max/token-exchange branch 2 times, most recently from 7a1f5ac to 33fdadb Compare October 15, 2025 19:31
Copy link
Collaborator

@oxtoacart oxtoacart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a couple of thoughts.

go.mod Outdated
module tailscale.com/client/tailscale/v2

go 1.24.0
go 1.25.1
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we require anything in go 1.25? If not, why force people to upgrade?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing specifically requires go1.25, I just thought we generally try to keep the Tailscale projects as up-to-date as possible. What's our cadence or strategy to keep projects on the latest Go version?

I'll remove the bump from the PR.

// GenerateAccessToken exchanges a federated identity client ID and identity token for a Tailscale API access token.
// Used to delegate access to Tailscale resources via workload identity federation.
//
// Usage example:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be helpful in the README as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops I missed the readme as usage examples, moved there.

//
// Usage example:
//
// accessToken, err := IdentityFederationConfig{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we get the token only once and then pass it to the client, after the token expires (in 1 hour), the client will stop working.

It might be better to follow the pattern of the Go OAuth library, which provides a special transport that uses a special token source that refreshes tokens when necessary.

Copy link
Collaborator Author

@mcoulombe mcoulombe Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially did not go with that approach because unlike with OAuth creds the ID token cannot indefinitely generate new API access tokens as needed. I updated the PR with a custom roundtripper implementation to make it a bit easier for consumers to handle token expiration and surface more useful error messages if their workflow lasts longer than the token lifetimes. Lmk if how if that make sense.

@mcoulombe mcoulombe force-pushed the max/token-exchange branch 3 times, most recently from bdcb257 to e8e882c Compare October 16, 2025 15:45
README.md Outdated
}
```

### With a dynamic ID token generator
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call including this example!

Copy link
Collaborator

@oxtoacart oxtoacart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some more comments.


// refreshToken performs the token exchange and updates the cached token.
func (t *tokenTransport) refreshToken(ctx context.Context) error {
if t.config.IDTokenGenerator != nil && validateIDToken(t.config.IDToken) != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your suggestion of just having one IDTokenGenerator parameter on the config sounds good to me, as it gets rid of a lot of these if/else branches.

At that point, you could also just call it IDToken again.

if t.config.IDTokenGenerator != nil && validateIDToken(t.config.IDToken) != nil {
idToken, err := t.config.IDTokenGenerator()
if err != nil {
return fmt.Errorf("failed to generate ID token: %w", err)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: rather than referring to getting an ID token as "generating", consider just calling it "get" or "fetch".

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

client := &http.Client{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the best way to solve it, but one of the reasons that the top-level Client type accepts an http.Client is to allow configuring things like timeouts, proxy settings, etc. Using a separate client for fetching the token skips that and could cause confusion if it breaks because it's missing something like a proxy setting.

Copy link
Collaborator Author

@mcoulombe mcoulombe Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm that's a tricky one. Not having strictly the same timeouts etc. for the toke exchange compared to calling the Tailscale API is mostly fine, but I see how ignoring proxy settings could cause problems.

It creates a chicken-and-egg problem because ideally we want to validate the WIF config as soon as possible. The only place to do so since the client itself does not have a constructor is IdentityFederationConfig.HTTPClient to refresh (or in that case initialize) the API access token before the consumer had a chance to configure the underlying http client.

I'll have to think about this one.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurs to me that we have a similar problem with OAuth already, so I don't think we need to solve this here.

Copy link
Collaborator

@oxtoacart oxtoacart left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few more comments, but no absolute blockers.

I do think it would be good to let IDTokenFunc accept a Context, especially since we call it while holding a mutex.

token string
expiresAt time.Time

tokenRefreshMu sync.Mutex
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: our convention is to place mutexes before the values they guard, with a comment like "guards the below"

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

client := &http.Client{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurs to me that we have a similar problem with OAuth already, so I don't think we need to solve this here.

defer t.tokenRefreshMu.Unlock()

if t.idToken == "" || validateIDToken(t.idToken) != nil {
idToken, err := t.config.IDTokenFunc()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to let IDTokenFunc accept a context and pass our context here. Especially since we're holding tokenRefreshMu while making this potentially remote call, it's important for that to respect some kind of timeout so that we don't lock the whole process.

// with a fresh ID token.
IDTokenFunc func() (string, error)
// BaseURL is an optional base URL for the API server to which we'll connect. Defaults to https://api.tailscale.com.
BaseURL string
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unfortunate that we need that both here and in the Client, but I don't see an obvious way around it.

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

client := &http.Client{
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider moving this http.Client to the tokenTransport type so that you can reuse it (and therefore potentially reuse underlying TCP connections).


// HTTPClient constructs an HTTP client that authenticates using identity federation.
// The client automatically handles token exchange and caching.
func (c IdentityFederationConfig) HTTPClient() (*http.Client, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be surprising that this makes a network call and can hang for a while. Consider:

  1. Documenting that behavior in the comment
  2. Accepting and respecting a Context to further emphasize this

@mcoulombe
Copy link
Collaborator Author

Closing, we'll use a different approach and refactor how OAuth is managed.

@mcoulombe mcoulombe closed this Oct 17, 2025
@mcoulombe mcoulombe deleted the max/token-exchange branch October 17, 2025 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants