golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/iapclient/iapclient.go (about) 1 // Copyright 2022 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Package iapclient enables programmatic access to IAP-secured services. See 6 // https://cloud.google.com/iap/docs/authentication-howto. 7 // 8 // Login will be done as necessary using offline browser-based authentication, 9 // similarly to gcloud auth login. Credentials will be stored in the user's 10 // config directory. 11 package iapclient 12 13 import ( 14 "context" 15 "crypto/tls" 16 "encoding/json" 17 "fmt" 18 "io" 19 "net/http" 20 "net/url" 21 "os" 22 "path/filepath" 23 "strings" 24 "time" 25 26 "cloud.google.com/go/compute/metadata" 27 "golang.org/x/oauth2" 28 "golang.org/x/oauth2/google" 29 "google.golang.org/api/idtoken" 30 "google.golang.org/grpc" 31 "google.golang.org/grpc/credentials" 32 "google.golang.org/grpc/credentials/oauth" 33 ) 34 35 var gomoteConfig = &oauth2.Config{ 36 // Gomote client ID and secret. 37 ClientID: "872405196845-odamr0j3kona7rp7fima6h4ummnd078t.apps.googleusercontent.com", 38 ClientSecret: "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", 39 Endpoint: google.Endpoint, 40 Scopes: []string{"email openid profile"}, 41 } 42 43 func login(ctx context.Context) (*oauth2.Token, error) { 44 resp, err := http.PostForm("https://oauth2.googleapis.com/device/code", url.Values{ 45 "client_id": []string{gomoteConfig.ClientID}, 46 "scope": gomoteConfig.Scopes, 47 }) 48 if err != nil { 49 return nil, err 50 } 51 if resp.StatusCode != http.StatusOK { 52 return nil, fmt.Errorf("unexpected status on device code request %v", resp.Status) 53 } 54 codeResp := &codeResponse{} 55 if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil { 56 return nil, err 57 } 58 fmt.Printf("Please visit %v in your browser and enter verification code:\n %v\n", codeResp.VerificationURL, codeResp.UserCode) 59 60 tick := time.NewTicker(time.Duration(codeResp.Interval) * time.Second) 61 defer tick.Stop() 62 63 refresh := &oauth2.Token{} 64 outer: 65 for { 66 select { 67 case <-ctx.Done(): 68 return nil, ctx.Err() 69 case <-tick.C: 70 resp, err := http.PostForm("https://oauth2.googleapis.com/token", url.Values{ 71 "client_id": []string{gomoteConfig.ClientID}, 72 "client_secret": []string{gomoteConfig.ClientSecret}, 73 "device_code": []string{codeResp.DeviceCode}, 74 "grant_type": []string{"urn:ietf:params:oauth:grant-type:device_code"}, 75 }) 76 if err != nil { 77 return nil, err 78 } 79 if resp.StatusCode == http.StatusPreconditionRequired { 80 continue 81 } 82 if resp.StatusCode != http.StatusOK { 83 return nil, fmt.Errorf("unexpected status on token request %v", resp.Status) 84 } 85 if err := json.NewDecoder(resp.Body).Decode(refresh); err != nil { 86 return nil, err 87 } 88 break outer 89 } 90 } 91 92 if err := writeToken(refresh); err != nil { 93 fmt.Fprintf(os.Stderr, "warning: could not save token, you will be asked to log in again: %v\n", err) 94 } 95 return refresh, nil 96 } 97 98 // https://developers.google.com/identity/protocols/oauth2/limited-input-device#step-2:-handle-the-authorization-server-response 99 type codeResponse struct { 100 DeviceCode string `json:"device_code"` 101 Interval int `json:"interval"` 102 UserCode string `json:"user_code"` 103 VerificationURL string `json:"verification_url"` 104 } 105 106 func writeToken(refresh *oauth2.Token) error { 107 configDir, err := os.UserConfigDir() 108 if err != nil { 109 return err 110 } 111 refreshBytes, err := json.Marshal(refresh) 112 if err != nil { 113 return err 114 } 115 err = os.MkdirAll(filepath.Join(configDir, "gomote"), 0755) 116 if err != nil { 117 return err 118 } 119 return os.WriteFile(filepath.Join(configDir, "gomote/iap-refresh-tv-token"), refreshBytes, 0600) 120 } 121 122 func cachedToken() (*oauth2.Token, error) { 123 configDir, err := os.UserConfigDir() 124 if err != nil { 125 return nil, err 126 } 127 refreshBytes, err := os.ReadFile(filepath.Join(configDir, "gomote/iap-refresh-tv-token")) 128 if err != nil { 129 if os.IsNotExist(err) { 130 return nil, nil 131 } 132 return nil, err 133 } 134 var refreshToken oauth2.Token 135 if err := json.Unmarshal(refreshBytes, &refreshToken); err != nil { 136 return nil, err 137 } 138 if !refreshToken.Valid() { 139 return nil, nil 140 } 141 return &refreshToken, nil 142 } 143 144 // TokenSource returns a TokenSource that can be used to access Go's 145 // IAP-protected sites. It will prompt for login if necessary. 146 func TokenSource(ctx context.Context) (oauth2.TokenSource, error) { 147 const audience = "872405196845-b6fu2qpi0fehdssmc8qo47h2u3cepi0e.apps.googleusercontent.com" // Go build IAP client ID. 148 149 if metadata.OnGCE() { 150 if project, err := metadata.ProjectID(); err == nil && (project == "symbolic-datum-552" || project == "go-security-trybots") { 151 return idtoken.NewTokenSource(ctx, audience) 152 } 153 } 154 155 refresh, err := cachedToken() 156 if err != nil { 157 return nil, err 158 } 159 if refresh == nil { 160 refresh, err = login(ctx) 161 if err != nil { 162 return nil, err 163 } 164 } 165 tokenSource := oauth2.ReuseTokenSource(nil, &jwtTokenSource{gomoteConfig, audience, refresh}) 166 // Eagerly request a token to verify we're good. The source will cache it. 167 if _, err := tokenSource.Token(); err != nil { 168 return nil, err 169 } 170 return tokenSource, nil 171 } 172 173 // HTTPClient returns an http.Client that can be used to access Go's 174 // IAP-protected sites. It will prompt for login if necessary. 175 func HTTPClient(ctx context.Context) (*http.Client, error) { 176 ts, err := TokenSource(ctx) 177 if err != nil { 178 return nil, err 179 } 180 return oauth2.NewClient(ctx, ts), nil 181 } 182 183 // GRPCClient returns a *gprc.ClientConn that can access Go's IAP-protected 184 // servers. It will prompt for login if necessary. 185 func GRPCClient(ctx context.Context, addr string) (*grpc.ClientConn, error) { 186 ts, err := TokenSource(ctx) 187 if err != nil { 188 return nil, err 189 } 190 opts := []grpc.DialOption{ 191 grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{InsecureSkipVerify: strings.HasPrefix(addr, "localhost:")})), 192 grpc.WithDefaultCallOptions(grpc.PerRPCCredentials(oauth.TokenSource{TokenSource: ts})), 193 grpc.WithBlock(), 194 } 195 return grpc.DialContext(ctx, addr, opts...) 196 } 197 198 type jwtTokenSource struct { 199 conf *oauth2.Config 200 audience string 201 refresh *oauth2.Token 202 } 203 204 // Token exchanges a refresh token for a JWT that works with IAP. As of writing, there 205 // isn't anything to do this in the oauth2 library or google.golang.org/api/idtoken. 206 func (s *jwtTokenSource) Token() (*oauth2.Token, error) { 207 resp, err := http.PostForm(s.conf.Endpoint.TokenURL, url.Values{ 208 "client_id": []string{s.conf.ClientID}, 209 "client_secret": []string{s.conf.ClientSecret}, 210 "refresh_token": []string{s.refresh.RefreshToken}, 211 "grant_type": []string{"refresh_token"}, 212 "audience": []string{s.audience}, 213 }) 214 if err != nil { 215 return nil, err 216 } 217 defer resp.Body.Close() 218 if resp.StatusCode != http.StatusOK { 219 body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) 220 return nil, fmt.Errorf("IAP token exchange failed: status %v, body %q", resp.Status, body) 221 } 222 body, err := io.ReadAll(resp.Body) 223 if err != nil { 224 return nil, err 225 } 226 var token jwtTokenJSON 227 if err := json.Unmarshal(body, &token); err != nil { 228 return nil, err 229 } 230 return &oauth2.Token{ 231 TokenType: "Bearer", 232 AccessToken: token.IDToken, 233 }, nil 234 } 235 236 type jwtTokenJSON struct { 237 IDToken string `json:"id_token"` 238 }