-
Notifications
You must be signed in to change notification settings - Fork 69
Add OAuth support for CLI extensions #892
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
base: main
Are you sure you want to change the base?
Conversation
cretz
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.
Looks great, comments about some detailed things, but the general approach of "external option set" and client config loading and such makes sense.
cliext/client.go
Outdated
| // BuildClientOptions creates SDK client options from a ClientOptionsBuilder. | ||
| // If OAuth is configured and no APIKey is set, OAuth will be used to obtain an access token. | ||
| // Returns the client options and the resolved namespace (which may differ from input if loaded from profile). | ||
| func BuildClientOptions(ctx context.Context, opts ClientOptionsBuilder) (client.Options, string, 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.
Pedantic, but If the second parameter isn't really an "options", might as well just put Build as a method on *ClientOptionsBuilder
cliext/client.go
Outdated
|
|
||
| // BuildClientOptions creates SDK client options from a ClientOptionsBuilder. | ||
| // If OAuth is configured and no APIKey is set, OAuth will be used to obtain an access token. | ||
| // Returns the client options and the resolved namespace (which may differ from input if loaded from profile). |
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.
Isn't namespace already on the options?
cliext/client.oauth.go
Outdated
| // NewOAuthDynamicTokenProvider creates a function that provides OAuth access tokens dynamically. | ||
| // The returned function loads OAuth configuration on-demand from the config file and refreshes | ||
| // tokens as needed. Returns empty string if no OAuth is configured. | ||
| func NewOAuthDynamicTokenProvider(opts ClientOptionsBuilder) func(context.Context) (string, 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.
Pedantic, but would also consider putting this as a method on the builder (don't even have to have it return a func, can just make the method the func, e.g. func (c *ClientOptionsBuilder) GetOAuthToken(ctx context.Context) (string, error) {, but see comment a couple of lines down above why we'd want some before-anon-func work)
cliext/client.oauth.go
Outdated
| } | ||
|
|
||
| // token returns a valid access token, refreshing if necessary. | ||
| func (c *oauthClient) token(ctx context.Context, config *OAuthConfig) (OAuthToken, 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.
I would have expected oauth2 package to handle expiration, refresh, etc. Is this logic basically what oauth2.ReuseTokenSourceWithExpiry does? I wonder if we even need to provide config/token structs ourselves instead of reusing *oauth2.Config and *oauth2.Token (and documenting which values of those we support for load/store). If we do still want our oauth config structs, IMO we should offer a NewTokenSource() func on *OAuthConfig. This will help with some libraries that want the oauth data types, e.g. https://pkg.go.dev/google.golang.org/grpc/credentials/oauth#TokenSource.
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! I must have missed ReuseTokenSourceWithExpiry.
re OAuthClientConfig and OAuthToken have the benefit of hiding the oauth2 as an implementation detail. Another benefit is that they allow attaching RequestParams which we need for the Cloud CLI (to provide the audience).
But I can also see how NewTokenSource can be useful for integration as you point out. I don't know what weighs more, the benefit of hiding oauth2 as an impl detail or the convenience of exposing it via NewTokenSource().
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.
re OAuthClientConfig and OAuthToken have the benefit of hiding the oauth2 as an implementation detail
Why hide that implementation detail? Not sure any other Go library using OAuth would (e.g. the Go gRPC library). I see code was updated, so not easily seeing where request params is used in current PR, but would have to see how other Go OAuth clients are setting such request params. Definitely can have something like:
type OAuthConfig struct {
ClientConfig *oauth2.Config
Token *oauth2.Token
ClientRequestParams map[string]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.
👍 okay, let's expose oauth2, I agree it is simpler and seems to really be the library everyone uses anyway.
re request params: since they are not needed for token refresh, it's just a config field as far as cliext is concerned.
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.
But where are the request params used by cliext code?
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.
Right; I also just realized that since AFAIK the audience request param is only required during login, we could let Cloud CLI (and other CLIs) deal with that themselves. I still had it here because originally I thought it was required for token refresh or the CLI's auto-login - but both of these aren't true (anymore). I'll remove it.
cliext/client.oauth.go
Outdated
| // tokens as needed. Returns empty string if no OAuth is configured. | ||
| func NewOAuthDynamicTokenProvider(opts ClientOptionsBuilder) func(context.Context) (string, error) { | ||
| return func(ctx context.Context) (string, error) { | ||
| result, err := LoadClientOAuth(LoadClientOAuthOptions{ |
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.
IMO we should not re-lookup oauth config on each token need, it should be bound to original the loaded configuration. This means long-running CLI commands do not get logged in as maybe another user if the file changes out from under. I'd move this loading part out of to before the func.
cliext/client.go
Outdated
| clientOpts.Logger = log.NewStructuredLogger(opts.Logger) | ||
| } | ||
|
|
||
| // Set OAuth credentials if configured and no API key is set. |
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.
Not seeing where this is checking whether oauth is configured
cliext/flags.gen.go
Outdated
| ctx.BindFlagEnvVar(f.Lookup("config-file"), "TEMPORAL_CONFIG_FILE") | ||
| f.StringVar(&v.Profile, "profile", "", "Profile to use for config file. EXPERIMENTAL.") | ||
| ctx.BindFlagEnvVar(f.Lookup("profile"), "TEMPORAL_PROFILE") |
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's clearer if the --config-file and --profile flags are not bound to env vars on the flags themselves. IMO they should default to unset and let envconfig resolve them at runtime as the true canonical place for knowing what these are based on environment (in case we want to change that some day).
If a programmatic user needs to know the defaults of these values when the flag is not otherwise specified, we can provide helpers if what we have is not good enough already.
Also, BindFlagEnvVar is a relic of the env days and since envconfig came about in #764, BindFlagEnvVar was reduced to only applying for env. But now that we are exporting some of this functionality, I would be ok just hardcoding the TEMPORAL_ENV env var mapping somewhere and removing the concept of optionContext altogether. And then, at that point, we might as well remove support for the env: in the YAML file.
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.
👍 on --config-file and --profile
👍 moved out TEMPORAL_ENV and removed optionContext
Regarding implied-env; is that actually used anywhere?
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.
Regarding implied-env; is that actually used anywhere?
That's the only one that's used, it's env: that's basically not used anymore except for the one TEMPORAL_ENV, so now we can probably remove env:. FWIW, env: used to be used in lots of places until envconfig came about and basically took over environment variables except for this one legacy 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.
Right; I've removed env: but I can't see where ImpliedEnv is actually used. I can't see it. My AI coding agent suggested appending it to flag descriptions; I didn't commit it here but it seemed like a good idea.
4cbfb8c to
6ede038
Compare
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.
^ all moved to cliext/logger.go
cliext/client.go
Outdated
| if cfg.ApiKey == "" { | ||
| if err := b.initOAuthTokenSource(ctx); err != nil { | ||
| return client.Options{}, fmt.Errorf("failed to initialize OAuth: %w", err) | ||
| } | ||
| // Only set credentials if OAuth is configured | ||
| if b.oauthTokenSource != nil { | ||
| clientOpts.Credentials = client.NewAPIKeyDynamicCredentials(b.getOAuthToken) | ||
| } | ||
| } |
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.
Section where we integrate OAuth into the code that was moved here from temporalcli/client.go
2e421e9 to
138af77
Compare
| c.Command.Version = VersionString() | ||
|
|
||
| // Bind TEMPORAL_ENV environment variable to --env flag | ||
| cctx.BindFlagEnvVar(c.Command.PersistentFlags().Lookup("env"), "TEMPORAL_ENV") |
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.
Now manually binding TEMPORAL_ENV here.
| if o.Env != "" { | ||
| w.writeLinef("cctx.BindFlagEnvVar(%v.Lookup(%q), %q)", flagVar, o.Name, o.Env) | ||
| } |
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.
Removed
| if o.ImpliedEnv != strings.ToUpper(o.ImpliedEnv) { | ||
| return fmt.Errorf("env variables must be in all caps") | ||
| } |
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.
Changed over to ImpliedEnv.
a498f5e to
e1949f7
Compare
cretz
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.
Looking good, I think it's only detail stuff now
cliext/client.oauth.go
Outdated
| // Token is expired or about to expire, refresh it. | ||
| cfg := c.createOAuth2Config("") | ||
| tokenSource := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: config.RefreshToken}) | ||
| newToken, err := tokenSource.Token() |
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 am curious on this, do we think we should be re-persisting this refreshed token to disk? What does tcld do?
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 callout; the original PR I closed wrote the new access token to disk. It got lost in the reshuffle. I'll need to update that.
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.
Does tcld re-persist?
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.
cliext/client.oauth.go
Outdated
| } | ||
|
|
||
| // token returns a valid access token, refreshing if necessary. | ||
| func (c *oauthClient) token(ctx context.Context, config *OAuthConfig) (OAuthToken, 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.
re OAuthClientConfig and OAuthToken have the benefit of hiding the oauth2 as an implementation detail
Why hide that implementation detail? Not sure any other Go library using OAuth would (e.g. the Go gRPC library). I see code was updated, so not easily seeing where request params is used in current PR, but would have to see how other Go OAuth clients are setting such request params. Definitely can have something like:
type OAuthConfig struct {
ClientConfig *oauth2.Config
Token *oauth2.Token
ClientRequestParams map[string]string
}|
|
||
| // Attempt to configure OAuth config if no API key is set. | ||
| if cfg.ApiKey == "" { | ||
| result, err := LoadClientOAuth(LoadClientOAuthOptions{ |
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 don't think this properly accounts for if !common.DisableConfigFile || !common.DisableConfigEnv that the other envconfig loading does above.
Would be ok if we wanted to go ahead and have Go SDK's env config support "additional data" to avoid this double-load for everyone that is doing basic no-auth CLI calls (happens commonly in cases like proxies). But also ok if we want to double load while we wait for that.
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.
👍 Absolutely; adding that check now. (just for DisableConfigFile since the OAuth cannot be configured via env vars)
cliext/flags.gen.go
Outdated
| ctx.BindFlagEnvVar(f.Lookup("config-file"), "TEMPORAL_CONFIG_FILE") | ||
| f.StringVar(&v.Profile, "profile", "", "Profile to use for config file. EXPERIMENTAL.") | ||
| ctx.BindFlagEnvVar(f.Lookup("profile"), "TEMPORAL_PROFILE") |
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.
Regarding implied-env; is that actually used anywhere?
That's the only one that's used, it's env: that's basically not used anymore except for the one TEMPORAL_ENV, so now we can probably remove env:. FWIW, env: used to be used in lots of places until envconfig came about and basically took over environment variables except for this one legacy one.
| description: | | ||
| Path to environment settings file. | ||
| Defaults to `$HOME/.config/temporalio/temporal.yaml`. | ||
| - name: config-file |
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.
Where did implied-env go for config-file and profile? implied-env basically is just documentation/usage help, it has no runtime component.
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 got lost when I moved it, added back
What was changed
Redo of #886.
Adding:
CommonOptionsandClientOptionsgenerated for cliextClientOptionsBuilderfor building client dial optionsStoreClientOAuthOptionsandLoadClientOAuthfor oauth configNewLoggerhelper for creating a new logger from flagsWhy?
To make OAuth a first-class authentication mechanism in the Temporal CLI and CLI extensions.
Checklist
Closes
How was this tested: