diff --git a/provider/github-app-token/dummy.go b/provider/github-app-token/dummy.go index 43c32489..3c7234e2 100644 --- a/provider/github-app-token/dummy.go +++ b/provider/github-app-token/dummy.go @@ -9,6 +9,12 @@ import ( type githubClientDummy struct{} +func (c *githubClientDummy) GetApp(ctx context.Context) (*github.GetAppResponse, error) { + return &github.GetAppResponse{ + HTMLURL: "https://github.com/shogo82148/actions-github-app-token", + }, nil +} + func (c *githubClientDummy) CreateStatus(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) { if token != "ghs_dummyGitHubToken" || owner != "shogo82148" || repo != "actions-aws-assume-role" || ref != "e3a45c6c16c1464826b36a598ff39e6cc98c4da4" { return nil, &github.ErrUnexpectedStatusCode{StatusCode: http.StatusBadRequest} diff --git a/provider/github-app-token/github-app-token.go b/provider/github-app-token/github-app-token.go index 118e11fd..1dd754ca 100644 --- a/provider/github-app-token/github-app-token.go +++ b/provider/github-app-token/github-app-token.go @@ -19,6 +19,7 @@ import ( ) type githubClient interface { + GetApp(ctx context.Context) (*github.GetAppResponse, error) CreateStatus(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) GetReposInstallation(ctx context.Context, owner, repo string) (*github.GetReposInstallationResponse, error) CreateAppAccessToken(ctx context.Context, installationID uint64, permissions *github.CreateAppAccessTokenRequest) (*github.CreateAppAccessTokenResponse, error) @@ -34,6 +35,7 @@ const ( type Handler struct { github githubClient + app *github.GetAppResponse } func NewHandler() (*Handler, error) { @@ -70,8 +72,15 @@ func NewHandler() (*Handler, error) { if err != nil { return nil, err } + + app, err := c.GetApp(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get the app information, check your configure: %w", err) + } + return &Handler{ github: c, + app: app, }, nil } @@ -149,6 +158,19 @@ func (h *Handler) handle(ctx context.Context, req *requestBody) (*responseBody, } inst, err := h.github.GetReposInstallation(ctx, owner, repo) if err != nil { + var ghErr *github.ErrUnexpectedStatusCode + if errors.As(err, &ghErr) && ghErr.StatusCode == http.StatusNotFound { + // installation not found. + // the user may not install the app. + return nil, &validationError{ + message: fmt.Sprintf( + "Installation not found. "+ + "You need to install the GitHub App to use the action. "+ + "See %s for more detail", + h.app.HTMLURL, + ), + } + } return nil, fmt.Errorf("failed to get resp's installation: %w", err) } token, err := h.github.CreateAppAccessToken(ctx, inst.ID, &github.CreateAppAccessTokenRequest{ diff --git a/provider/github-app-token/github-app-token_test.go b/provider/github-app-token/github-app-token_test.go index cd7027b5..9b8681b2 100644 --- a/provider/github-app-token/github-app-token_test.go +++ b/provider/github-app-token/github-app-token_test.go @@ -10,12 +10,17 @@ import ( ) type githubClientMock struct { + GetAppFunc func(ctx context.Context) (*github.GetAppResponse, error) CreateStatusFunc func(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) GetReposInstallationFunc func(ctx context.Context, owner, repo string) (*github.GetReposInstallationResponse, error) CreateAppAccessTokenFunc func(ctx context.Context, installationID uint64, permissions *github.CreateAppAccessTokenRequest) (*github.CreateAppAccessTokenResponse, error) ValidateAPIURLFunc func(url string) error } +func (c *githubClientMock) GetApp(ctx context.Context) (*github.GetAppResponse, error) { + return c.GetAppFunc(ctx) +} + func (c *githubClientMock) CreateStatus(ctx context.Context, token, owner, repo, ref string, status *github.CreateStatusRequest) (*github.CreateStatusResponse, error) { return c.CreateStatusFunc(ctx, token, owner, repo, ref, status) } diff --git a/provider/github-app-token/github/get_app.go b/provider/github-app-token/github/get_app.go new file mode 100644 index 00000000..8d00dd0a --- /dev/null +++ b/provider/github-app-token/github/get_app.go @@ -0,0 +1,51 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type GetAppResponse struct { + HTMLURL string `json:"html_url"` + + // omit other fields, we don't use them. +} + +// GetApp returns the GitHub App associated with the authentication credentials used. +// https://docs.github.com/en/rest/reference/apps#get-the-authenticated-app +func (c *Client) GetApp(ctx context.Context) (*GetAppResponse, error) { + token, err := c.generateJWT() + if err != nil { + return nil, err + } + + // build the request + u := fmt.Sprintf("%s/app", c.baseURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", githubUserAgent) + req.Header.Set("Authorization", "Bearer "+token) + + // send the request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // parse the response + if resp.StatusCode != http.StatusOK { + return nil, newErrUnexpectedStatusCode(resp) + } + + var ret *GetAppResponse + if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { + return nil, err + } + return ret, nil +} diff --git a/provider/github-app-token/github/get_app_test.go b/provider/github-app-token/github/get_app_test.go new file mode 100644 index 00000000..d9111593 --- /dev/null +++ b/provider/github-app-token/github/get_app_test.go @@ -0,0 +1,94 @@ +package github + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v4" +) + +func TestGetApp(t *testing.T) { + privateKey, err := os.ReadFile("./testdata/id_rsa_for_testing") + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(privateKey) + if block == nil { + t.Fatal("no key found") + } + if block.Type != "RSA PRIVATE KEY" { + t.Fatalf("unsupported key type: %q", block.Type) + } + + rsaPrivateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + + ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("unexpected method: want GET, got %s", r.Method) + } + + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + t.Errorf("unexpected Authorization header: %q", auth) + rw.WriteHeader(http.StatusUnauthorized) + return + } + auth = strings.TrimPrefix(auth, "Bearer ") + token, err := jwt.Parse(auth, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return &rsaPrivateKey.PublicKey, nil + }) + if err != nil { + t.Error(err) + rw.WriteHeader(http.StatusUnauthorized) + return + } + claims := token.Claims.(jwt.MapClaims) + iss := claims["iss"].(string) + if iss != "123456" { + t.Errorf("unexpected issuer: want %q, got %q", "123456", iss) + } + + path := "/app" + if r.URL.Path != path { + t.Errorf("unexpected path: want %q, got %q", path, r.URL.Path) + } + + data, err := os.ReadFile("testdata/app.json") + if err != nil { + panic(err) + } + rw.Header().Set("Content-Type", "application/json") + rw.Header().Set("Content-Length", strconv.Itoa(len(data))) + rw.WriteHeader(http.StatusOK) + rw.Write(data) + })) + defer ts.Close() + + c, err := NewClient(nil, 123456, privateKey) + if err != nil { + t.Fatal(err) + } + c.baseURL = ts.URL + + resp, err := c.GetApp(context.Background()) + if err != nil { + t.Fatal(err) + } + if resp.HTMLURL != "https://github.com/apps/octoapp" { + t.Errorf("unexpected html url: want %q, got %q", "https://github.com/apps/octoapp", resp.HTMLURL) + } +} diff --git a/provider/github-app-token/github/testdata/app.json b/provider/github-app-token/github/testdata/app.json new file mode 100644 index 00000000..b6110f4b --- /dev/null +++ b/provider/github-app-token/github/testdata/app.json @@ -0,0 +1,41 @@ +{ + "id": 1, + "slug": "octoapp", + "node_id": "MDExOkludGVncmF0aW9uMQ==", + "owner": { + "login": "github", + "id": 1, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjE=", + "url": "https://api.github.com/orgs/github", + "repos_url": "https://api.github.com/orgs/github/repos", + "events_url": "https://api.github.com/orgs/github/events", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": true + }, + "name": "Octocat App", + "description": "", + "external_url": "https://example.com", + "html_url": "https://github.com/apps/octoapp", + "created_at": "2017-07-08T16:18:44-04:00", + "updated_at": "2017-07-08T16:18:44-04:00", + "permissions": { + "metadata": "read", + "contents": "read", + "issues": "write", + "single_file": "write" + }, + "events": [ + "push", + "pull_request" + ] +}