This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/terraform-provider-iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 15073ae  feat(provider): add OAuth2 client credentials auth (#127)
15073ae is described below

commit 15073ae689183caef8e410b4e6aa4d339b12e944
Author: Alex Stephen <[email protected]>
AuthorDate: Thu Oct 15 04:33:35 2026 -0700

    feat(provider): add OAuth2 client credentials auth (#127)
    
    * oauth2 auth manager
    
    * PR comments
---
 docs/index.md                             |  46 +++++++-
 internal/provider/provider.go             | 182 ++++++++++++++++++++++++++----
 internal/provider/provider_config_test.go |  89 +++++++++++++--
 internal/provider/provider_test.go        | 172 ++++++++++++++++++++++++++++
 4 files changed, 457 insertions(+), 32 deletions(-)

diff --git a/docs/index.md b/docs/index.md
index c7239eb..1c77ba9 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -204,6 +204,35 @@ carry duplicate credentials.
 - [iceberg_namespace](resources/namespace.md) — Manage a catalog namespace.
 - [iceberg_table](resources/table.md) — Manage an Iceberg table.
 
+## Example Usage
+
+Authenticate with a static bearer token:
+
+```terraform
+provider "iceberg" {
+  catalog_uri = "https://catalog.example.com";
+  token       = var.catalog_token
+}
+```
+
+Authenticate with the OAuth2 client credentials flow. The provider fetches a
+token from the token endpoint and refreshes it automatically:
+
+```terraform
+provider "iceberg" {
+  catalog_uri = "https://catalog.example.com";
+
+  auth = {
+    oauth2 = {
+      credential = "${var.client_id}:${var.client_secret}"
+      scope      = "PRINCIPAL_ROLE:ALL"
+    }
+  }
+}
+```
+
+`auth.oauth2` cannot be set together with `token` or `auth.sigv4`.
+
 ## Schema
 
 ### Required
@@ -213,7 +242,7 @@ carry duplicate credentials.
 ### Optional
 
 - `auth` (Attributes) Authentication settings for the Iceberg REST catalog. 
(see [below for nested schema](#nestedatt--auth))
-- `headers` (Map of String, Sensitive) The headers to use for authentication.
+- `headers` (Map of String, Sensitive) The headers to use for authentication. 
With `auth.oauth2`, an `Authorization` entry is not sent, and the other headers 
are also sent to the OAuth2 token endpoint.
 - `token` (String, Sensitive) The token to use for authentication.
 - `type` (String) The type of catalog. Use 'rest' for a plain REST catalog.
 - `warehouse` (String) The warehouse to use for the Iceberg REST catalog. This 
will be passed as `warehouse` property in the catalog properties.
@@ -223,8 +252,23 @@ carry duplicate credentials.
 
 Optional:
 
+- `oauth2` (Attributes) Authenticate with the OAuth2 client credentials flow. 
Tokens are fetched and refreshed automatically. (see [below for nested 
schema](#nestedatt--auth--oauth2))
 - `sigv4` (Attributes) Sign requests with AWS Signature Version 4, as catalogs 
such as AWS Glue require. Setting this attribute enables signing. (see [below 
for nested schema](#nestedatt--auth--sigv4))
 
+<a id="nestedatt--auth--oauth2"></a>
+### Nested Schema for `auth.oauth2`
+
+Required:
+
+- `credential` (String, Sensitive) The client credential, formatted as 
`client_id:client_secret`. A value without a colon is used as the client secret 
with an empty client ID.
+
+Optional:
+
+- `audience` (String) The audience to request.
+- `resource` (String) The resource to request.
+- `scope` (String) The scope to request. Defaults to `catalog`.
+- `server_uri` (String) The OAuth2 token endpoint. Defaults to 
`{catalog_uri}/v1/oauth/tokens`.
+
 <a id="nestedatt--auth--sigv4"></a>
 ### Nested Schema for `auth.sigv4`
 
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index ff8d555..1d0222e 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -20,6 +20,7 @@ import (
        "errors"
        "fmt"
        "net/http"
+       "net/url"
 
        "github.com/apache/iceberg-go/catalog"
        "github.com/apache/iceberg-go/catalog/rest"
@@ -27,6 +28,7 @@ import (
        "github.com/aws/aws-sdk-go-v2/config"
        "github.com/aws/aws-sdk-go-v2/credentials"
        "github.com/aws/aws-sdk-go-v2/credentials/processcreds"
+       
"github.com/hashicorp/terraform-plugin-framework-validators/providervalidator"
        
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
        "github.com/hashicorp/terraform-plugin-framework/datasource"
        "github.com/hashicorp/terraform-plugin-framework/path"
@@ -38,7 +40,10 @@ import (
        "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
 )
 
-var _ provider.Provider = &icebergProvider{}
+var (
+       _ provider.Provider                     = &icebergProvider{}
+       _ provider.ProviderWithConfigValidators = &icebergProvider{}
+)
 
 // New is a helper function to simplify provider server and testing 
implementation.
 func New() func() provider.Provider {
@@ -55,6 +60,18 @@ type icebergProvider struct {
        warehouse   string
        headers     map[string]string
        sigv4       *sigv4Config
+       oauth2      *oauth2Config
+       // authUnknown is set while planning when auth depends on values known 
only after apply.
+       authUnknown bool
+}
+
+// oauth2Config holds the OAuth2 client credentials settings passed to 
iceberg-go.
+type oauth2Config struct {
+       credential string
+       serverURI  *url.URL
+       scope      string
+       audience   string
+       resource   string
 }
 
 // sigv4Config holds the settings for signing requests with AWS SigV4.
@@ -64,8 +81,6 @@ type sigv4Config struct {
        accessKeyID     string
        secretAccessKey string
        sessionToken    string
-       // unknown is set while planning when auth depends on values known only 
after apply.
-       unknown bool
 }
 
 // icebergProviderModel maps provider schema data to a Go type.
@@ -79,7 +94,8 @@ type icebergProviderModel struct {
 }
 
 type icebergAuthModel struct {
-       SigV4 types.Object `tfsdk:"sigv4"`
+       SigV4  types.Object `tfsdk:"sigv4"`
+       OAuth2 types.Object `tfsdk:"oauth2"`
 }
 
 type icebergSigV4Model struct {
@@ -90,6 +106,14 @@ type icebergSigV4Model struct {
        SessionToken    types.String `tfsdk:"session_token"`
 }
 
+type icebergOAuth2Model struct {
+       Credential types.String `tfsdk:"credential"`
+       ServerURI  types.String `tfsdk:"server_uri"`
+       Scope      types.String `tfsdk:"scope"`
+       Audience   types.String `tfsdk:"audience"`
+       Resource   types.String `tfsdk:"resource"`
+}
+
 // Metadata returns the provider type name.
 func (p *icebergProvider) Metadata(_ context.Context, _ 
provider.MetadataRequest, resp *provider.MetadataResponse) {
        resp.TypeName = "iceberg"
@@ -118,7 +142,7 @@ func (p *icebergProvider) Schema(_ context.Context, _ 
provider.SchemaRequest, re
                                Optional:    true,
                        },
                        "headers": schema.MapAttribute{
-                               Description: "The headers to use for 
authentication.",
+                               Description: "The headers to use for 
authentication. With `auth.oauth2`, an `Authorization` entry is not sent, and 
the other headers are also sent to the OAuth2 token endpoint.",
                                Optional:    true,
                                Sensitive:   true,
                                ElementType: types.StringType,
@@ -171,12 +195,57 @@ func (p *icebergProvider) Schema(_ context.Context, _ 
provider.SchemaRequest, re
                                                        },
                                                },
                                        },
+                                       "oauth2": schema.SingleNestedAttribute{
+                                               Description: "Authenticate with 
the OAuth2 client credentials flow. Tokens are fetched and refreshed 
automatically.",
+                                               Optional:    true,
+                                               Attributes: 
map[string]schema.Attribute{
+                                                       "credential": 
schema.StringAttribute{
+                                                               Description: 
"The client credential, formatted as `client_id:client_secret`. A value without 
a colon is used as the client secret with an empty client ID.",
+                                                               Required:    
true,
+                                                               Sensitive:   
true,
+                                                               Validators: 
[]validator.String{
+                                                                       
stringvalidator.LengthAtLeast(1),
+                                                               },
+                                                       },
+                                                       "server_uri": 
schema.StringAttribute{
+                                                               Description: 
"The OAuth2 token endpoint. Defaults to `{catalog_uri}/v1/oauth/tokens`.",
+                                                               Optional:    
true,
+                                                       },
+                                                       "scope": 
schema.StringAttribute{
+                                                               Description: 
"The scope to request. Defaults to `catalog`.",
+                                                               Optional:    
true,
+                                                       },
+                                                       "audience": 
schema.StringAttribute{
+                                                               Description: 
"The audience to request.",
+                                                               Optional:    
true,
+                                                       },
+                                                       "resource": 
schema.StringAttribute{
+                                                               Description: 
"The resource to request.",
+                                                               Optional:    
true,
+                                                       },
+                                               },
+                                       },
                                },
                        },
                },
        }
 }
 
+// ConfigValidators returns validators that apply across provider attributes.
+func (p *icebergProvider) ConfigValidators(_ context.Context) 
[]provider.ConfigValidator {
+       return []provider.ConfigValidator{
+               providervalidator.Conflicting(
+                       path.MatchRoot("token"),
+                       path.MatchRoot("auth").AtName("oauth2"),
+               ),
+               // SigV4 replaces the Authorization header that carries the 
OAuth2 token.
+               providervalidator.Conflicting(
+                       path.MatchRoot("auth").AtName("sigv4"),
+                       path.MatchRoot("auth").AtName("oauth2"),
+               ),
+       }
+}
+
 // Configure prepares a Iceberg API client for data sources and resources.
 func (p *icebergProvider) Configure(ctx context.Context, req 
provider.ConfigureRequest, resp *provider.ConfigureResponse) {
        var data icebergProviderModel
@@ -230,6 +299,8 @@ func (p *icebergProvider) Configure(ctx context.Context, 
req provider.ConfigureR
        }
 
        p.sigv4 = nil
+       p.oauth2 = nil
+       p.authUnknown = false
        if !data.Auth.IsNull() {
                authValue, err := data.Auth.ToTerraformValue(ctx)
                if err != nil {
@@ -239,8 +310,8 @@ func (p *icebergProvider) Configure(ctx context.Context, 
req provider.ConfigureR
                }
 
                if !authValue.IsFullyKnown() {
-                       // Guessing any part would sign with the wrong scope or 
identity.
-                       p.sigv4 = &sigv4Config{unknown: true}
+                       // Guessing any part would authenticate with the wrong 
scope or identity.
+                       p.authUnknown = true
                } else {
                        var auth icebergAuthModel
                        resp.Diagnostics.Append(data.Auth.As(ctx, &auth, 
basetypes.ObjectAsOptions{})...)
@@ -263,6 +334,13 @@ func (p *icebergProvider) Configure(ctx context.Context, 
req provider.ConfigureR
                                        sessionToken:    
m.SessionToken.ValueString(),
                                }
                        }
+
+                       if !auth.OAuth2.IsNull() {
+                               p.oauth2 = configureOAuth2(ctx, auth.OAuth2, 
resp)
+                               if resp.Diagnostics.HasError() {
+                                       return
+                               }
+                       }
                }
        }
 
@@ -270,18 +348,78 @@ func (p *icebergProvider) Configure(ctx context.Context, 
req provider.ConfigureR
        resp.ResourceData = p
 }
 
+func configureOAuth2(ctx context.Context, obj types.Object, resp 
*provider.ConfigureResponse) *oauth2Config {
+       var m icebergOAuth2Model
+       resp.Diagnostics.Append(obj.As(ctx, &m, basetypes.ObjectAsOptions{})...)
+       if resp.Diagnostics.HasError() {
+               return nil
+       }
+
+       cfg := &oauth2Config{
+               credential: m.Credential.ValueString(),
+               scope:      m.Scope.ValueString(),
+               audience:   m.Audience.ValueString(),
+               resource:   m.Resource.ValueString(),
+       }
+
+       if serverURI := m.ServerURI.ValueString(); serverURI != "" {
+               u, err := url.Parse(serverURI)
+               if err != nil || u.Scheme == "" || u.Host == "" {
+                       resp.Diagnostics.AddAttributeError(
+                               
path.Root("auth").AtName("oauth2").AtName("server_uri"),
+                               "Invalid OAuth2 Server URI",
+                               "server_uri must be an absolute URL. Got: 
"+serverURI,
+                       )
+
+                       return nil
+               }
+               cfg.serverURI = u
+       }
+
+       return cfg
+}
+
 func (p *icebergProvider) NewCatalog(ctx context.Context) (catalog.Catalog, 
error) {
+       if p.authUnknown {
+               return nil, errors.New("auth is not known until apply, so 
requests cannot be authenticated yet")
+       }
+
        opts := make([]rest.Option, 0)
        if p.token != "" && p.sigv4 == nil {
                opts = append(opts, rest.WithOAuthToken(p.token))
        }
 
+       if o := p.oauth2; o != nil {
+               opts = append(opts, rest.WithCredential(o.credential))
+               if o.serverURI != nil {
+                       opts = append(opts, rest.WithAuthURI(o.serverURI))
+               }
+               if o.scope != "" {
+                       opts = append(opts, rest.WithScope(o.scope))
+               }
+               if o.audience != "" {
+                       opts = append(opts, rest.WithAudience(o.audience))
+               }
+               if o.resource != "" {
+                       opts = append(opts, rest.WithResource(o.resource))
+               }
+       }
+
        if p.warehouse != "" {
                opts = append(opts, rest.WithWarehouseLocation(p.warehouse))
        }
 
        if p.sigv4 == nil {
-               opts = append(opts, 
rest.WithCustomTransport(&headerRoundTripper{headers: p.headers}))
+               headers := p.headers
+               if p.oauth2 != nil {
+                       headers = withoutAuthorization(headers)
+               }
+               if len(headers) > 0 {
+                       opts = append(opts, rest.WithHeaders(headers))
+               }
+               // Without a transport, iceberg-go builds one per catalog and 
never
+               // closes its idle connections.
+               opts = append(opts, 
rest.WithCustomTransport(http.DefaultTransport))
        } else {
                sigv4Opts, err := p.sigv4.options(ctx, p.token, p.headers)
                if err != nil {
@@ -293,6 +431,19 @@ func (p *icebergProvider) NewCatalog(ctx context.Context) 
(catalog.Catalog, erro
        return rest.NewCatalog(ctx, p.catalogType, p.catalogURI, opts...)
 }
 
+// withoutAuthorization drops the Authorization header, which the OAuth2 token
+// replaces and which must not reach a separate token endpoint.
+func withoutAuthorization(headers map[string]string) map[string]string {
+       kept := make(map[string]string, len(headers))
+       for name, value := range headers {
+               if http.CanonicalHeaderKey(name) != "Authorization" {
+                       kept[name] = value
+               }
+       }
+
+       return kept
+}
+
 // options returns the iceberg-go options that sign requests with SigV4.
 // iceberg-go signs before a custom transport runs, so the configured headers
 // go through it to be signed with the request. SigV4 owns Authorization, so an
@@ -329,9 +480,6 @@ func (s *sigv4Config) options(ctx context.Context, token 
string, headers map[str
 // replace the AWS credential chain. The region falls back to the AWS
 // environment, and credential providers that call STS get it as well.
 func (s *sigv4Config) awsConfig(ctx context.Context) (aws.Config, error) {
-       if s.unknown {
-               return aws.Config{}, errors.New("auth.sigv4 is not known until 
apply, so requests cannot be signed yet")
-       }
        static := s.accessKeyID != "" && s.secretAccessKey != ""
        if !static && (s.accessKeyID != "" || s.secretAccessKey != "" || 
s.sessionToken != "") {
                // Never fall back to the AWS credential chain when keys were 
configured.
@@ -377,18 +525,6 @@ func (p processOutputHidden) Retrieve(ctx context.Context) 
(aws.Credentials, err
        return creds, err
 }
 
-type headerRoundTripper struct {
-       headers map[string]string
-}
-
-func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, 
error) {
-       for k, v := range h.headers {
-               req.Header.Add(k, v)
-       }
-
-       return http.DefaultTransport.RoundTrip(req)
-}
-
 // DataSources defines the data sources implemented in the provider.
 func (p *icebergProvider) DataSources(_ context.Context) []func() 
datasource.DataSource {
        return []func() datasource.DataSource{
diff --git a/internal/provider/provider_config_test.go 
b/internal/provider/provider_config_test.go
index c91b236..c4cd780 100644
--- a/internal/provider/provider_config_test.go
+++ b/internal/provider/provider_config_test.go
@@ -57,19 +57,34 @@ func object(typ tftypes.Object, set 
map[string]tftypes.Value) tftypes.Value {
        return tftypes.NewValue(typ, vals)
 }
 
-// authProviderConfig builds a provider configuration with catalog_uri and the 
given auth value.
-func authProviderConfig(t *testing.T, auth tftypes.Value) 
*tfprotov6.DynamicValue {
+// providerConfigWith builds a provider configuration with catalog_uri and the 
given attributes set.
+func providerConfigWith(t *testing.T, set map[string]tftypes.Value) 
*tfprotov6.DynamicValue {
        t.Helper()
        configType, _, _ := schemaTypes(t)
-       config, err := tfprotov6.NewDynamicValue(configType, object(configType, 
map[string]tftypes.Value{
-               "catalog_uri": str("http://localhost:8181";),
-               "auth":        auth,
-       }))
+       vals := map[string]tftypes.Value{"catalog_uri": 
str("http://localhost:8181";)}
+       maps.Copy(vals, set)
+       config, err := tfprotov6.NewDynamicValue(configType, object(configType, 
vals))
        require.NoError(t, err)
 
        return &config
 }
 
+// authProviderConfig builds a provider configuration with catalog_uri and the 
given auth value.
+func authProviderConfig(t *testing.T, auth tftypes.Value) 
*tfprotov6.DynamicValue {
+       t.Helper()
+
+       return providerConfigWith(t, map[string]tftypes.Value{"auth": auth})
+}
+
+// oauth2ObjectType returns the Terraform type of auth.oauth2.
+func oauth2ObjectType(t *testing.T, auth tftypes.Object) tftypes.Object {
+       t.Helper()
+       oauth2, ok := auth.AttributeTypes["oauth2"].(tftypes.Object)
+       require.True(t, ok)
+
+       return oauth2
+}
+
 // sigv4ProviderConfig builds a provider configuration that sets the given 
auth.sigv4 attributes.
 func sigv4ProviderConfig(t *testing.T, sigv4 map[string]tftypes.Value) 
*tfprotov6.DynamicValue {
        t.Helper()
@@ -176,9 +191,10 @@ func TestValidateSigV4RequiresCompleteKeyPair(t 
*testing.T) {
        }
 }
 
-func TestNewCatalogRefusesSigV4SettingsUnknownAtPlan(t *testing.T) {
+func TestNewCatalogRefusesAuthUnknownAtPlan(t *testing.T) {
        isolateAWSEnv(t)
        _, authType, sigv4Type := schemaTypes(t)
+       oauth2Type := oauth2ObjectType(t, authType)
        unknown := tftypes.NewValue(tftypes.String, tftypes.UnknownValue)
        withSigV4 := func(set map[string]tftypes.Value) tftypes.Value {
                return object(authType, map[string]tftypes.Value{"sigv4": 
object(sigv4Type, set)})
@@ -190,6 +206,12 @@ func TestNewCatalogRefusesSigV4SettingsUnknownAtPlan(t 
*testing.T) {
                "signing name": withSigV4(map[string]tftypes.Value{"region": 
str("us-east-1"), "signing_name": unknown}),
                "sigv4":        object(authType, 
map[string]tftypes.Value{"sigv4": tftypes.NewValue(sigv4Type, 
tftypes.UnknownValue)}),
                "auth":         tftypes.NewValue(authType, 
tftypes.UnknownValue),
+               "oauth2 credential": object(authType, map[string]tftypes.Value{
+                       "oauth2": object(oauth2Type, 
map[string]tftypes.Value{"credential": unknown}),
+               }),
+               "oauth2 server uri": object(authType, map[string]tftypes.Value{
+                       "oauth2": object(oauth2Type, 
map[string]tftypes.Value{"credential": str("client:secret"), "server_uri": 
unknown}),
+               }),
        } {
                t.Run(name, func(t *testing.T) {
                        p := &icebergProvider{}
@@ -203,7 +225,58 @@ func TestNewCatalogRefusesSigV4SettingsUnknownAtPlan(t 
*testing.T) {
                        headers, err := configHeaders(t, p)
                        require.ErrorContains(t, err, "not known until apply")
 
-                       assert.Nil(t, headers, "nothing may be sent until 
auth.sigv4 is known")
+                       assert.Nil(t, headers, "nothing may be sent until auth 
is known")
+               })
+       }
+}
+
+func TestValidateOAuth2Settings(t *testing.T) {
+       _, authType, sigv4Type := schemaTypes(t)
+       oauth2Type := oauth2ObjectType(t, authType)
+       withOAuth2 := func(set map[string]tftypes.Value) tftypes.Value {
+               return object(authType, map[string]tftypes.Value{"oauth2": 
object(oauth2Type, set)})
+       }
+       credential := map[string]tftypes.Value{"credential": 
str("client:secret")}
+
+       for name, tc := range map[string]struct {
+               config map[string]tftypes.Value
+               want   string
+       }{
+               "credential only": {map[string]tftypes.Value{"auth": 
withOAuth2(credential)}, ""},
+               "conflicts with token": {map[string]tftypes.Value{
+                       "token": str("static"),
+                       "auth":  withOAuth2(credential),
+               }, "Invalid Attribute Combination"},
+               "conflicts with sigv4": {map[string]tftypes.Value{"auth": 
object(authType, map[string]tftypes.Value{
+                       "oauth2": object(oauth2Type, credential),
+                       "sigv4":  object(sigv4Type, 
map[string]tftypes.Value{"region": str("us-east-1")}),
+               })}, "Invalid Attribute Combination"},
+               "empty credential": {map[string]tftypes.Value{
+                       "auth": 
withOAuth2(map[string]tftypes.Value{"credential": str("")}),
+               }, "auth.oauth2.credential string length must be at least 1"},
+               "relative server_uri": {map[string]tftypes.Value{
+                       "auth": 
withOAuth2(map[string]tftypes.Value{"credential": str("client:secret"), 
"server_uri": str("/oauth/tokens")}),
+               }, "Invalid OAuth2 Server URI"},
+       } {
+               t.Run(name, func(t *testing.T) {
+                       server, err := 
providerserver.NewProtocol6WithError(New()())()
+                       require.NoError(t, err)
+                       config := providerConfigWith(t, tc.config)
+
+                       validateResp, err := 
server.ValidateProviderConfig(context.Background(),
+                               
&tfprotov6.ValidateProviderConfigRequest{Config: config})
+                       require.NoError(t, err)
+                       configureResp, err := 
server.ConfigureProvider(context.Background(),
+                               &tfprotov6.ConfigureProviderRequest{Config: 
config})
+                       require.NoError(t, err)
+
+                       errs := 
append(errorDiagnostics(validateResp.Diagnostics), 
errorDiagnostics(configureResp.Diagnostics)...)
+                       if tc.want == "" {
+                               assert.Empty(t, errs)
+
+                               return
+                       }
+                       assert.Contains(t, strings.Join(errs, "\n"), tc.want)
                })
        }
 }
diff --git a/internal/provider/provider_test.go 
b/internal/provider/provider_test.go
index 8c5a5a1..1859adb 100644
--- a/internal/provider/provider_test.go
+++ b/internal/provider/provider_test.go
@@ -16,11 +16,183 @@
 package provider
 
 import (
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "net/http/httptest"
        "testing"
 
+       "github.com/hashicorp/terraform-plugin-testing/helper/resource"
        "github.com/stretchr/testify/assert"
 )
 
 func TestProvider(t *testing.T) {
        assert.NotNil(t, New()())
 }
+
+// newOAuth2CatalogServer returns a fake REST catalog that issues a token from
+// tokenPath and rejects catalog requests that don't carry exactly that token
+// and the wantHeaders. Token requests must not carry an Authorization header.
+func newOAuth2CatalogServer(t *testing.T, tokenPath string, wantForm, 
wantHeaders map[string]string) *httptest.Server {
+       t.Helper()
+
+       const token = "issued-token"
+       mux := http.NewServeMux()
+       mux.HandleFunc("POST "+tokenPath, func(w http.ResponseWriter, r 
*http.Request) {
+               if auth := r.Header.Values("Authorization"); len(auth) > 0 {
+                       http.Error(w, fmt.Sprintf("token request carries 
Authorization %q", auth), http.StatusBadRequest)
+
+                       return
+               }
+               if err := r.ParseForm(); err != nil {
+                       http.Error(w, err.Error(), http.StatusBadRequest)
+
+                       return
+               }
+               for k, v := range wantForm {
+                       if got := r.PostForm.Get(k); got != v {
+                               http.Error(w, fmt.Sprintf("form %s: got %q, 
want %q", k, got, v), http.StatusBadRequest)
+
+                               return
+                       }
+               }
+               w.Header().Set("Content-Type", "application/json")
+               _ = json.NewEncoder(w).Encode(map[string]any{
+                       "access_token": token,
+                       "token_type":   "Bearer",
+                       "expires_in":   3600,
+               })
+       })
+       mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) {
+               if auth := r.Header.Values("Authorization"); len(auth) != 1 || 
auth[0] != "Bearer "+token {
+                       http.Error(w, 
`{"error":{"message":"unauthorized","type":"NotAuthorizedException","code":401}}`,
 http.StatusUnauthorized)
+
+                       return
+               }
+               for k, v := range wantHeaders {
+                       if got := r.Header.Values(k); len(got) != 1 || got[0] 
!= v {
+                               http.Error(w, 
fmt.Sprintf(`{"error":{"message":"header %s: got %q, want 
%q","type":"BadRequestException","code":400}}`, k, got, v), 
http.StatusBadRequest)
+
+                               return
+                       }
+               }
+               w.Header().Set("Content-Type", "application/json")
+               switch r.URL.Path {
+               case "/v1/config":
+                       _, _ = w.Write([]byte(`{"defaults":{},"overrides":{}}`))
+               case "/v1/namespaces/ns":
+                       _, _ = 
w.Write([]byte(`{"namespace":["ns"],"properties":{"owner":"oauth"}}`))
+               default:
+                       http.NotFound(w, r)
+               }
+       })
+
+       srv := httptest.NewServer(mux)
+       t.Cleanup(srv.Close)
+
+       return srv
+}
+
+func TestOAuth2ClientCredentials(t *testing.T) {
+       srv := newOAuth2CatalogServer(t, "/v1/oauth/tokens", map[string]string{
+               "grant_type":    "client_credentials",
+               "client_id":     "client",
+               "client_secret": "secret",
+               "scope":         "catalog",
+       }, nil)
+
+       resource.UnitTest(t, resource.TestCase{
+               ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+               Steps: []resource.TestStep{
+                       {
+                               Config: fmt.Sprintf(`
+provider "iceberg" {
+  catalog_uri = %q
+  auth = {
+    oauth2 = {
+      credential = "client:secret"
+    }
+  }
+}
+
+data "iceberg_namespace" "ns" {
+  name = ["ns"]
+}
+`, srv.URL),
+                               Check: 
resource.TestCheckResourceAttr("data.iceberg_namespace.ns", 
"server_properties.owner", "oauth"),
+                       },
+               },
+       })
+}
+
+func TestOAuth2CustomServerURI(t *testing.T) {
+       srv := newOAuth2CatalogServer(t, "/idp/token", map[string]string{
+               "client_id":     "client",
+               "client_secret": "secret",
+               "scope":         "PRINCIPAL_ROLE:ALL",
+               "audience":      "my-audience",
+               "resource":      "my-resource",
+       }, nil)
+
+       resource.UnitTest(t, resource.TestCase{
+               ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+               Steps: []resource.TestStep{
+                       {
+                               Config: fmt.Sprintf(`
+provider "iceberg" {
+  catalog_uri = %[1]q
+  auth = {
+    oauth2 = {
+      credential = "client:secret"
+      server_uri = "%[1]s/idp/token"
+      scope      = "PRINCIPAL_ROLE:ALL"
+      audience   = "my-audience"
+      resource   = "my-resource"
+    }
+  }
+}
+
+data "iceberg_namespace" "ns" {
+  name = ["ns"]
+}
+`, srv.URL),
+                               Check: 
resource.TestCheckResourceAttr("data.iceberg_namespace.ns", 
"server_properties.owner", "oauth"),
+                       },
+               },
+       })
+}
+
+func TestOAuth2WithHeaders(t *testing.T) {
+       srv := newOAuth2CatalogServer(t, "/v1/oauth/tokens", nil, 
map[string]string{
+               "X-Custom": "value",
+       })
+
+       resource.UnitTest(t, resource.TestCase{
+               ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+               Steps: []resource.TestStep{
+                       {
+                               // The OAuth2 token must replace, not 
duplicate, a configured
+                               // Authorization header, which must not reach 
the token endpoint.
+                               Config: fmt.Sprintf(`
+provider "iceberg" {
+  catalog_uri = %q
+  headers = {
+    Authorization = "Bearer stale"
+    X-Custom      = "value"
+  }
+  auth = {
+    oauth2 = {
+      credential = "client:secret"
+    }
+  }
+}
+
+data "iceberg_namespace" "ns" {
+  name = ["ns"]
+}
+`, srv.URL),
+                               Check: 
resource.TestCheckResourceAttr("data.iceberg_namespace.ns", 
"server_properties.owner", "oauth"),
+                       },
+               },
+       })
+}

Reply via email to