Skip to content

Commit

Permalink
feat(internaloption): add better support for self-signed JWT (#738)
Browse files Browse the repository at this point in the history
Added a couple of new internaloptions, WithDefaultAudience and
WithDefaultScopes, which will be used to start using self-signed
JWTs by default when certain criteria are met:

1. Authenticating with a service account.
2. User does not pass explicitly provide their own scopes.
3. An audience is provided.

In the future gapic client will begin to pass these new
internaloptions which will enable them to authenticate with with
a JWT signed by a service account. This is a non-standard oAuth2
flow, and is an optimization to save an extra network request.
  • Loading branch information
codyoss committed Dec 2, 2020
1 parent 445fe0b commit 1a7550f
Show file tree
Hide file tree
Showing 5 changed files with 54 additions and 15 deletions.
2 changes: 1 addition & 1 deletion idtoken/idtoken.go
Expand Up @@ -104,7 +104,7 @@ func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds
if err := isServiceAccount(data); err != nil {
return nil, err
}
cfg, err := google.JWTConfigFromJSON(data, ds.Scopes...)
cfg, err := google.JWTConfigFromJSON(data, ds.GetScopes()...)
if err != nil {
return nil, err
}
Expand Down
23 changes: 11 additions & 12 deletions internal/creds.go
Expand Up @@ -34,24 +34,24 @@ func baseCreds(ctx context.Context, ds *DialSettings) (*google.Credentials, erro
return ds.Credentials, nil
}
if ds.CredentialsJSON != nil {
return credentialsFromJSON(ctx, ds.CredentialsJSON, ds.Endpoint, ds.Scopes, ds.Audiences)
return credentialsFromJSON(ctx, ds.CredentialsJSON, ds)
}
if ds.CredentialsFile != "" {
data, err := ioutil.ReadFile(ds.CredentialsFile)
if err != nil {
return nil, fmt.Errorf("cannot read credentials file: %v", err)
}
return credentialsFromJSON(ctx, data, ds.Endpoint, ds.Scopes, ds.Audiences)
return credentialsFromJSON(ctx, data, ds)
}
if ds.TokenSource != nil {
return &google.Credentials{TokenSource: ds.TokenSource}, nil
}
cred, err := google.FindDefaultCredentials(ctx, ds.Scopes...)
cred, err := google.FindDefaultCredentials(ctx, ds.GetScopes()...)
if err != nil {
return nil, err
}
if len(cred.JSON) > 0 {
return credentialsFromJSON(ctx, cred.JSON, ds.Endpoint, ds.Scopes, ds.Audiences)
return credentialsFromJSON(ctx, cred.JSON, ds)
}
// For GAE and GCE, the JSON is empty so return the default credentials directly.
return cred, nil
Expand All @@ -66,12 +66,12 @@ const (
//
// - If the JSON is a service account and no scopes provided, returns self-signed JWT auth flow
// - Otherwise, returns OAuth 2.0 flow.
func credentialsFromJSON(ctx context.Context, data []byte, endpoint string, scopes []string, audiences []string) (*google.Credentials, error) {
cred, err := google.CredentialsFromJSON(ctx, data, scopes...)
func credentialsFromJSON(ctx context.Context, data []byte, ds *DialSettings) (*google.Credentials, error) {
cred, err := google.CredentialsFromJSON(ctx, data, ds.GetScopes()...)
if err != nil {
return nil, err
}
if len(data) > 0 && len(scopes) == 0 {
if len(data) > 0 && len(ds.Scopes) == 0 && (ds.DefaultAudience != "" || len(ds.Audiences) > 0) {
var f struct {
Type string `json:"type"`
// The rest JSON fields are omitted because they are not used.
Expand All @@ -80,7 +80,7 @@ func credentialsFromJSON(ctx context.Context, data []byte, endpoint string, scop
return nil, err
}
if f.Type == serviceAccountKey {
ts, err := selfSignedJWTTokenSource(data, endpoint, audiences)
ts, err := selfSignedJWTTokenSource(data, ds.DefaultAudience, ds.Audiences)
if err != nil {
return nil, err
}
Expand All @@ -90,9 +90,8 @@ func credentialsFromJSON(ctx context.Context, data []byte, endpoint string, scop
return cred, err
}

func selfSignedJWTTokenSource(data []byte, endpoint string, audiences []string) (oauth2.TokenSource, error) {
// Use the API endpoint as the default audience
audience := endpoint
func selfSignedJWTTokenSource(data []byte, defaultAudience string, audiences []string) (oauth2.TokenSource, error) {
audience := defaultAudience
if len(audiences) > 0 {
// TODO(shinfan): Update golang oauth to support multiple audiences.
if len(audiences) > 1 {
Expand All @@ -118,7 +117,7 @@ func QuotaProjectFromCreds(cred *google.Credentials) string {

func impersonateCredentials(ctx context.Context, creds *google.Credentials, ds *DialSettings) (*google.Credentials, error) {
if len(ds.ImpersonationConfig.Scopes) == 0 {
ds.ImpersonationConfig.Scopes = ds.Scopes
ds.ImpersonationConfig.Scopes = ds.GetScopes()
}
ts, err := impersonate.TokenSource(ctx, creds.TokenSource, ds.ImpersonationConfig)
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/creds_test.go
Expand Up @@ -119,7 +119,7 @@ const validServiceAccountJSON = `{
func TestQuotaProjectFromCreds(t *testing.T) {
ctx := context.Background()

cred, err := credentialsFromJSON(ctx, []byte(validServiceAccountJSON), "foo.googleapis.com", nil, nil)
cred, err := credentialsFromJSON(ctx, []byte(validServiceAccountJSON), &DialSettings{Endpoint: "foo.googleapis.com"})
if err != nil {
t.Fatalf("got %v, wanted no error", err)
}
Expand All @@ -133,7 +133,7 @@ func TestQuotaProjectFromCreds(t *testing.T) {
"quota_project_id": "foobar"
}`)

cred, err = credentialsFromJSON(ctx, []byte(quotaProjectJSON), "foo.googleapis.com", nil, nil)
cred, err = credentialsFromJSON(ctx, []byte(quotaProjectJSON), &DialSettings{Endpoint: "foo.googleapis.com"})
if err != nil {
t.Fatalf("got %v, wanted no error", err)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/settings.go
Expand Up @@ -23,13 +23,15 @@ type DialSettings struct {
DefaultEndpoint string
DefaultMTLSEndpoint string
Scopes []string
DefaultScopes []string
TokenSource oauth2.TokenSource
Credentials *google.Credentials
CredentialsFile string // if set, Token Source is ignored.
CredentialsJSON []byte
UserAgent string
APIKey string
Audiences []string
DefaultAudience string
HTTPClient *http.Client
GRPCDialOpts []grpc.DialOption
GRPCConn *grpc.ClientConn
Expand All @@ -49,6 +51,15 @@ type DialSettings struct {
RequestReason string
}

// GetScopes returns the user-provided scopes, if set, or else falls back to the
// default scopes.
func (ds *DialSettings) GetScopes() []string {
if len(ds.Scopes) > 0 {
return ds.Scopes
}
return ds.DefaultScopes
}

// Validate reports an error if ds is invalid.
func (ds *DialSettings) Validate() error {
if ds.SkipValidation {
Expand Down
29 changes: 29 additions & 0 deletions option/internaloption/internaloption.go
Expand Up @@ -65,3 +65,32 @@ type enableDirectPath bool
func (e enableDirectPath) Apply(o *internal.DialSettings) {
o.EnableDirectPath = bool(e)
}

// WithDefaultAudience returns a ClientOption that specifies a default audience
// to be used as the audience field ("aud") for the JWT token authentication.
//
// It should only be used internally by generated clients.
func WithDefaultAudience(audience string) option.ClientOption {
return withDefaultAudience(audience)
}

type withDefaultAudience string

func (w withDefaultAudience) Apply(o *internal.DialSettings) {
o.DefaultAudience = string(w)
}

// WithDefaultScopes returns a ClientOption that overrides the default OAuth2
// scopes to be used for a service.
//
// It should only be used internally by generated clients.
func WithDefaultScopes(scope ...string) option.ClientOption {
return withDefaultScopes(scope)
}

type withDefaultScopes []string

func (w withDefaultScopes) Apply(o *internal.DialSettings) {
o.DefaultScopes = make([]string, len(w))
copy(o.DefaultScopes, w)
}

0 comments on commit 1a7550f

Please sign in to comment.