From a6adf15f2111679dfc544dce832505d28920c6bf Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Mon, 2 Mar 2026 02:09:35 +0100 Subject: [PATCH 01/10] add top-level app creds and a helper function --- github/provider.go | 58 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..82b781799e 100644 --- a/github/provider.go +++ b/github/provider.go @@ -98,7 +98,7 @@ func Provider() *schema.Provider { Optional: true, MaxItems: 1, Description: descriptions["app_auth"], - // ConflictsWith: []string{"token"}, // TODO: Enable as part of v7. + Deprecated: "Use top-level app_id, app_installation_id, and app_pem_file instead.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -123,6 +123,25 @@ func Provider() *schema.Provider { }, }, }, + "app_id": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_ID", nil), + Description: descriptions["app_auth.id"], + }, + "app_installation_id": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_INSTALLATION_ID", nil), + Description: descriptions["app_auth.installation_id"], + }, + "app_pem_file": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PEM_FILE", nil), + Description: descriptions["app_auth.pem_file"], + }, // https://developer.github.com/guides/traversing-with-pagination/#basics-of-pagination "max_per_page": { Type: schema.TypeInt, @@ -497,6 +516,43 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } } +func getAppCredentials(d *schema.ResourceData) (appID, appInstallationID, appPemFile string) { + // Try top-level fields first + if v, ok := d.Get("app_id").(string); ok && v != "" { + appID = v + } + if v, ok := d.Get("app_installation_id").(string); ok && v != "" { + appInstallationID = v + } + if v, ok := d.Get("app_pem_file").(string); ok && v != "" { + appPemFile = strings.ReplaceAll(v, `\n`, "\n") + } + + // Fall back to app_auth block for any missing values + if appID == "" || appInstallationID == "" || appPemFile == "" { + if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil { + appAuthAttr := appAuth[0].(map[string]any) + if appID == "" { + if v, ok := appAuthAttr["id"].(string); ok && v != "" { + appID = v + } + } + if appInstallationID == "" { + if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" { + appInstallationID = v + } + } + if appPemFile == "" { + if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { + appPemFile = strings.ReplaceAll(v, `\n`, "\n") + } + } + } + } + + return +} + // ghCLIHostFromAPIHost maps an API hostname to the corresponding // gh-CLI --hostname value. For example api.github.com -> github.com // and api..ghe.com -> .ghe.com. From 4c9ede5aa628b292370b09eb0bdd2057e7476d53 Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Mon, 2 Mar 2026 02:35:25 +0100 Subject: [PATCH 02/10] add explicit auth_mode configuration --- github/provider.go | 113 +++++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/github/provider.go b/github/provider.go index 82b781799e..0650ea30d7 100644 --- a/github/provider.go +++ b/github/provider.go @@ -12,17 +12,24 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func Provider() *schema.Provider { p := &schema.Provider{ Schema: map[string]*schema.Schema{ + "auth_mode": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_AUTH_MODE", nil), + Description: descriptions["auth_mode"], + ValidateFunc: validation.StringInSlice([]string{"anonymous", "token", "app"}, false), + }, "token": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("GITHUB_TOKEN", nil), Description: descriptions["token"], - // ConflictsWith: []string{"app_auth"}, // TODO: Enable as part of v7. }, "owner": { Type: schema.TypeString, @@ -326,6 +333,9 @@ var descriptions map[string]string func init() { descriptions = map[string]string{ + "auth_mode": "Explicit authentication mode. Valid values are `anonymous`, `token`, and `app`. " + + "When not set, the provider auto-detects the mode based on provided credentials for backward compatibility.", + "token": "The OAuth token used to connect to GitHub. Anonymous mode is enabled if both `token` and " + "`app_auth` are not set.", @@ -366,9 +376,11 @@ func init() { func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { return func(ctx context.Context, d *schema.ResourceData) (any, diag.Diagnostics) { + var diags diag.Diagnostics + owner := d.Get("owner").(string) - token := d.Get("token").(string) insecure := d.Get("insecure").(bool) + authMode := d.Get("auth_mode").(string) // BEGIN backwards compatibility // OwnerOrOrgEnvDefaultFunc used to be the default value for both @@ -397,34 +409,37 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { owner = org } - if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil { - appAuthAttr := appAuth[0].(map[string]any) + var token string - var appID, appInstallationID, appPemFile string + switch authMode { + case "anonymous": + log.Printf("[INFO] Auth mode: anonymous") - if v, ok := appAuthAttr["id"].(string); ok && v != "" { - appID = v - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.id must be set and contain a non-empty value")}) + case "token": + token = d.Get("token").(string) + if token == "" { + return nil, diag.FromErr(fmt.Errorf( + "auth_mode is set to \"token\" but no token was provided; " + + "set the `token` argument or `GITHUB_TOKEN` environment variable")) } + log.Printf("[INFO] Auth mode: token") - if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" { - appInstallationID = v - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.installation_id must be set and contain a non-empty value")}) + case "app": + appID, appInstallationID, appPemFile := getAppCredentials(d) + var missingFields []string + if appID == "" { + missingFields = append(missingFields, "app_id (GITHUB_APP_ID)") } - - if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { - // The Go encoding/pem package only decodes PEM formatted blocks - // that contain new lines. Some platforms, like Terraform Cloud, - // do not support new lines within Environment Variables. - // Any occurrence of \n in the `pem_file` argument's value - // (explicit value, or default value taken from - // GITHUB_APP_PEM_FILE Environment Variable) is replaced with an - // actual new line character before decoding. - appPemFile = strings.ReplaceAll(v, `\n`, "\n") - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value")}) + if appInstallationID == "" { + missingFields = append(missingFields, "app_installation_id (GITHUB_APP_INSTALLATION_ID)") + } + if appPemFile == "" { + missingFields = append(missingFields, "app_pem_file (GITHUB_APP_PEM_FILE)") + } + if len(missingFields) > 0 { + return nil, diag.FromErr(fmt.Errorf( + "auth_mode is set to \"app\" but the following app credentials are missing: %s", + strings.Join(missingFields, ", "))) } apiPath := "" @@ -438,11 +453,42 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } token = appToken - } + log.Printf("[INFO] Auth mode: app (ID: %s, installation: %s)", appID, appInstallationID) + + default: // auto-detect (backward compatibility) + token = d.Get("token").(string) + + if token == "" { + appID, appInstallationID, appPemFile := getAppCredentials(d) + if appID != "" && appInstallationID != "" && appPemFile != "" { + apiPath := "" + if isGHES { + apiPath = GHESRESTAPIPath + } - if token == "" { - log.Printf("[INFO] No token found, using GitHub CLI to get token from hostname %s", baseURL.Host) - token = tokenFromGHCLI(baseURL) + appToken, err := GenerateOAuthTokenFromApp(baseURL.JoinPath(apiPath), appID, appInstallationID, appPemFile) + if err != nil { + return nil, wrapErrors([]error{err}) + } + token = appToken + log.Printf("[INFO] Auth mode: app (ID: %s, installation: %s)", appID, appInstallationID) + } + } + + if token == "" { + log.Printf("[INFO] No token found, trying GitHub CLI to get token from hostname %s", baseURL.Host) + ghToken := tokenFromGHCLI(baseURL) + if ghToken != "" { + token = ghToken + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "GitHub CLI token fallback is deprecated", + Detail: "Automatic token detection from `gh auth token` is deprecated and will be removed in a future major release. " + + "Please set the `token` provider argument or `GITHUB_TOKEN` environment variable explicitly. " + + "You can use `export GITHUB_TOKEN=$(gh auth token)` as a replacement.", + }) + } + } } writeDelay := d.Get("write_delay_ms").(int) @@ -512,7 +558,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { return nil, wrapErrors([]error{err}) } - return meta, nil + return meta, diags } } @@ -525,6 +571,13 @@ func getAppCredentials(d *schema.ResourceData) (appID, appInstallationID, appPem appInstallationID = v } if v, ok := d.Get("app_pem_file").(string); ok && v != "" { + // The Go encoding/pem package only decodes PEM formatted blocks + // that contain new lines. Some platforms, like Terraform Cloud, + // do not support new lines within Environment Variables. + // Any occurrence of \n in the `pem_file` argument's value + // (explicit value, or default value taken from + // GITHUB_APP_PEM_FILE Environment Variable) is replaced with an + // actual new line character before decoding. appPemFile = strings.ReplaceAll(v, `\n`, "\n") } From 06ad80fedd779fb6dcff8057dc87033e02e8708c Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Mon, 2 Mar 2026 02:35:40 +0100 Subject: [PATCH 03/10] update auth example --- examples/app_authentication/providers.tf | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/examples/app_authentication/providers.tf b/examples/app_authentication/providers.tf index df2b382473..1a66cf2358 100644 --- a/examples/app_authentication/providers.tf +++ b/examples/app_authentication/providers.tf @@ -1,10 +1,8 @@ provider "github" { - owner = var.owner - app_auth { - // Empty block to allow the provider configurations to be specified through - // environment variables. - // See: https://github.com/hashicorp/terraform-plugin-sdk/issues/142 - } + owner = var.owner + auth_mode = "app" + # Credentials are specified through environment variables: + # GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PEM_FILE } terraform { From 77612206e65c91491890bb8d58d720be83e6299d Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Mon, 2 Mar 2026 02:59:36 +0100 Subject: [PATCH 04/10] add auth_mode tests --- github/provider_test.go | 128 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/github/provider_test.go b/github/provider_test.go index bb9ab01400..b0d52150d7 100644 --- a/github/provider_test.go +++ b/github/provider_test.go @@ -232,6 +232,134 @@ data "github_ip_ranges" "test" {} }, }) }) + t.Run("auth_mode anonymous ignores GITHUB_TOKEN in env", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "anonymous" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { t.Setenv("GITHUB_TOKEN", "some-token-value"); t.Setenv("GH_PATH", "none-existent-path") }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + }, + }) + }) + + t.Run("auth_mode token errors when no token provided", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "token" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { t.Setenv("GITHUB_TOKEN", ""); t.Setenv("GH_PATH", "none-existent-path") }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`auth_mode is set to "token" but no token was provided`), + }, + }, + }) + }) + + t.Run("can be configured with auth_mode token", func(t *testing.T) { + config := fmt.Sprintf(` + provider "github" { + auth_mode = "token" + token = "%s" + owner = "%s" + }`, testAccConf.token, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, individual) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + }, + }) + }) + + t.Run("auth_mode app errors when credentials are incomplete", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "app" + app_id = "12345" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + t.Setenv("GITHUB_APP_INSTALLATION_ID", "") + t.Setenv("GITHUB_APP_PEM_FILE", "") + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`auth_mode is set to "app" but the following app credentials are missing`), + }, + }, + }) + }) + + t.Run("can be configured with top-level app fields without auth_mode", func(t *testing.T) { + config := fmt.Sprintf(` + provider "github" { + owner = "%s" + app_id = "123456" + app_installation_id = "1234567890" + app_pem_file = "not-valid-pem" + } + data "github_ip_ranges" "test" {} + `, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { t.Setenv("GITHUB_TOKEN", "") }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("no decodeable PEM data found"), + }, + }, + }) + }) + + t.Run("auth_mode rejects invalid values", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "invalid" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`expected auth_mode to be one of`), + }, + }, + }) + }) + t.Run("should not allow both token and app_auth to be configured", func(t *testing.T) { t.Skip("This would be a semver breaking change, this will be reinstated for v7.") config := fmt.Sprintf(` From a2010f5830e136afe297afdd4f402c256de8e4ba Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Mon, 2 Mar 2026 03:13:47 +0100 Subject: [PATCH 05/10] rename app_pem_file to app_private_key --- examples/app_authentication/README.md | 2 +- examples/app_authentication/providers.tf | 2 +- github/provider.go | 31 +++++++++++++----------- github/provider_test.go | 3 ++- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/examples/app_authentication/README.md b/examples/app_authentication/README.md index 7f2852d8dc..8bb0683f6c 100644 --- a/examples/app_authentication/README.md +++ b/examples/app_authentication/README.md @@ -10,7 +10,7 @@ You may use variables passed via command line: export GITHUB_OWNER= export GITHUB_APP_ID= export GITHUB_APP_INSTALLATION_ID= -export GITHUB_APP_PEM_FILE= +export GITHUB_APP_PRIVATE_KEY= ``` ```console diff --git a/examples/app_authentication/providers.tf b/examples/app_authentication/providers.tf index 1a66cf2358..60f67e29b6 100644 --- a/examples/app_authentication/providers.tf +++ b/examples/app_authentication/providers.tf @@ -2,7 +2,7 @@ provider "github" { owner = var.owner auth_mode = "app" # Credentials are specified through environment variables: - # GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PEM_FILE + # GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY } terraform { diff --git a/github/provider.go b/github/provider.go index 0650ea30d7..5940bd8eef 100644 --- a/github/provider.go +++ b/github/provider.go @@ -105,7 +105,7 @@ func Provider() *schema.Provider { Optional: true, MaxItems: 1, Description: descriptions["app_auth"], - Deprecated: "Use top-level app_id, app_installation_id, and app_pem_file instead.", + Deprecated: "Use top-level app_id, app_installation_id, and app_private_key instead.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -134,20 +134,20 @@ func Provider() *schema.Provider { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_ID", nil), - Description: descriptions["app_auth.id"], + Description: descriptions["app_id"], }, "app_installation_id": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_INSTALLATION_ID", nil), - Description: descriptions["app_auth.installation_id"], + Description: descriptions["app_installation_id"], }, - "app_pem_file": { + "app_private_key": { Type: schema.TypeString, Optional: true, Sensitive: true, - DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PEM_FILE", nil), - Description: descriptions["app_auth.pem_file"], + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PRIVATE_KEY", nil), + Description: descriptions["app_private_key"], }, // https://developer.github.com/guides/traversing-with-pagination/#basics-of-pagination "max_per_page": { @@ -336,8 +336,8 @@ func init() { "auth_mode": "Explicit authentication mode. Valid values are `anonymous`, `token`, and `app`. " + "When not set, the provider auto-detects the mode based on provided credentials for backward compatibility.", - "token": "The OAuth token used to connect to GitHub. Anonymous mode is enabled if both `token` and " + - "`app_auth` are not set.", + "token": "The OAuth token used to connect to GitHub. " + + "When `auth_mode` is not set, anonymous mode is enabled if no credentials are provided.", "base_url": "The GitHub Base API URL", @@ -349,11 +349,14 @@ func init() { "organization": "The GitHub organization name to manage. " + "Use this field instead of `owner` when managing organization accounts.", - "app_auth": "The GitHub App credentials used to connect to GitHub. Conflicts with " + - "`token`. Anonymous mode is enabled if both `token` and `app_auth` are not set.", + "app_auth": "Deprecated: use top-level `app_id`, `app_installation_id`, and `app_private_key` instead. " + + "The GitHub App credentials used to connect to GitHub.", "app_auth.id": "The GitHub App ID.", "app_auth.installation_id": "The GitHub App installation instance ID.", "app_auth.pem_file": "The GitHub App PEM file contents.", + "app_id": "The GitHub App ID.", + "app_installation_id": "The GitHub App installation instance ID.", + "app_private_key": "The GitHub App private key in PEM format.", "write_delay_ms": "Amount of time in milliseconds to sleep in between writes to GitHub API. " + "Defaults to 1000ms or 1s if not set.", "read_delay_ms": "Amount of time in milliseconds to sleep in between non-write requests to GitHub API. " + @@ -434,7 +437,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { missingFields = append(missingFields, "app_installation_id (GITHUB_APP_INSTALLATION_ID)") } if appPemFile == "" { - missingFields = append(missingFields, "app_pem_file (GITHUB_APP_PEM_FILE)") + missingFields = append(missingFields, "app_private_key (GITHUB_APP_PRIVATE_KEY)") } if len(missingFields) > 0 { return nil, diag.FromErr(fmt.Errorf( @@ -570,13 +573,13 @@ func getAppCredentials(d *schema.ResourceData) (appID, appInstallationID, appPem if v, ok := d.Get("app_installation_id").(string); ok && v != "" { appInstallationID = v } - if v, ok := d.Get("app_pem_file").(string); ok && v != "" { + if v, ok := d.Get("app_private_key").(string); ok && v != "" { // The Go encoding/pem package only decodes PEM formatted blocks // that contain new lines. Some platforms, like Terraform Cloud, // do not support new lines within Environment Variables. - // Any occurrence of \n in the `pem_file` argument's value + // Any occurrence of \n in the `app_private_key` argument's value // (explicit value, or default value taken from - // GITHUB_APP_PEM_FILE Environment Variable) is replaced with an + // GITHUB_APP_PRIVATE_KEY Environment Variable) is replaced with an // actual new line character before decoding. appPemFile = strings.ReplaceAll(v, `\n`, "\n") } diff --git a/github/provider_test.go b/github/provider_test.go index b0d52150d7..5ece698f63 100644 --- a/github/provider_test.go +++ b/github/provider_test.go @@ -306,6 +306,7 @@ data "github_ip_ranges" "test" {} resource.Test(t, resource.TestCase{ PreCheck: func() { t.Setenv("GITHUB_APP_INSTALLATION_ID", "") + t.Setenv("GITHUB_APP_PRIVATE_KEY", "") t.Setenv("GITHUB_APP_PEM_FILE", "") }, ProviderFactories: providerFactories, @@ -324,7 +325,7 @@ data "github_ip_ranges" "test" {} owner = "%s" app_id = "123456" app_installation_id = "1234567890" - app_pem_file = "not-valid-pem" + app_private_key = "not-valid-pem" } data "github_ip_ranges" "test" {} `, testAccConf.owner) From 91625cf55beaa0dc39aa094e8ade8c4059044b84 Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Mon, 2 Mar 2026 03:21:05 +0100 Subject: [PATCH 06/10] update authentication docs --- website/docs/index.html.markdown | 84 +++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 22 deletions(-) diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index 28408af769..3d6d9ad780 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -56,46 +56,80 @@ resource "github_membership" "membership_for_user_x" { ## Authentication -The GitHub provider offers multiple ways to authenticate with GitHub API. +The GitHub provider offers multiple ways to authenticate with GitHub API. You can explicitly set the authentication mode using the `auth_mode` argument, or let the provider auto-detect based on the provided credentials. -### GitHub CLI +### Explicit Authentication Mode (Recommended) -The GitHub provider taps into [GitHub CLI](https://cli.github.com/) authentication, where it picks up the token issued by [`gh auth login`](https://cli.github.com/manual/gh_auth_login) command. It is possible to specify the path to the `gh` executable in the `GH_PATH` environment variable, which is useful for when the GitHub Terraform provider can not properly determine its the path to GitHub CLI such as in the cygwin terminal. +Setting `auth_mode` explicitly is recommended for clarity and to avoid unexpected behavior. -### OAuth / Personal Access Token - -To authenticate using OAuth tokens, ensure that the `token` argument or the `GITHUB_TOKEN` environment variable is set. +#### Anonymous ```terraform provider "github" { - token = var.token # or `GITHUB_TOKEN` + auth_mode = "anonymous" } ``` -### GitHub App Installation +When `auth_mode` is set to `"anonymous"`, the provider will not use any credentials, even if `GITHUB_TOKEN` or other authentication environment variables are set. -To authenticate using a GitHub App installation, ensure that arguments in the `app_auth` block or the `GITHUB_APP_XXX` environment variables are set. -The `owner` parameter required in this situation. Leaving out will throw a `403 "Resource not accessible by integration"` error. +#### OAuth / Personal Access Token -Some API operations may not be available when using a GitHub App installation configuration. For more information, refer to the list of [supported endpoints](https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps). +```terraform +provider "github" { + auth_mode = "token" + token = var.token # or `GITHUB_TOKEN` +} +``` + +When `auth_mode` is set to `"token"`, the provider requires the `token` argument or `GITHUB_TOKEN` environment variable. An error will be returned if no token is provided. + +#### GitHub App Installation ```terraform provider "github" { - owner = var.github_organization - app_auth { - id = var.app_id # or `GITHUB_APP_ID` - installation_id = var.app_installation_id # or `GITHUB_APP_INSTALLATION_ID` - pem_file = var.app_pem_file # or `GITHUB_APP_PEM_FILE` - } + auth_mode = "app" + owner = var.github_owner + app_id = var.app_id # or `GITHUB_APP_ID` + app_installation_id = var.app_installation_id # or `GITHUB_APP_INSTALLATION_ID` + app_private_key = var.app_private_key # or `GITHUB_APP_PRIVATE_KEY` } ``` -~> **Note:** When using environment variables, an empty `app_auth` block is required to allow provider configurations from environment variables to be specified. See: https://github.com/hashicorp/terraform-plugin-sdk/issues/142 +When `auth_mode` is set to `"app"`, the provider requires all three app credential arguments (`app_id`, `app_installation_id`, `app_private_key`) or their corresponding environment variables. The `owner` argument is also required when using app authentication. + +Some API operations may not be available when using a GitHub App installation configuration. For more information, refer to the list of [supported endpoints](https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps). + +### Auto-detected Authentication (Default) + +When `auth_mode` is not set, the provider auto-detects the authentication method using the following priority: + +1. If `token` (or `GITHUB_TOKEN`) is set, use token-based authentication. +2. If app credentials (`app_id`, `app_installation_id`, `app_private_key`, or the deprecated `app_auth` block) are set, use GitHub App authentication. +3. If none of the above, fall back to the GitHub CLI (`gh auth token`). +4. If no credentials are found, operate in anonymous mode. + +This is equivalent to the pre-existing behavior and is preserved for backward compatibility. + +~> **Note:** Using `auth_mode = "anonymous"` is the only way to ensure the provider runs anonymously when `GITHUB_TOKEN` or GitHub CLI credentials are present in the environment. + +### GitHub CLI (Deprecated) + +~> The GitHub CLI token fallback is deprecated and will be removed in a future major release. Please set the `token` provider argument or `GITHUB_TOKEN` environment variable explicitly. You can use `export GITHUB_TOKEN=$(gh auth token)` as a replacement. + +### Deprecated: `app_auth` block + +~> The `app_auth` block is deprecated. Use the top-level `app_id`, `app_installation_id`, and `app_private_key` arguments instead. Note that the deprecated `app_auth` block reads from `GITHUB_APP_PEM_FILE`, while the new `app_private_key` argument reads from `GITHUB_APP_PRIVATE_KEY`. + +The `app_auth` block is still supported for backward compatibility: ```terraform provider "github" { owner = var.github_organization - app_auth {} # When using `GITHUB_APP_XXX` environment variables + app_auth { + id = var.app_id # or `GITHUB_APP_ID` + installation_id = var.app_installation_id # or `GITHUB_APP_INSTALLATION_ID` + pem_file = var.app_private_key # or `GITHUB_APP_PEM_FILE` + } } ``` @@ -103,6 +137,8 @@ provider "github" { The following arguments are supported in the `provider` block: +* `auth_mode` - (Optional) Explicit authentication mode. Valid values are `anonymous`, `token`, and `app`. When not set, the provider auto-detects the mode based on provided credentials for backward compatibility. Can also be sourced from the `GITHUB_AUTH_MODE` environment variable. + * `token` - (Optional) A GitHub OAuth / Personal Access Token. When not provided or made available via the `GITHUB_TOKEN` environment variable, the provider can only access resources available anonymously. * `base_url` - (Optional) This is the target GitHub base API endpoint. Providing a value is a requirement when working with GitHub Enterprise. It is optional to provide this value and it can also be sourced from the `GITHUB_BASE_URL` environment variable. The value must end with a slash, for example: `https://terraformtesting-ghe.westus.cloudapp.azure.com/` @@ -111,7 +147,13 @@ The following arguments are supported in the `provider` block: * `organization` - (Deprecated) This behaves the same as `owner`, which should be used instead. This value can also be sourced from the `GITHUB_ORGANIZATION` environment variable. -* `app_auth` - (Optional) Configuration block to use GitHub App installation token. When not provided, the provider can only access resources available anonymously. +* `app_id` - (Optional) This is the ID of the GitHub App. It can also be sourced from the `GITHUB_APP_ID` environment variable. + +* `app_installation_id` - (Optional) This is the ID of the GitHub App installation. It can also be sourced from the `GITHUB_APP_INSTALLATION_ID` environment variable. + +* `app_private_key` - (Optional) This is the contents of the GitHub App private key in PEM format. It can also be sourced from the `GITHUB_APP_PRIVATE_KEY` environment variable and may use `\n` instead of actual new lines. If you have a PEM file on disk, you can pass it in via `app_private_key = file("path/to/file.pem")`. + +* `app_auth` - (Optional, **Deprecated**: Use top-level `app_id`, `app_installation_id`, and `app_private_key` instead.) Configuration block to use GitHub App installation token. * `id` - (Required) This is the ID of the GitHub App. It can sourced from the `GITHUB_APP_ID` environment variable. * `installation_id` - (Required) This is the ID of the GitHub App installation. It can sourced from the `GITHUB_APP_INSTALLATION_ID` environment variable. * `pem_file` - (Required) This is the contents of the GitHub App private key PEM file. It can also be sourced from the `GITHUB_APP_PEM_FILE` environment variable and may use `\n` instead of actual new lines. @@ -126,8 +168,6 @@ The following arguments are supported in the `provider` block: * `max_retries` - (Optional) Number of times to retry a request after receiving an error status code. Defaults to 3 -Note: If you have a PEM file on disk, you can pass it in via `pem_file = file("path/to/file.pem")`. - For backwards compatibility, if more than one of `owner`, `organization`, `GITHUB_OWNER` and `GITHUB_ORGANIZATION` are set, the first in this list takes priority. From e07604bf872025485ddd48b280e7c042d2b29a8e Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Thu, 5 Mar 2026 03:02:20 +0100 Subject: [PATCH 07/10] Use tflog in providerConfigure instead of log.Printf --- github/provider.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/github/provider.go b/github/provider.go index 5940bd8eef..d6e5c05b5c 100644 --- a/github/provider.go +++ b/github/provider.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -408,7 +409,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { org := d.Get("organization").(string) if org != "" { - log.Printf("[INFO] Selecting organization attribute as owner: %s", org) + tflog.Info(ctx, "Selecting organization attribute as owner", map[string]any{"owner": org}) owner = org } @@ -416,7 +417,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { switch authMode { case "anonymous": - log.Printf("[INFO] Auth mode: anonymous") + tflog.Info(ctx, "Auth mode: anonymous") case "token": token = d.Get("token").(string) @@ -425,7 +426,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { "auth_mode is set to \"token\" but no token was provided; " + "set the `token` argument or `GITHUB_TOKEN` environment variable")) } - log.Printf("[INFO] Auth mode: token") + tflog.Info(ctx, "Auth mode: token") case "app": appID, appInstallationID, appPemFile := getAppCredentials(d) @@ -456,7 +457,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } token = appToken - log.Printf("[INFO] Auth mode: app (ID: %s, installation: %s)", appID, appInstallationID) + tflog.Info(ctx, "Auth mode: app", map[string]any{"app_id": appID, "installation_id": appInstallationID}) default: // auto-detect (backward compatibility) token = d.Get("token").(string) @@ -474,12 +475,12 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { return nil, wrapErrors([]error{err}) } token = appToken - log.Printf("[INFO] Auth mode: app (ID: %s, installation: %s)", appID, appInstallationID) + tflog.Info(ctx, "Auth mode: app", map[string]any{"app_id": appID, "installation_id": appInstallationID}) } } if token == "" { - log.Printf("[INFO] No token found, trying GitHub CLI to get token from hostname %s", baseURL.Host) + tflog.Info(ctx, "No token found, trying GitHub CLI to get token", map[string]any{"hostname": baseURL.Host}) ghToken := tokenFromGHCLI(baseURL) if ghToken != "" { token = ghToken @@ -498,25 +499,25 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { if writeDelay <= 0 { return nil, wrapErrors([]error{fmt.Errorf("write_delay_ms must be greater than 0ms")}) } - log.Printf("[INFO] Setting write_delay_ms to %d", writeDelay) + tflog.Info(ctx, "Setting write_delay_ms", map[string]any{"write_delay_ms": writeDelay}) readDelay := d.Get("read_delay_ms").(int) if readDelay < 0 { return nil, wrapErrors([]error{fmt.Errorf("read_delay_ms must be greater than or equal to 0ms")}) } - log.Printf("[DEBUG] Setting read_delay_ms to %d", readDelay) + tflog.Debug(ctx, "Setting read_delay_ms", map[string]any{"read_delay_ms": readDelay}) retryDelay := d.Get("read_delay_ms").(int) if retryDelay < 0 { return nil, diag.FromErr(fmt.Errorf("retry_delay_ms must be greater than or equal to 0ms")) } - log.Printf("[DEBUG] Setting retry_delay_ms to %d", retryDelay) + tflog.Debug(ctx, "Setting retry_delay_ms", map[string]any{"retry_delay_ms": retryDelay}) maxRetries := d.Get("max_retries").(int) if maxRetries < 0 { return nil, diag.FromErr(fmt.Errorf("max_retries must be greater than or equal to 0")) } - log.Printf("[DEBUG] Setting max_retries to %d", maxRetries) + tflog.Debug(ctx, "Setting max_retries", map[string]any{"max_retries": maxRetries}) retryableErrors := make(map[int]bool) if maxRetries > 0 { reParam := d.Get("retryable_errors").([]any) @@ -528,19 +529,19 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } } - log.Printf("[DEBUG] Setting retriableErrors to %v", retryableErrors) + tflog.Debug(ctx, "Setting retryable_errors", map[string]any{"retryable_errors": retryableErrors}) } _maxPerPage := d.Get("max_per_page").(int) if _maxPerPage <= 0 { return nil, diag.FromErr(fmt.Errorf("max_per_page must be greater than than 0")) } - log.Printf("[DEBUG] Setting max_per_page to %d", _maxPerPage) + tflog.Debug(ctx, "Setting max_per_page", map[string]any{"max_per_page": _maxPerPage}) maxPerPage = _maxPerPage parallelRequests := d.Get("parallel_requests").(bool) - log.Printf("[DEBUG] Setting parallel_requests to %t", parallelRequests) + tflog.Debug(ctx, "Setting parallel_requests", map[string]any{"parallel_requests": parallelRequests}) config := Config{ Token: token, From c33ccb5a475ee41ca805bf857823f1f720d76a86 Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Thu, 5 Mar 2026 03:03:12 +0100 Subject: [PATCH 08/10] Use diag.Errorf instead of diag.FromErr(fmt.Errorf --- github/provider.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/github/provider.go b/github/provider.go index d6e5c05b5c..46159bea31 100644 --- a/github/provider.go +++ b/github/provider.go @@ -422,9 +422,9 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { case "token": token = d.Get("token").(string) if token == "" { - return nil, diag.FromErr(fmt.Errorf( + return nil, diag.Errorf( "auth_mode is set to \"token\" but no token was provided; " + - "set the `token` argument or `GITHUB_TOKEN` environment variable")) + "set the `token` argument or `GITHUB_TOKEN` environment variable") } tflog.Info(ctx, "Auth mode: token") @@ -441,9 +441,9 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { missingFields = append(missingFields, "app_private_key (GITHUB_APP_PRIVATE_KEY)") } if len(missingFields) > 0 { - return nil, diag.FromErr(fmt.Errorf( + return nil, diag.Errorf( "auth_mode is set to \"app\" but the following app credentials are missing: %s", - strings.Join(missingFields, ", "))) + strings.Join(missingFields, ", ")) } apiPath := "" @@ -509,13 +509,13 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { retryDelay := d.Get("read_delay_ms").(int) if retryDelay < 0 { - return nil, diag.FromErr(fmt.Errorf("retry_delay_ms must be greater than or equal to 0ms")) + return nil, diag.Errorf("retry_delay_ms must be greater than or equal to 0ms") } tflog.Debug(ctx, "Setting retry_delay_ms", map[string]any{"retry_delay_ms": retryDelay}) maxRetries := d.Get("max_retries").(int) if maxRetries < 0 { - return nil, diag.FromErr(fmt.Errorf("max_retries must be greater than or equal to 0")) + return nil, diag.Errorf("max_retries must be greater than or equal to 0") } tflog.Debug(ctx, "Setting max_retries", map[string]any{"max_retries": maxRetries}) retryableErrors := make(map[int]bool) @@ -534,7 +534,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { _maxPerPage := d.Get("max_per_page").(int) if _maxPerPage <= 0 { - return nil, diag.FromErr(fmt.Errorf("max_per_page must be greater than than 0")) + return nil, diag.Errorf("max_per_page must be greater than than 0") } tflog.Debug(ctx, "Setting max_per_page", map[string]any{"max_per_page": _maxPerPage}) maxPerPage = _maxPerPage From e3264c4a2ae3975172623e7a12ec78bd6b0a4fc1 Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Thu, 5 Mar 2026 03:09:28 +0100 Subject: [PATCH 09/10] Add mutual ConflictsWith between the new top-level parameters and the old app_auth --- github/provider.go | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/github/provider.go b/github/provider.go index 46159bea31..52b2d70a36 100644 --- a/github/provider.go +++ b/github/provider.go @@ -102,11 +102,12 @@ func Provider() *schema.Provider { Description: descriptions["parallel_requests"], }, "app_auth": { - Type: schema.TypeList, - Optional: true, - MaxItems: 1, - Description: descriptions["app_auth"], - Deprecated: "Use top-level app_id, app_installation_id, and app_private_key instead.", + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: descriptions["app_auth"], + Deprecated: "Use top-level app_id, app_installation_id, and app_private_key instead.", + ConflictsWith: []string{"app_id", "app_installation_id", "app_private_key"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -132,23 +133,26 @@ func Provider() *schema.Provider { }, }, "app_id": { - Type: schema.TypeString, - Optional: true, - DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_ID", nil), - Description: descriptions["app_id"], + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_ID", nil), + Description: descriptions["app_id"], + ConflictsWith: []string{"app_auth"}, }, "app_installation_id": { - Type: schema.TypeString, - Optional: true, - DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_INSTALLATION_ID", nil), - Description: descriptions["app_installation_id"], + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_INSTALLATION_ID", nil), + Description: descriptions["app_installation_id"], + ConflictsWith: []string{"app_auth"}, }, "app_private_key": { - Type: schema.TypeString, - Optional: true, - Sensitive: true, - DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PRIVATE_KEY", nil), - Description: descriptions["app_private_key"], + Type: schema.TypeString, + Optional: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PRIVATE_KEY", nil), + Description: descriptions["app_private_key"], + ConflictsWith: []string{"app_auth"}, }, // https://developer.github.com/guides/traversing-with-pagination/#basics-of-pagination "max_per_page": { From af6fb595e702e3ef4516308db1adfcc4c9717e1b Mon Sep 17 00:00:00 2001 From: Alexey Alekhin Date: Thu, 5 Mar 2026 04:04:56 +0100 Subject: [PATCH 10/10] Validate auth_mode for top-level app fields and add tests for edge cases --- github/provider.go | 14 +++++ github/provider_test.go | 111 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/github/provider.go b/github/provider.go index 52b2d70a36..e0b801b705 100644 --- a/github/provider.go +++ b/github/provider.go @@ -467,6 +467,20 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { token = d.Get("token").(string) if token == "" { + // Top-level app fields require an explicit auth_mode. + // Skip this check when app_auth is configured for backward compatibility. + appAuth, _ := d.Get("app_auth").([]any) + hasAppAuth := len(appAuth) > 0 && appAuth[0] != nil + topLevelAppSet := d.Get("app_id").(string) != "" || + d.Get("app_installation_id").(string) != "" || + d.Get("app_private_key").(string) != "" + if topLevelAppSet && !hasAppAuth { + return nil, diag.Errorf( + "top-level app credentials (app_id, app_installation_id, app_private_key) " + + "require auth_mode = \"app\" to be set explicitly; use the `auth_mode` " + + "provider argument or the GITHUB_AUTH_MODE environment variable") + } + appID, appInstallationID, appPemFile := getAppCredentials(d) if appID != "" && appInstallationID != "" && appPemFile != "" { apiPath := "" diff --git a/github/provider_test.go b/github/provider_test.go index 5ece698f63..5b2bc65764 100644 --- a/github/provider_test.go +++ b/github/provider_test.go @@ -319,16 +319,15 @@ data "github_ip_ranges" "test" {} }) }) - t.Run("can be configured with top-level app fields without auth_mode", func(t *testing.T) { - config := fmt.Sprintf(` + t.Run("top-level app fields without auth_mode fail validation", func(t *testing.T) { + config := ` provider "github" { - owner = "%s" app_id = "123456" app_installation_id = "1234567890" app_private_key = "not-valid-pem" } data "github_ip_ranges" "test" {} - `, testAccConf.owner) + ` resource.Test(t, resource.TestCase{ PreCheck: func() { t.Setenv("GITHUB_TOKEN", "") }, @@ -336,7 +335,109 @@ data "github_ip_ranges" "test" {} Steps: []resource.TestStep{ { Config: config, - ExpectError: regexp.MustCompile("no decodeable PEM data found"), + ExpectError: regexp.MustCompile(`require auth_mode = "app" to be set explicitly`), + }, + }, + }) + }) + + t.Run("top-level app fields with GITHUB_AUTH_MODE env var pass validation", func(t *testing.T) { + config := ` + provider "github" { + app_id = "123456" + app_installation_id = "1234567890" + app_private_key = "not-valid-pem" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + t.Setenv("GITHUB_AUTH_MODE", "app") + t.Setenv("GITHUB_TOKEN", "") + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + // Should fail on invalid PEM, not on "requires auth_mode" + Config: config, + ExpectError: regexp.MustCompile(`no decodeable PEM data found`), + }, + }, + }) + }) + + t.Run("app_auth block without auth_mode still auto-detects (backward compatibility)", func(t *testing.T) { + config := ` + provider "github" { + app_auth {} + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + t.Setenv("GITHUB_APP_ID", "12345") + t.Setenv("GITHUB_APP_INSTALLATION_ID", "67890") + t.Setenv("GITHUB_APP_PEM_FILE", "not-valid-pem") + t.Setenv("GITHUB_TOKEN", "") + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + // Should fail on invalid PEM, NOT on "requires auth_mode" + Config: config, + ExpectError: regexp.MustCompile(`no decodeable PEM data found`), + }, + }, + }) + }) + + t.Run("app_auth and top-level app fields cannot both be explicitly configured", func(t *testing.T) { + config := ` + provider "github" { + app_id = "123456" + app_auth { + id = "123456" + installation_id = "1234567890" + pem_file = "not-valid-pem" + } + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`conflicts with`), + }, + }, + }) + }) + + t.Run("top-level app fields are ignored when auth_mode is set to token", func(t *testing.T) { + config := fmt.Sprintf(` + provider "github" { + auth_mode = "token" + token = "%s" + owner = "%s" + app_id = "123456" + app_installation_id = "1234567890" + app_private_key = "not-valid-pem" + } + data "github_ip_ranges" "test" {} + `, testAccConf.token, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, individual) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + PlanOnly: true, + ExpectNonEmptyPlan: false, }, }, })