Skip to content
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

feat(auth): add impersonate package #8578

Merged
merged 5 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions auth/impersonate/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package impersonate is used to impersonate Google Credentials. If you need
// to impersonate some credentials to use with a client library see
// [NewCredentialTokenProvider]. If instead you would like to create an Open
// Connect ID token using impersonation see [NewIDTokenProvider].
//
codyoss marked this conversation as resolved.
Show resolved Hide resolved
// # Required IAM roles
//
// In order to impersonate a service account the base service account must have
// the Service Account Token Creator role, roles/iam.serviceAccountTokenCreator,
// on the service account being impersonated. See
// https://cloud.google.com/iam/docs/understanding-service-accounts.
//
// Optionally, delegates can be used during impersonation if the base service
// account lacks the token creator role on the target. When using delegates,
// each service account must be granted roles/iam.serviceAccountTokenCreator
// on the next service account in the delgation chain.
//
// For example, if a base service account of SA1 is trying to impersonate target
// service account SA2 while using delegate service accounts DSA1 and DSA2,
// the following must be true:
//
// 1. Base service account SA1 has roles/iam.serviceAccountTokenCreator on
// DSA1.
// 2. DSA1 has roles/iam.serviceAccountTokenCreator on DSA2.
// 3. DSA2 has roles/iam.serviceAccountTokenCreator on target SA2.
//
// If the base credential is an authorized user and not a service account, or if
// the option WithQuotaProject is set, the target service account must have a
// role that grants the serviceusage.services.use permission such as
// roles/serviceusage.serviceUsageConsumer.
package impersonate
85 changes: 85 additions & 0 deletions auth/impersonate/example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package impersonate_test

import (
"log"

"cloud.google.com/go/auth/httptransport"
"cloud.google.com/go/auth/impersonate"
)

func ExampleNewCredentialTokenProvider_serviceAccount() {
quartzmo marked this conversation as resolved.
Show resolved Hide resolved
// Base credentials sourced from ADC or provided client options
tp, err := impersonate.NewCredentialTokenProvider(&impersonate.CredentialOptions{
TargetPrincipal: "[email protected]",
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
// Optionally supply delegates
Delegates: []string{"[email protected]"},
})
if err != nil {
log.Fatal(err)
}

// TODO(codyoss): link to option once it exists.

// Use this TokenProvider with a client library
codyoss marked this conversation as resolved.
Show resolved Hide resolved
_ = tp
}

func ExampleNewCredentialTokenProvider_adminUser() {
codyoss marked this conversation as resolved.
Show resolved Hide resolved
// Base credentials sourced from ADC or provided client options
tp, err := impersonate.NewCredentialTokenProvider(&impersonate.CredentialOptions{
TargetPrincipal: "[email protected]",
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
// Optionally supply delegates
Delegates: []string{"[email protected]"},
// Specify user to impersonate
Subject: "[email protected]",
})
if err != nil {
log.Fatal(err)
}

// Use this TokenProvider with a client library like
// "google.golang.org/api/admin/directory/v1"
_ = tp
}

func ExampleNewIDTokenProvider() {
// Base credentials sourced from ADC or provided client options.
tp, err := impersonate.NewIDTokenProvider(&impersonate.IDTokenOptions{
Audience: "http://example.com/",
TargetPrincipal: "[email protected]",
IncludeEmail: true,
// Optionally supply delegates.
Delegates: []string{"[email protected]"},
})
if err != nil {
log.Fatal(err)
}

// Create an authenticated client
client, err := httptransport.NewClient(&httptransport.Options{
TokenProvider: tp,
})
if err != nil {
log.Fatal(err)
}

// Use your client that is authenticated with impersonated credentials to
// make requests.
client.Get("http://example.com/")
}
171 changes: 171 additions & 0 deletions auth/impersonate/idtoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package impersonate

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"

"cloud.google.com/go/auth"
"cloud.google.com/go/auth/detect"
"cloud.google.com/go/auth/httptransport"
"cloud.google.com/go/auth/internal"
)

// IDTokenOptions for generating an impersonated ID token.
type IDTokenOptions struct {
// Audience is the `aud` field for the token, such as an API endpoint the
// token will grant access to. Required.
Audience string
// TargetPrincipal is the email address of the service account to
// impersonate. Required.
TargetPrincipal string
// IncludeEmail includes the target service account's email in the token.
// The resulting token will include both an `email` and `email_verified`
// claim. Optional.
IncludeEmail bool
// Delegates are the ordered service account email addresses in a delegation
// chain. Each service account must be granted
// roles/iam.serviceAccountTokenCreator on the next service account in the
// chain. Optional.
Delegates []string

// TokenProvider is the provider of the credentials used to fetch the ID
// token. If not provided, and a Client is also not provided, base
// credentials will try to be detected from the environment. Optional.
TokenProvider auth.TokenProvider
// Client configures the underlying client used to make network requests
// when fetching tokens. If provided the client should provide it's own
// base credentials at call time. Optional.
Client *http.Client
}

