package ghappman import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "time" credentials "cloud.google.com/go/iam/credentials/apiv1" "cloud.google.com/go/iam/credentials/apiv1/credentialspb" "golang.org/x/oauth2" ) // Client embeds an http.Client for accessing the ghappman service. It provides auth // via an OIDC token type Client struct { *http.Client } // CreateGitHubApp prints a prompt to start the app creation flow. It blocks until the user creates the app // or a timeout occurs. A '*' passed as installSuffix will cause a random suffix to be generated. func CreateGitHubApp(ctx context.Context, installSuffix string) (*AppConfig, error) { b, err := generatePassword() if err != nil { return nil, err } userKey := base64.StdEncoding.EncodeToString(b) // TODO: installSuffix will be generated before repo creation // so repo and app have the same suffix. address when intergrating // with full e2e bootstrap flow client, err := NewIAPClient(ctx) if err != nil { return nil, err } if installSuffix == "*" { installSuffix = RandAN(8) } req, err := json.Marshal(&CreateAppReq{UserKey: userKey, InstallSuffix: installSuffix}) if err != nil { return nil, err } res, err := client.Post( fmt.Sprintf("%s/%s", ghamURL, "new-app"), "application/json", bytes.NewReader(req), ) if err != nil { return nil, err } resByte, _ := io.ReadAll(res.Body) createAppRes := &CreateAppRes{} err = json.Unmarshal(resByte, createAppRes) if err != nil { return nil, err } fmt.Println("=== beginning github app creation") fmt.Printf("visit %v to initiate app creation\n", createAppRes.CreateURL) waitTimeout, cancel := context.WithTimeout(ctx, createAppRes.ExpiresIn) app, err := client.waitForAppInstallation(waitTimeout, createAppRes.State, userKey) cancel() if err != nil { return nil, err } fmt.Printf("app '%s' has been created, but you may still need to finish the installation dialog\n", app.Name) // TODO: if this is integrated with env bootstrapping, repo creation, pause until the app // installation for the new (or existing) repo is complete return app, nil } // NewIAPClient creates an http.Client configured to include Bearer token Auth headers. It uses // local, default gcloud credentials to obtain an ID token for accessing the given IAP Web App // (audience) via a service account (name). See: // https://cloud.google.com/iap/docs/authentication-howto#obtaining_an_oidc_token_in_all_other_cases func NewIAPClient(ctx context.Context) (*Client, error) { c, err := credentials.NewIamCredentialsClient(ctx) if err != nil { return nil, fmt.Errorf("credentials.NewIamCredentialsClient: %v", err) } // Name is the service account to impersonate // Audience is the OAuth2 client id for the IAP enabled web service req := &credentialspb.GenerateIdTokenRequest{ Name: "projects/-/serviceAccounts/114355029064852695579", Audience: "886862789596-v6bsskt8f7o4v0ql32trt354h7plcta7.apps.googleusercontent.com", IncludeEmail: true, } resp, err := c.GenerateIdToken(ctx, req) if err != nil { return nil, fmt.Errorf("GenerateIdToken: %v", err) } // TODO: implement a ReuseTokenSource that obtains the idtoken once and caches it // oauth2.ReuseTokenSource(t *oauth2.Token, src oauth2.TokenSource). Maybe not necessary // for low frequency calls. Id tokens expire quickly anyways ts := oauth2.StaticTokenSource(&oauth2.Token{ TokenType: "Bearer", AccessToken: resp.Token, }) client := oauth2.NewClient(ctx, ts) return &Client{client}, nil } func generatePassword() ([]byte, error) { n := 32 b := make([]byte, n) _, err := rand.Read(b) if err != nil { return nil, err } return b, nil } func (c *Client) waitForAppInstallation(ctx context.Context, state string, key string) (*AppConfig, error) { interval := time.Second * 5 for { select { case <-ctx.Done(): if ctx.Err() == context.DeadlineExceeded { return nil, fmt.Errorf("waiting for app creation timed out") } case <-time.After(interval): retryable, app := c.makeAppInstallationRequest(state, key) if !retryable && app != nil { // TODO: the app response here contains the private key. to actually run the chariot app // in a dev environment, we still need to get that secret into the env's project's secret manager return app, nil } else if !retryable && app == nil { fmt.Println("something went wrong.") os.Exit(1) } } } } func (c *Client) makeAppInstallationRequest(state string, key string) (bool, *AppConfig) { url := fmt.Sprintf("%s/new-app/%s/app", ghamURL, state) appReq, err := http.NewRequest("GET", url, nil) if err != nil { fmt.Printf("failed to create request. err: %v\n", err.Error()) os.Exit(1) } appReq.Header.Add("X-Appkey", key) appRes, err := c.Do(appReq) if err != nil { fmt.Printf("failed to make request. err: %v\n", err.Error()) os.Exit(1) } switch appRes.StatusCode { case http.StatusBadRequest: return true, nil case http.StatusNotFound: return false, nil } appResBody, _ := io.ReadAll(appRes.Body) _ = appRes.Body.Close() app := &AppConfig{} err = json.Unmarshal(appResBody, app) if err != nil { fmt.Printf("failed to parse response. err: %v\nresponse: %s\n", err.Error(), appResBody) os.Exit(1) } return false, app }