Skip to content

Conversation

@stephanos
Copy link
Contributor

@stephanos stephanos commented Dec 18, 2025

What was changed

Redo of #886.

Adding:

  1. ability to code-gen just options-sets
  2. ability to reference option-sets across codegen YAML input files
  3. shared CommonOptions and ClientOptions generated for cliext
  4. ClientOptionsBuilder for building client dial options
  5. integration of OAuth token into client dial options (plus integration tests)
  6. StoreClientOAuthOptions and LoadClientOAuth for oauth config
  7. NewLogger helper for creating a new logger from flags

Why?

To make OAuth a first-class authentication mechanism in the Temporal CLI and CLI extensions.

Checklist

  1. Closes

  2. How was this tested:

  1. Any docs updates needed?

Copy link
Member

@cretz cretz left a 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) {
Copy link
Member

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).
Copy link
Member

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?

// 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) {
Copy link
Member

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)

}

// token returns a valid access token, refreshing if necessary.
func (c *oauthClient) token(ctx context.Context, config *OAuthConfig) (OAuthToken, error) {
Copy link
Member

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.

Copy link
Contributor Author

@stephanos stephanos Dec 18, 2025

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().

Copy link
Member

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
}

Copy link
Contributor Author

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.

Copy link
Member

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?

Copy link
Contributor Author

@stephanos stephanos Dec 19, 2025

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.

// 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{
Copy link
Member

@cretz cretz Dec 18, 2025

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.
Copy link
Member

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

Comment on lines 69 to 71
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")
Copy link
Member

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.

Copy link
Contributor Author

@stephanos stephanos Dec 19, 2025

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?

Copy link
Member

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.

Copy link
Contributor Author

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.

@stephanos stephanos force-pushed the oauth2 branch 3 times, most recently from 4cbfb8c to 6ede038 Compare December 19, 2025 02:23
Copy link
Contributor Author

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
Comment on lines 197 to 210
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)
}
}
Copy link
Contributor Author

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

@stephanos stephanos force-pushed the oauth2 branch 5 times, most recently from 2e421e9 to 138af77 Compare December 19, 2025 04:43
c.Command.Version = VersionString()

// Bind TEMPORAL_ENV environment variable to --env flag
cctx.BindFlagEnvVar(c.Command.PersistentFlags().Lookup("env"), "TEMPORAL_ENV")
Copy link
Contributor Author

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.

Comment on lines -489 to -491
if o.Env != "" {
w.writeLinef("cctx.BindFlagEnvVar(%v.Lookup(%q), %q)", flagVar, o.Name, o.Env)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed

Comment on lines +263 to 260
if o.ImpliedEnv != strings.ToUpper(o.ImpliedEnv) {
return fmt.Errorf("env variables must be in all caps")
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed over to ImpliedEnv.

@stephanos stephanos force-pushed the oauth2 branch 4 times, most recently from a498f5e to e1949f7 Compare December 19, 2025 05:00
@stephanos stephanos marked this pull request as ready for review December 19, 2025 05:07
@stephanos stephanos requested review from a team as code owners December 19, 2025 05:07
@stephanos stephanos requested a review from cretz December 19, 2025 05:13
Copy link
Member

@cretz cretz left a 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

// 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()
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

Choose a reason for hiding this comment

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

Does tcld re-persist?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

}

// token returns a valid access token, refreshing if necessary.
func (c *oauthClient) token(ctx context.Context, config *OAuthConfig) (OAuthToken, error) {
Copy link
Member

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{
Copy link
Member

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.

Copy link
Contributor Author

@stephanos stephanos Dec 19, 2025

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)

Comment on lines 69 to 71
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")
Copy link
Member

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
Copy link
Member

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.

Copy link
Contributor Author

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

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.

2 participants