var (
defaultAud = "https://iamcredentials.googleapis.com/"
defaultScope = "https://www.googleapis.com/auth/cloud-platform"
)

// NewIDTokenProvider creates an impersonated
// [cloud.google.com/go/auth/TokenProvider] that returns ID tokens configured
// with the provided config and using credentials loaded from Application
// Default Credentials as the base credentials if not provided with the opts.
// The tokens produced are valid for one hour and are automatically refreshed.
codyoss marked this conversation as resolved.
Show resolved Hide resolved
func NewIDTokenProvider(opts *IDTokenOptions) (auth.TokenProvider, error) {
if opts == nil {
return nil, fmt.Errorf("impersonate: opts must be provided")
}
if opts.Audience == "" {
return nil, fmt.Errorf("impersonate: an audience must be provided")
}
if opts.TargetPrincipal == "" {
return nil, fmt.Errorf("impersonate: a target service account must be provided")
}

var client *http.Client
if opts.Client == nil && opts.TokenProvider == nil {
var err error
client, err = httptransport.NewClient(&httptransport.Options{
DetectOpts: &detect.Options{
Audience: defaultAud,
Scopes: []string{defaultScope},
},
})
if err != nil {
return nil, err
}
} else if opts.Client == nil {
client = internal.CloneDefaultClient()
if err := httptransport.AddAuthorizationMiddleware(client, opts.TokenProvider); err != nil {
return nil, err
}
} else {
client = opts.Client
}

itp := impersonatedIDTokenProvider{
client: client,
targetPrincipal: opts.TargetPrincipal,
audience: opts.Audience,
includeEmail: opts.IncludeEmail,
}
for _, v := range opts.Delegates {
itp.delegates = append(itp.delegates, formatIAMServiceAccountName(v))
}
return auth.NewCachedTokenProvider(itp, nil), nil
}

type generateIDTokenRequest struct {
Audience string `json:"audience"`
IncludeEmail bool `json:"includeEmail"`
Delegates []string `json:"delegates,omitempty"`
}

type generateIDTokenResponse struct {
Token string `json:"token"`
}

type impersonatedIDTokenProvider struct {
client *http.Client

targetPrincipal string
audience string
includeEmail bool
delegates []string
}

func (i impersonatedIDTokenProvider) Token(ctx context.Context) (*auth.Token, error) {
genIDTokenReq := generateIDTokenRequest{
Audience: i.audience,
IncludeEmail: i.includeEmail,
Delegates: i.delegates,
}
bodyBytes, err := json.Marshal(genIDTokenReq)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to marshal request: %w", err)
}

url := fmt.Sprintf("%s/v1/%s:generateIdToken", iamCredentialsEndpoint, formatIAMServiceAccountName(i.targetPrincipal))
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("impersonate: unable to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := i.client.Do(req)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to generate ID token: %w", err)
}
defer resp.Body.Close()
body, err := internal.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("impersonate: unable to read body: %w", err)
}
if c := resp.StatusCode; c < 200 || c > 299 {
return nil, fmt.Errorf("impersonate: status code %d: %s", c, body)
}

var generateIDTokenResp generateIDTokenResponse
if err := json.Unmarshal(body, &generateIDTokenResp); err != nil {
return nil, fmt.Errorf("impersonate: unable to parse response: %w", err)
}
return &auth.Token{
Value: generateIDTokenResp.Token,
// Generated ID tokens are good for one hour.
Expiry: time.Now().Add(1 * time.Hour),
}, nil
}
93 changes: 93 additions & 0 deletions auth/impersonate/idtoken_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package impersonate

import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
)

func TestIDTokenSource(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
aud string
targetPrincipal string
wantErr bool
}{
{
name: "missing aud",
targetPrincipal: "[email protected]",
wantErr: true,
},
{
name: "missing targetPrincipal",
aud: "http://example.com/",
wantErr: true,
},
{
name: "works",
aud: "http://example.com/",
targetPrincipal: "[email protected]",
wantErr: false,
},
}

for _, tt := range tests {
name := tt.name
t.Run(name, func(t *testing.T) {
idTok := "id-token"
client := &http.Client{
Transport: RoundTripFn(func(req *http.Request) *http.Response {
codyoss marked this conversation as resolved.
Show resolved Hide resolved
resp := generateIDTokenResponse{
Token: idTok,
}
b, err := json.Marshal(&resp)
if err != nil {
t.Fatalf("unable to marshal response: %v", err)
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(b)),
Header: make(http.Header),
}
}),
}
tp, err := NewIDTokenProvider(&IDTokenOptions{
Audience: tt.aud,
TargetPrincipal: tt.targetPrincipal,
Client: client,
},
)
if tt.wantErr && err != nil {
return
}
if err != nil {
t.Fatal(err)
}
tok, err := tp.Token(ctx)
if err != nil {
t.Fatal(err)
}
if tok.Value != idTok {
t.Fatalf("got %q, want %q", tok.Value, idTok)
}
})
}
}
Loading