blob: 4107329c37c06341a16ddd1166060244a26e8fb5 [file] [log] [blame]
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package iapclient enables programmatic access to IAP-secured services. See
// https://cloud.google.com/iap/docs/authentication-howto.
//
// Login will be done as necessary using offline browser-based authentication,
// similarly to gcloud auth login. Credentials will be stored in the user's
// config directory.
package iapclient
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"cloud.google.com/go/compute/metadata"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/idtoken"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/oauth"
)
var gomoteConfig = &oauth2.Config{
// Gomote client ID and secret.
ClientID: "872405196845-odamr0j3kona7rp7fima6h4ummnd078t.apps.googleusercontent.com",
ClientSecret: "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",
Endpoint: google.Endpoint,
Scopes: []string{"email openid profile"},
}
func login(ctx context.Context) (*oauth2.Token, error) {
resp, err := http.PostForm("https://oauth2.googleapis.com/device/code", url.Values{
"client_id": []string{gomoteConfig.ClientID},
"scope": gomoteConfig.Scopes,
})
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status on device code request %v", resp.Status)
}
codeResp := &codeResponse{}
if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil {
return nil, err
}
fmt.Printf("Please visit %v in your browser and enter verification code:\n %v\n", codeResp.VerificationURL, codeResp.UserCode)
tick := time.NewTicker(time.Duration(codeResp.Interval) * time.Second)
defer tick.Stop()
refresh := &oauth2.Token{}
outer:
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-tick.C:
resp, err := http.PostForm("https://oauth2.googleapis.com/token", url.Values{
"client_id": []string{gomoteConfig.ClientID},
"client_secret": []string{gomoteConfig.ClientSecret},
"device_code": []string{codeResp.DeviceCode},
"grant_type": []string{"urn:ietf:params:oauth:grant-type:device_code"},
})
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusPreconditionRequired {
continue
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status on token request %v", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(refresh); err != nil {
return nil, err
}
break outer
}
}
if err := writeToken(refresh); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save token, you will be asked to log in again: %v\n", err)
}
return refresh, nil
}
// https://developers.google.com/identity/protocols/oauth2/limited-input-device#step-2:-handle-the-authorization-server-response
type codeResponse struct {
DeviceCode string `json:"device_code"`
Interval int `json:"interval"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
}
const (
configSubDir = "gomote"
tokenFile = "iap-refresh-tv-token"
)
func writeToken(refresh *oauth2.Token) error {
configDir, err := os.UserConfigDir()
if err != nil {
return err
}
refreshBytes, err := json.Marshal(refresh)
if err != nil {
return err
}
err = os.MkdirAll(filepath.Join(configDir, configSubDir), 0755)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(configDir, configSubDir, tokenFile), refreshBytes, 0600)
}
func removeToken() error {
configDir, err := os.UserConfigDir()
if err != nil {
return err
}
if err := os.Remove(filepath.Join(configDir, configSubDir, tokenFile)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func cachedToken() (*oauth2.Token, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
refreshBytes, err := os.ReadFile(filepath.Join(configDir, configSubDir, tokenFile))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var refreshToken oauth2.Token
if err := json.Unmarshal(refreshBytes, &refreshToken); err != nil {
return nil, err
}
if !refreshToken.Valid() {
return nil, nil
}
return &refreshToken, nil
}
// TokenSource returns a TokenSource that can be used to access Go's
// IAP-protected sites. It will delete any existing authentication token
// credentials and prompt for login.
func TokenSourceForceLogin(ctx context.Context) (oauth2.TokenSource, error) {
if err := removeToken(); err != nil {
return nil, fmt.Errorf("failed to delete existing token file: %s", err)
}
return TokenSource(ctx)
}
// TokenSource returns a TokenSource that can be used to access Go's
// IAP-protected sites. It will prompt for login if necessary.
func TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
const audience = "872405196845-b6fu2qpi0fehdssmc8qo47h2u3cepi0e.apps.googleusercontent.com" // Go build IAP client ID.
if metadata.OnGCE() {
if project, err := metadata.ProjectID(); err == nil && (project == "symbolic-datum-552" || project == "go-security-trybots") {
return idtoken.NewTokenSource(ctx, audience)
}
}
refresh, err := cachedToken()
if err != nil {
return nil, err
}
if refresh == nil {
refresh, err = login(ctx)
if err != nil {
return nil, err
}
}
tokenSource := oauth2.ReuseTokenSource(nil, &jwtTokenSource{gomoteConfig, audience, refresh})
// Eagerly request a token to verify we're good. The source will cache it.
if _, err := tokenSource.Token(); err != nil {
return nil, err
}
return tokenSource, nil
}
// HTTPClient returns an http.Client that can be used to access Go's
// IAP-protected sites. It will prompt for login if necessary.
func HTTPClient(ctx context.Context) (*http.Client, error) {
ts, err := TokenSource(ctx)
if err != nil {
return nil, err
}
return oauth2.NewClient(ctx, ts), nil
}
// GRPCClient returns a *gprc.ClientConn that can access Go's IAP-protected
// servers. It will prompt for login if necessary.
func GRPCClient(ctx context.Context, addr string) (*grpc.ClientConn, error) {
ts, err := TokenSource(ctx)
if err != nil {
return nil, err
}
opts := []grpc.DialOption{
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{InsecureSkipVerify: strings.HasPrefix(addr, "localhost:")})),
grpc.WithDefaultCallOptions(grpc.PerRPCCredentials(oauth.TokenSource{TokenSource: ts})),
grpc.WithBlock(),
}
return grpc.DialContext(ctx, addr, opts...)
}
type jwtTokenSource struct {
conf *oauth2.Config
audience string
refresh *oauth2.Token
}
// Token exchanges a refresh token for a JWT that works with IAP. As of writing, there
// isn't anything to do this in the oauth2 library or google.golang.org/api/idtoken.
func (s *jwtTokenSource) Token() (*oauth2.Token, error) {
resp, err := http.PostForm(s.conf.Endpoint.TokenURL, url.Values{
"client_id": []string{s.conf.ClientID},
"client_secret": []string{s.conf.ClientSecret},
"refresh_token": []string{s.refresh.RefreshToken},
"grant_type": []string{"refresh_token"},
"audience": []string{s.audience},
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
tokenRespErr := struct {
Err string `json:"error"`
Description string `json:"error_description"`
}{}
err := fmt.Errorf("IAP token exchange failed: status %v, body %q", resp.Status, body)
if unmarshErr := json.Unmarshal(body, &tokenRespErr); unmarshErr == nil && tokenRespErr.Err == "invalid_grant" {
return nil, AuthenticationError{Err: err, Description: tokenRespErr.Description}
}
return nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var token jwtTokenJSON
if err := json.Unmarshal(body, &token); err != nil {
return nil, err
}
return &oauth2.Token{
TokenType: "Bearer",
AccessToken: token.IDToken,
}, nil
}
type jwtTokenJSON struct {
IDToken string `json:"id_token"`
}
// AuthenticationError records an authentication error.
type AuthenticationError struct {
Description string
Err error
}
func (ar AuthenticationError) Error() string { return ar.Description }
func (ar AuthenticationError) Unwrap() error { return ar.Err }