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"),
+ },
+ },
+ })
+}