-
Notifications
You must be signed in to change notification settings - Fork 8
identityfederation: added a utility method to perform the token exchange #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
7a1f5ac to
33fdadb
Compare
oxtoacart
left a comment
There was a problem hiding this 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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
identityfederation.go
Outdated
| // 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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
identityfederation.go
Outdated
| // | ||
| // Usage example: | ||
| // | ||
| // accessToken, err := IdentityFederationConfig{ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
bdcb257 to
e8e882c
Compare
README.md
Outdated
| } | ||
| ``` | ||
|
|
||
| ### With a dynamic ID token generator |
There was a problem hiding this comment.
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!
oxtoacart
left a comment
There was a problem hiding this 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.
identityfederation.go
Outdated
|
|
||
| // 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 { |
There was a problem hiding this comment.
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.
identityfederation.go
Outdated
| 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) |
There was a problem hiding this comment.
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{ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
e8e882c to
3c6f3a0
Compare
Updates tailscale/terraform-provider-tailscale#485 Signed-off-by: mcoulombe <[email protected]>
3c6f3a0 to
2645aaa
Compare
oxtoacart
left a comment
There was a problem hiding this 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 |
There was a problem hiding this comment.
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{ |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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{ |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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:
- Documenting that behavior in the comment
- Accepting and respecting a Context to further emphasize this
|
Closing, we'll use a different approach and refactor how OAuth is managed. |
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
GenerateAccessTokenis 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.