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/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 5c233d439 feat(rest): export NewOauth2AuthManager for custom token
sources (#2433)
5c233d439 is described below
commit 5c233d43981d997696756bd906412d72d2b8b594
Author: Revanth <[email protected]>
AuthorDate: Thu Nov 5 11:18:15 2026 -0600
feat(rest): export NewOauth2AuthManager for custom token sources (#2433)
---
catalog/rest/auth.go | 26 ++++++++
catalog/rest/auth_test.go | 78 ++++++++++++++++++++++
catalog/rest/auth_token_source_test.go | 117 +++++++++++++++++++++++++++++++++
website/src/configuration.md | 27 +++++++-
4 files changed, 247 insertions(+), 1 deletion(-)
diff --git a/catalog/rest/auth.go b/catalog/rest/auth.go
index 4789eb78d..0b50b6c88 100644
--- a/catalog/rest/auth.go
+++ b/catalog/rest/auth.go
@@ -57,6 +57,32 @@ type Oauth2AuthManager struct {
var _ ContextAuthManager = (*Oauth2AuthManager)(nil)
+// NewOauth2AuthManager returns an AuthManager that sends tokens from ts, for
+// identity providers the built-in client-credentials flow can't reach, such as
+// Google Application Default Credentials for BigLake. Pass the result to
+// WithAuthManager.
+//
+// Like oauth2.NewClient, it wraps ts in oauth2.ReuseTokenSource, so ts is
+// called only when the cached token is missing or expired. A token without an
+// expiry is reused indefinitely. The header uses the token's type, and an
+// *oauth2.RetrieveError from ts is reported as ErrOAuthError.
+//
+// The built-in flows bound each refresh with their own HTTP client timeout.
+// A refresh through ts is bounded only by the HTTP client ts uses; for
+// sources built from a context, such as google.DefaultTokenSource, that is
+// the client in the context's oauth2.HTTPClient value, or
+// http.DefaultClient, which has no timeout. AuthHeaderWithContext still
+// returns when its context is done.
+//
+// NewOauth2AuthManager panics if ts is nil.
+func NewOauth2AuthManager(ts oauth2.TokenSource) *Oauth2AuthManager {
+ if ts == nil {
+ panic("rest: NewOauth2AuthManager called with a nil
oauth2.TokenSource")
+ }
+
+ return &Oauth2AuthManager{tokenSource: oauth2.ReuseTokenSource(nil, ts)}
+}
+
// AuthHeader returns the authorization header with the bearer token.
func (o *Oauth2AuthManager) AuthHeader() (string, string, error) {
tok, err, _ := o.refresh.Do("token", o.fetchToken)
diff --git a/catalog/rest/auth_test.go b/catalog/rest/auth_test.go
index 165be30bc..543db6170 100644
--- a/catalog/rest/auth_test.go
+++ b/catalog/rest/auth_test.go
@@ -190,3 +190,81 @@ func
TestOauth2AuthManager_AuthHeaderWithContext_SharesRefresh(t *testing.T) {
assert.Equal(t, "Authorization", key)
assert.Equal(t, "Bearer shared_token", value)
}
+
+// sequenceTokenSource returns its tokens in order, repeating the last one, and
+// counts Token calls.
+type sequenceTokenSource struct {
+ tokens []*oauth2.Token
+ err error
+ calls atomic.Int32
+}
+
+func (s *sequenceTokenSource) Token() (*oauth2.Token, error) {
+ n := int(s.calls.Add(1))
+ if s.err != nil {
+ return nil, s.err
+ }
+
+ return s.tokens[min(n, len(s.tokens))-1], nil
+}
+
+func TestNewOauth2AuthManager_ReusesValidToken(t *testing.T) {
+ // An empty TokenType defaults to Bearer, as oauth2.Token.Type does.
+ source := &sequenceTokenSource{tokens: []*oauth2.Token{
+ {AccessToken: "a", Expiry: time.Now().Add(time.Hour)},
+ }}
+ manager := NewOauth2AuthManager(source)
+
+ for range 3 {
+ key, value, err := manager.AuthHeader()
+ require.NoError(t, err)
+ assert.Equal(t, "Authorization", key)
+ assert.Equal(t, "Bearer a", value)
+ }
+ assert.EqualValues(t, 1, source.calls.Load(), "a valid token must be
reused, not refetched per request")
+}
+
+func TestNewOauth2AuthManager_RefreshesExpiredToken(t *testing.T) {
+ source := &sequenceTokenSource{tokens: []*oauth2.Token{
+ {AccessToken: "a", TokenType: "Bearer", Expiry:
time.Now().Add(-time.Minute)},
+ {AccessToken: "b", TokenType: "Bearer", Expiry:
time.Now().Add(time.Hour)},
+ }}
+ manager := NewOauth2AuthManager(source)
+
+ for _, want := range []string{"Bearer a", "Bearer b", "Bearer b"} {
+ _, value, err :=
manager.AuthHeaderWithContext(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, want, value)
+ }
+ assert.EqualValues(t, 2, source.calls.Load())
+}
+
+func TestNewOauth2AuthManager_RetrieveErrorIsOAuthError(t *testing.T) {
+ manager := NewOauth2AuthManager(&sequenceTokenSource{err:
&oauth2.RetrieveError{
+ ErrorCode: "invalid_grant",
+ ErrorDescription: "Token has been expired or revoked.",
+ }})
+
+ _, _, err := manager.AuthHeader()
+ require.ErrorIs(t, err, ErrOAuthError)
+ assert.Contains(t, err.Error(), "invalid_grant")
+ assert.Contains(t, err.Error(), "Token has been expired or revoked.")
+}
+
+func TestNewOauth2AuthManager_CanceledContextSkipsSource(t *testing.T) {
+ source := &sequenceTokenSource{tokens: []*oauth2.Token{{AccessToken:
"a"}}}
+ manager := NewOauth2AuthManager(source)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, _, err := manager.AuthHeaderWithContext(ctx)
+
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Zero(t, source.calls.Load(), "a canceled caller must not trigger
a refresh")
+}
+
+func TestNewOauth2AuthManager_NilSourcePanics(t *testing.T) {
+ assert.PanicsWithValue(t, "rest: NewOauth2AuthManager called with a nil
oauth2.TokenSource", func() {
+ NewOauth2AuthManager(nil)
+ })
+}
diff --git a/catalog/rest/auth_token_source_test.go
b/catalog/rest/auth_token_source_test.go
new file mode 100644
index 000000000..a0ce87527
--- /dev/null
+++ b/catalog/rest/auth_token_source_test.go
@@ -0,0 +1,117 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package rest_test
+
+import (
+ "context"
+ "encoding/json"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/apache/iceberg-go/catalog/rest"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/google"
+)
+
+// rotatingTokenSource returns an already expired token first and a valid one
+// after that, standing in for a source whose first token has run out.
+type rotatingTokenSource struct {
+ mu sync.Mutex
+ calls int
+}
+
+func (s *rotatingTokenSource) Token() (*oauth2.Token, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.calls++
+ if s.calls == 1 {
+ return &oauth2.Token{AccessToken: "first", Expiry:
time.Now().Add(-time.Minute)}, nil
+ }
+
+ return &oauth2.Token{AccessToken: "second", Expiry:
time.Now().Add(time.Hour)}, nil
+}
+
+func TestNewOauth2AuthManagerAuthenticatesCatalogRequests(t *testing.T) {
+ var (
+ mu sync.Mutex
+ headers []string
+ )
+ record := func(r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ headers = append(headers, r.URL.Path+"
"+r.Header.Get("Authorization"))
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, r
*http.Request) {
+ record(r)
+ json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{},
+ "overrides": map[string]any{},
+ "endpoints": rest.AllEndpointStrings,
+ })
+ })
+ mux.HandleFunc("/v1/namespaces", func(w http.ResponseWriter, r
*http.Request) {
+ record(r)
+ json.NewEncoder(w).Encode(map[string]any{"namespaces": []any{}})
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ ctx := context.Background()
+ cat, err := rest.NewCatalog(ctx, "rest", srv.URL,
+
rest.WithAuthManager(rest.NewOauth2AuthManager(&rotatingTokenSource{})))
+ require.NoError(t, err)
+ _, err = cat.ListNamespaces(ctx, nil)
+ require.NoError(t, err)
+
+ // The expired first token is sent once, then replaced on the next
request.
+ require.Equal(t, []string{
+ "/v1/config Bearer first",
+ "/v1/namespaces Bearer second",
+ }, headers)
+}
+
+// ExampleNewOauth2AuthManager connects to Google's BigLake Iceberg REST
+// catalog with Application Default Credentials.
+func ExampleNewOauth2AuthManager() {
+ ctx := context.Background()
+
+ // Application Default Credentials: a service-account key file, gcloud
user
+ // credentials, or workload identity from the metadata server.
+ ts, err := google.DefaultTokenSource(ctx,
"https://www.googleapis.com/auth/cloud-platform")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ cat, err := rest.NewCatalog(ctx, "biglake",
"https://biglake.googleapis.com/iceberg/v1/restcatalog",
+ rest.WithAuthManager(rest.NewOauth2AuthManager(ts)),
+ rest.WithWarehouseLocation("gs://lakehouse-public-data"),
+ // The project BigLake bills the requests to.
+ rest.WithHeaders(map[string]string{"x-goog-user-project":
"my-project"}),
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+ _ = cat
+}
diff --git a/website/src/configuration.md b/website/src/configuration.md
index bda15dcb0..43b23aeec 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -70,7 +70,7 @@ The most option-rich surface. Source:
[`catalog/rest/options.go`](https://github
| Group | Options |
|---|---|
-| Authentication | `WithCredential`, `WithOAuthToken`, `WithAuthManager`,
`WithAuthURI`, `WithScope`, `WithAudience`, `WithResource` |
+| Authentication | `WithCredential`, `WithOAuthToken`, `WithAuthManager`,
`NewOauth2AuthManager`, `WithAuthURI`, `WithScope`, `WithAudience`,
`WithResource` |
| AWS SigV4 | `WithSigV4`, `WithSigV4RegionSvc`, `WithSigner` |
| HTTP | `WithHeaders`, `WithTLSConfig`, `WithOAuthTLSConfig`,
`WithCustomTransport` |
| Catalog routing | `WithPrefix`, `WithWarehouseLocation`,
`WithMetadataLocation` |
@@ -99,6 +99,31 @@ The most option-rich surface. Source:
[`catalog/rest/options.go`](https://github
> until the `catalog/rest/sigv4` backend is blank-imported; the error names the
> import to add.
+#### Custom token sources (Google BigLake)
+
+`WithCredential` uses the OAuth2 client-credentials flow against the catalog's
+token endpoint. For other identity providers, wrap any `oauth2.TokenSource`
with
+`NewOauth2AuthManager` and pass it to `WithAuthManager`. Google's BigLake
Iceberg
+REST catalog, for example, takes Application Default Credentials:
+
+```go
+ts, err := google.DefaultTokenSource(ctx,
"https://www.googleapis.com/auth/cloud-platform")
+if err != nil {
+ return err
+}
+cat, err := rest.NewCatalog(ctx, "biglake",
"https://biglake.googleapis.com/iceberg/v1/restcatalog",
+ rest.WithAuthManager(rest.NewOauth2AuthManager(ts)),
+ rest.WithWarehouseLocation("gs://lakehouse-public-data"),
+ rest.WithHeaders(map[string]string{"x-goog-user-project":
"my-project"}))
+```
+
+The manager reuses a token until it expires, then asks the source for a new
one.
+That refresh runs on the HTTP client the source was built with. For
+`google.DefaultTokenSource` that is the client in the context's
+`oauth2.HTTPClient` value, or `http.DefaultClient`, which has no timeout.
+Unlike Java's `rest.auth.type=google`, this is configured in code; iceberg-go
+has no `rest.auth.type` property.
+
#### Metrics reporting
The REST catalog can POST scan and commit metrics to the catalog's