Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions examples/app_token/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@ data "github_app_token" "this" {
installation_id = var.installation_id
pem_file = file(var.pem_file_path)
}

data "github_app_token" "scoped" {
count = length(var.repositories) > 0 ? 1 : 0
app_id = var.app_id
installation_id = var.installation_id
pem_file = file(var.pem_file_path)
repositories = var.repositories
}
4 changes: 4 additions & 0 deletions examples/app_token/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ variable "installation_id" {
variable "pem_file_path" {
type = string
}

variable "repositories" {
type = list(string)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make sure all your files have valid line endings.

31 changes: 28 additions & 3 deletions github/apps.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package github

import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
Expand All @@ -18,27 +19,51 @@ import (
// GenerateOAuthTokenFromApp generates a GitHub OAuth access token from a set of valid GitHub App credentials.
// The returned token can be used to interact with both GitHub's REST and GraphQL APIs.
func GenerateOAuthTokenFromApp(apiURL *url.URL, appID, appInstallationID, pemData string) (string, error) {
return GenerateOAuthTokenFromAppWithRepositories(apiURL, appID, appInstallationID, pemData, nil)
}

// GenerateOAuthTokenFromAppWithRepositories generates a GitHub OAuth access token from a set of valid GitHub App credentials,
// optionally scoped to specific repositories. If repositories is nil or empty, the token will have access to all
// repositories the installation has access to.
func GenerateOAuthTokenFromAppWithRepositories(apiURL *url.URL, appID, appInstallationID, pemData string, repositories []string) (string, error) {
appJWT, err := generateAppJWT(appID, time.Now(), []byte(pemData))
if err != nil {
return "", err
}

token, err := getInstallationAccessToken(apiURL, appJWT, appInstallationID)
token, err := getInstallationAccessToken(apiURL, appJWT, appInstallationID, repositories)
if err != nil {
return "", err
}

return token, nil
}

func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string) (string, error) {
req, err := http.NewRequest(http.MethodPost, apiURL.JoinPath("app/installations", installationID, "access_tokens").String(), nil)
func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string, repositories []string) (string, error) {
var reqBody io.Reader
if len(repositories) > 0 {
body := struct {
Repositories []string `json:"repositories"`
}{
Repositories: repositories,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", err
}
reqBody = bytes.NewReader(bodyBytes)
}

req, err := http.NewRequest(http.MethodPost, apiURL.JoinPath("app/installations", installationID, "access_tokens").String(), reqBody)
if err != nil {
return "", err
}

req.Header.Add("Accept", "application/vnd.github.v3+json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
if len(repositories) > 0 {
req.Header.Add("Content-Type", "application/json")
}

res, err := http.DefaultClient.Do(req)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion github/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func TestGetInstallationAccessToken(t *testing.T) {
t.Fatalf("could not parse test server url")
}

accessToken, err := getInstallationAccessToken(u, fakeJWT, testGitHubAppInstallationID)
accessToken, err := getInstallationAccessToken(u, fakeJWT, testGitHubAppInstallationID, nil)
if err != nil {
t.Logf("Unexpected error: %s", err)
t.Fail()
Expand Down
15 changes: 14 additions & 1 deletion github/data_source_github_app_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ func dataSourceGithubAppToken() *schema.Resource {
Required: true,
Description: descriptions["app_auth.pem_file"],
},
"repositories": {
Type: schema.TypeList,
Optional: true,
Description: "List of repository names to scope the token to. If not specified, the token will have access to all repositories the installation has access to.",
Elem: &schema.Schema{Type: schema.TypeString},
},
"token": {
Type: schema.TypeString,
Computed: true,
Expand All @@ -50,7 +56,14 @@ func dataSourceGithubAppTokenRead(d *schema.ResourceData, meta any) error {
// actual new line character before decoding.
pemFile = strings.ReplaceAll(pemFile, `\n`, "\n")

token, err := GenerateOAuthTokenFromApp(meta.(*Owner).v3client.BaseURL, appID, installationID, pemFile)
var repositories []string
if v, ok := d.GetOk("repositories"); ok {
for _, repo := range v.([]any) {
repositories = append(repositories, repo.(string))
}
}
Comment on lines +59 to +64
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var repositories []string
if v, ok := d.GetOk("repositories"); ok {
for _, repo := range v.([]any) {
repositories = append(repositories, repo.(string))
}
}
var repos []string
if v, ok := d.GetOk("repositories"); ok {
if a, ok := v.([]any); ok {
repos := make([]string, len(a))
for i, r := range a {
if repo, ok := r.(string); ok {
repos[i] = repo
}
}
}
}

This would be a safer pattern.


token, err := GenerateOAuthTokenFromAppWithRepositories(meta.(*Owner).v3client.BaseURL, appID, installationID, pemFile, repositories)
if err != nil {
return err
}
Expand Down
66 changes: 66 additions & 0 deletions github/data_source_github_app_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestAccGithubAppTokenDataSource(t *testing.T) {
"app_id": {Type: schema.TypeString},
"installation_id": {Type: schema.TypeString},
"pem_file": {Type: schema.TypeString},
"repositories": {Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}},
"token": {Type: schema.TypeString},
}

Expand All @@ -72,4 +73,69 @@ func TestAccGithubAppTokenDataSource(t *testing.T) {
t.Fail()
}
})

t.Run("creates a application token scoped to repositories", func(t *testing.T) {
expectedAccessToken := "ghs_scoped_token_12345"

owner := "test-owner"

pemData, err := os.ReadFile(testGitHubAppPrivateKeyFile)
if err != nil {
t.Logf("Unexpected error: %s", err)
t.Fail()
}

ts := githubApiMock([]*mockResponse{
{
ExpectedUri: fmt.Sprintf("/app/installations/%s/access_tokens", testGitHubAppInstallationID),
ExpectedHeaders: map[string]string{
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json",
},
ExpectedBody: []byte(`{"repositories":["repo1","repo2"]}`),
ResponseBody: fmt.Sprintf(`{"token": "%s"}`, expectedAccessToken),
StatusCode: 201,
},
})
defer ts.Close()

httpCl := http.DefaultClient
httpCl.Transport = http.DefaultTransport

client := github.NewClient(httpCl)
u, _ := url.Parse(ts.URL + "/")
client.BaseURL = u

meta := &Owner{
name: owner,
v3client: client,
}

testSchema := map[string]*schema.Schema{
"app_id": {Type: schema.TypeString},
"installation_id": {Type: schema.TypeString},
"pem_file": {Type: schema.TypeString},
"repositories": {Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}},
"token": {Type: schema.TypeString},
}

schema := schema.TestResourceDataRaw(t, testSchema, map[string]any{
"app_id": testGitHubAppID,
"installation_id": testGitHubAppInstallationID,
"pem_file": string(pemData),
"repositories": []any{"repo1", "repo2"},
"token": "",
})

err = dataSourceGithubAppTokenRead(schema, meta)
if err != nil {
t.Logf("Unexpected error: %s", err)
t.Fail()
}

if schema.Get("token") != expectedAccessToken {
t.Logf("Expected %s, got %s", expectedAccessToken, schema.Get("token"))
t.Fail()
}
})
}
13 changes: 13 additions & 0 deletions website/docs/d/app_token.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ data "github_app_token" "this" {
}
```

### Scoped to specific repositories

```hcl
data "github_app_token" "scoped" {
app_id = "123456"
installation_id = "78910"
pem_file = file("foo/bar.pem")
repositories = ["my-repo", "another-repo"]
}
```

## Argument Reference

The following arguments are supported:
Expand All @@ -29,6 +40,8 @@ The following arguments are supported:

* `pem_file` - (Required) This is the contents of the GitHub App private key PEM file.

* `repositories` - (Optional) List of repository names to scope the token to. If not specified, the token will have access to all repositories the installation has access to.

## Attribute Reference

The following additional attributes are exported:
Expand Down