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

laskoviymishka 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 f5e2d7496 feat(io/azure): authenticate with static adls.token 
credentials (#2163)
f5e2d7496 is described below

commit f5e2d7496f27c70c25481080724d3b586b0a94ab
Author: Revanth <[email protected]>
AuthorDate: Sun Oct 11 08:53:32 2026 -0500

    feat(io/azure): authenticate with static adls.token credentials (#2163)
---
 catalog/rest/refresh_table_credentials_test.go | 185 +++++++++++++
 catalog/rest/rest.go                           |   8 +-
 catalog/rest/vended_creds.go                   |  51 +++-
 catalog/rest/vended_creds_test.go              | 190 ++++++++++++++
 internal/adlsauth/adlsauth.go                  |  83 ++++++
 io/config.go                                   |  16 +-
 io/gocloud/azure/azure.go                      |  53 ++--
 io/gocloud/azure/azure_test.go                 |   4 +-
 io/gocloud/azure/token_test.go                 | 346 +++++++++++++++++++++++++
 website/src/configuration.md                   |  18 +-
 10 files changed, 920 insertions(+), 34 deletions(-)

diff --git a/catalog/rest/refresh_table_credentials_test.go 
b/catalog/rest/refresh_table_credentials_test.go
index 81cb40e60..3170efd66 100644
--- a/catalog/rest/refresh_table_credentials_test.go
+++ b/catalog/rest/refresh_table_credentials_test.go
@@ -85,6 +85,8 @@ type credsCatalogOpts struct {
        // loadTable handles GET /v1/namespaces/db/tables/tbl. When nil, the
        // endpoint is left unrouted so a request to it would 404.
        loadTable http.HandlerFunc
+       // routes are further handlers, keyed by request path.
+       routes map[string]http.HandlerFunc
 }
 
 func newCredsTestCatalog(t *testing.T, opts credsCatalogOpts) *rest.Catalog {
@@ -112,6 +114,9 @@ func newCredsTestCatalog(t *testing.T, opts 
credsCatalogOpts) *rest.Catalog {
        if opts.loadTable != nil {
                mux.HandleFunc("/v1/namespaces/db/tables/tbl", opts.loadTable)
        }
+       for path, handler := range opts.routes {
+               mux.HandleFunc(path, handler)
+       }
 
        srv := httptest.NewServer(mux)
        t.Cleanup(srv.Close)
@@ -466,3 +471,183 @@ func TestRefreshTableCredentialsNotFound(t *testing.T) {
        assert.Nil(t, refreshed)
        assert.ErrorIs(t, err, catalog.ErrNoSuchTable)
 }
+
+// TestRefreshTableCredentialsClearsADLSAuthShadowingVendedToken checks that a
+// refreshed adls.token is not shadowed by shared-key, SAS-token, or
+// connection-string credentials left in the table's saved config for the same
+// storage account, which the Azure FileIO would otherwise select ahead of it.
+// Another account's credentials are kept. The recording scheme's host,
+// "warehouse", stands in for the storage account.
+func TestRefreshTableCredentialsClearsADLSAuthShadowingVendedToken(t 
*testing.T) {
+       const (
+               scheme    = "restcreds-stale-adls"
+               otherHost = "other.dfs.core.windows.net"
+       )
+
+       rec := &ioPropsRecorder{}
+       rec.register(t, scheme)
+
+       cat := newCredsTestCatalog(t, credsCatalogOpts{
+               creds: func(w http.ResponseWriter, _ *http.Request) {
+                       assert.NoError(t, 
json.NewEncoder(w).Encode(storageCredentialsBody(
+                               scheme+"://warehouse/database/table",
+                               map[string]string{iceio.ADLSToken: 
"vended-token"})))
+               },
+       })
+       tbl, _ := newExternalTable(t, cat, scheme, 
table.WithSavedConfig(iceberg.Properties{
+               iceio.ADLSSharedKeyAccountName:                 "warehouse",
+               iceio.ADLSSharedKeyAccountKey:                  "stale-key",
+               iceio.ADLSSasTokenPrefix + "warehouse":         "stale-sas",
+               iceio.ADLSConnectionStringPrefix + "warehouse": "stale-conn",
+               iceio.ADLSSasTokenPrefix + otherHost:           "other-sas",
+       }))
+
+       refreshed, err := cat.RefreshTableCredentials(context.Background(), tbl)
+       require.NoError(t, err)
+       _, err = refreshed.FS(context.Background())
+       require.NoError(t, err)
+
+       props := rec.lastLoad(t)
+       assert.Equal(t, "vended-token", props[iceio.ADLSToken])
+       for _, k := range []string{
+               iceio.ADLSSharedKeyAccountName,
+               iceio.ADLSSharedKeyAccountKey,
+               iceio.ADLSSasTokenPrefix + "warehouse",
+               iceio.ADLSConnectionStringPrefix + "warehouse",
+       } {
+               assert.NotContains(t, props, k, "stale credential %q must not 
survive the refresh", k)
+       }
+       assert.Equal(t, "other-sas", props[iceio.ADLSSasTokenPrefix+otherHost])
+}
+
+// tableResponseCalls are the catalog methods that build a table from a
+// load-table response, each of which merges the response's storage credentials
+// over the table-wide config.
+var tableResponseCalls = []struct {
+       name string
+       call func(context.Context, *rest.Catalog) (*table.Table, error)
+}{
+       {"CreateTable", func(ctx context.Context, cat *rest.Catalog) 
(*table.Table, error) {
+               return cat.CreateTable(ctx, catalog.ToIdentifier("db", "tbl"), 
tableSchemaSimple)
+       }},
+       {"RegisterTable", func(ctx context.Context, cat *rest.Catalog) 
(*table.Table, error) {
+               return cat.RegisterTable(ctx, catalog.ToIdentifier("db", 
"tbl"), "unused-metadata-location")
+       }},
+       {"LoadTable", func(ctx context.Context, cat *rest.Catalog) 
(*table.Table, error) {
+               return cat.LoadTable(ctx, catalog.ToIdentifier("db", "tbl"))
+       }},
+}
+
+// tableResponseFSProps calls each of tableResponseCalls against a server that
+// answers with a load-table response carrying tableConfig and one storage
+// credential, vended, for metadataLoc's prefix. It returns the properties each
+// call's table FileIO was built from, keyed by call name.
+func tableResponseFSProps(t *testing.T, scheme, metadataLoc string, 
tableConfig, vended map[string]string) map[string]map[string]string {
+       t.Helper()
+
+       rec := &ioPropsRecorder{}
+       rec.register(t, scheme)
+
+       respond := func(w http.ResponseWriter, _ *http.Request) {
+               body := 
storageCredentialsBody(metadataLoc[:strings.LastIndex(metadataLoc, 
"/metadata/")], vended)
+               body["metadata-location"] = metadataLoc
+               body["metadata"] = json.RawMessage(strings.ReplaceAll(
+                       exampleTableMetadataNoSnapshotV1, "s3://", 
scheme+"://"))
+               body["config"] = tableConfig
+               assert.NoError(t, json.NewEncoder(w).Encode(body))
+       }
+       cat := newCredsTestCatalog(t, credsCatalogOpts{
+               loadTable: respond,
+               routes: map[string]http.HandlerFunc{
+                       "/v1/namespaces/db/tables":   respond,
+                       "/v1/namespaces/db/register": respond,
+               },
+       })
+
+       got := make(map[string]map[string]string, len(tableResponseCalls))
+       for _, tc := range tableResponseCalls {
+               tbl, err := tc.call(context.Background(), cat)
+               require.NoError(t, err, tc.name)
+               _, err = tbl.FS(context.Background())
+               require.NoError(t, err, tc.name)
+               got[tc.name] = rec.lastLoad(t)
+       }
+
+       return got
+}
+
+// TestTableResponseVendedADLSTokenClearsShadowingCredentials covers the
+// table-wide merge sites, where config is the whole table config rather than a
+// per-location copy. The table's FileIO serves only the metadata location's
+// account, so a vended adls.token drops what would apply to that account ahead
+// of it or set its lifetime: that account's SAS token and connection string,
+// any shared key, and every inherited credential expiry. Another account's 
SAS token and
+// connection string never apply to it and are kept.
+func TestTableResponseVendedADLSTokenClearsShadowingCredentials(t *testing.T) {
+       const (
+               scheme    = "restcreds-table-adls"
+               host      = "acct.dfs.core.windows.net"
+               otherHost = "other.dfs.core.windows.net"
+       )
+
+       got := tableResponseFSProps(t, scheme,
+               
scheme+"://container@"+host+"/table/metadata/00000-a.metadata.json",
+               map[string]string{
+                       // The factory would sign this account's requests with 
another
+                       // account's shared key.
+                       iceio.ADLSSharedKeyAccountName:              "other",
+                       iceio.ADLSSharedKeyAccountKey:               
"other-key",
+                       iceio.ADLSSasTokenPrefix + host:             
"stale-sas",
+                       "adls.sas-token-expires-at-ms." + host:      "1000",
+                       iceio.ADLSConnectionStringPrefix + "acct":   
"stale-conn",
+                       iceio.ADLSSasTokenPrefix + otherHost:        
"other-sas",
+                       "adls.sas-token-expires-at-ms." + otherHost: "1000",
+                       iceio.ADLSConnectionStringPrefix + "other":  
"other-conn",
+                       "expiration-time":                           "1000",
+               },
+               map[string]string{iceio.ADLSToken: "vended-token"})
+
+       for name, props := range got {
+               assert.Equal(t, "vended-token", props[iceio.ADLSToken], name)
+               for _, k := range []string{
+                       iceio.ADLSSharedKeyAccountName,
+                       iceio.ADLSSharedKeyAccountKey,
+                       iceio.ADLSSasTokenPrefix + host,
+                       iceio.ADLSConnectionStringPrefix + "acct",
+               } {
+                       assert.NotContains(t, props, k, "%s: credential %q 
would be selected ahead of the token", name, k)
+               }
+               for _, k := range []string{
+                       "adls.sas-token-expires-at-ms." + host,
+                       "adls.sas-token-expires-at-ms." + otherHost,
+                       "expiration-time",
+               } {
+                       assert.NotContains(t, props, k, "%s: inherited expiry 
%q would set the token's lifetime", name, k)
+               }
+               assert.Equal(t, "other-sas", 
props[iceio.ADLSSasTokenPrefix+otherHost], name)
+               assert.Equal(t, "other-conn", 
props[iceio.ADLSConnectionStringPrefix+"other"], name)
+       }
+}
+
+// TestTableResponseLayersPartialS3Credential pins the existing behavior at the
+// table-wide merge sites: a partial vended S3 credential is layered over the
+// table config rather than replacing the key pair there. Whether a partial
+// credential should replace instead is a separate decision from adls.token
+// support.
+func TestTableResponseLayersPartialS3Credential(t *testing.T) {
+       const scheme = "restcreds-table-s3"
+
+       got := tableResponseFSProps(t, scheme,
+               
scheme+"://warehouse/database/table/metadata/00000-a.metadata.json",
+               map[string]string{
+                       iceio.S3AccessKeyID:     "static-key",
+                       iceio.S3SecretAccessKey: "static-secret",
+               },
+               map[string]string{iceio.S3SessionToken: "vended-token"})
+
+       for name, props := range got {
+               assert.Equal(t, "static-key", props[iceio.S3AccessKeyID], name)
+               assert.Equal(t, "static-secret", 
props[iceio.S3SecretAccessKey], name)
+               assert.Equal(t, "vended-token", props[iceio.S3SessionToken], 
name)
+       }
+}
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 2ad477634..06bd3eaae 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -1334,7 +1334,7 @@ func (r *Catalog) RefreshTableCredentials(ctx 
context.Context, tbl *table.Table)
        config := maps.Clone(r.props)
        maps.Copy(config, tbl.SavedConfig())
        scanCfg := maps.Clone(config)
-       maps.Copy(config, resp)
+       mergeVendedCredentials(config, resp, metadataLoc)
 
        // Keep a reporter the caller set on the table rather than reverting it 
to
        // the catalog default, as Refresh does.
@@ -1566,7 +1566,7 @@ func (r *Catalog) CreateTable(ctx context.Context, 
identifier table.Identifier,
        maps.Copy(saved, ret.Config)
        scanPlanningConfig := maps.Clone(config)
        credsVended := len(ret.StorageCredentials) > 0
-       maps.Copy(config, resolveStorageCredentials(ret.StorageCredentials, 
ret.MetadataLoc))
+       mergeVendedCredentials(config, 
resolveStorageCredentials(ret.StorageCredentials, ret.MetadataLoc), 
ret.MetadataLoc)
 
        return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, ret.Labels, 
table.WithSavedConfig(saved))
 }
@@ -1796,7 +1796,7 @@ func (r *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
        maps.Copy(saved, ret.Config)
        scanPlanningConfig := maps.Clone(config)
        credsVended := len(ret.StorageCredentials) > 0
-       maps.Copy(config, resolveStorageCredentials(ret.StorageCredentials, 
ret.MetadataLoc))
+       mergeVendedCredentials(config, 
resolveStorageCredentials(ret.StorageCredentials, ret.MetadataLoc), 
ret.MetadataLoc)
 
        return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, ret.Labels, 
table.WithSavedConfig(saved))
 }
@@ -1844,7 +1844,7 @@ func (r *Catalog) loadTableWithMode(ctx context.Context, 
identifier table.Identi
        maps.Copy(saved, ret.Config)
        scanPlanningConfig := maps.Clone(config)
        credsVended := len(ret.StorageCredentials) > 0
-       maps.Copy(config, resolveStorageCredentials(ret.StorageCredentials, 
ret.MetadataLoc))
+       mergeVendedCredentials(config, 
resolveStorageCredentials(ret.StorageCredentials, ret.MetadataLoc), 
ret.MetadataLoc)
 
        return r.tableFromResponse(ctx, identifier, ret.Metadata, 
ret.MetadataLoc, config, scanPlanningConfig, credsVended, ret.Labels, 
table.WithSavedConfig(saved))
 }
diff --git a/catalog/rest/vended_creds.go b/catalog/rest/vended_creds.go
index 0c8f3359e..e42c1c53f 100644
--- a/catalog/rest/vended_creds.go
+++ b/catalog/rest/vended_creds.go
@@ -30,6 +30,7 @@ import (
        "time"
 
        "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/internal/adlsauth"
        iceio "github.com/apache/iceberg-go/io"
        "golang.org/x/sync/semaphore"
 )
@@ -80,13 +81,18 @@ var credentialExpiryKeys = []string{
        keyExpirationTime,
 }
 
+// isCredentialExpiryKey reports whether key is one parseCredentialExpiry 
reads.
+func isCredentialExpiryKey(key string) bool {
+       return slices.Contains(credentialExpiryKeys, key) ||
+               key == keyAdlsSasExpiresAtMs ||
+               strings.HasPrefix(key, keyAdlsSasExpiresAtMs+".")
+}
+
 func parseCredentialExpiry(config iceberg.Properties) (time.Time, bool) {
        var earliest time.Time
        found := false
        for key, value := range config {
-               if !slices.Contains(credentialExpiryKeys, key) &&
-                       key != keyAdlsSasExpiresAtMs &&
-                       !strings.HasPrefix(key, keyAdlsSasExpiresAtMs+".") {
+               if !isCredentialExpiryKey(key) {
                        continue
                }
 
@@ -173,7 +179,7 @@ func (v *vendedCredentialRefresher) loadFS(ctx 
context.Context) (iceio.IO, error
                }
 
                config = maps.Clone(v.props)
-               maps.Copy(config, freshCreds)
+               mergeVendedCredentials(config, freshCreds, v.location)
        }
 
        // The IO is cached and shared by every later caller, so it must not
@@ -434,12 +440,47 @@ func (p *prefixScopedIO) propertiesForLocation(name 
string) iceberg.Properties {
        if credentialIndex >= 0 {
                credentialConfig := p.credentials[credentialIndex].Config
                clearOverriddenCredentialProperties(props, credentialConfig)
-               maps.Copy(props, credentialConfig)
+               mergeVendedCredentials(props, credentialConfig, name)
        }
 
        return props
 }
 
+// mergeVendedCredentials layers vended storage credential properties over
+// config, the properties an IO for location is built from. Every site that
+// merges storage credentials goes through here.
+//
+// A nonempty adls.token is the one case that also removes properties. config 
is
+// always a copy built for one IO, and the Azure FileIO serves only location's
+// storage account, so the removals cannot reach another account's IO:
+//   - Credentials the Azure FileIO selects ahead of the token would shadow it 
and
+//     the read would 403; see adlsauth.ClearTokenShadowingCredentials.
+//   - Every inherited credential expiry is dropped, whatever credential or
+//     account it was for. The IO's renewal takes the earliest expiry in 
config,
+//     so any of them would set the token's lifetime in place of the expiry
+//     vended with the token, or the default TTL without one. A server that
+//     vends adls.token for a location outside Azure is misconfigured; its IO
+//     loses inherited expiries too.
+//
+// Unlike the S3 and GCS clearing in clearOverriddenCredentialProperties, which
+// keys on property presence, this keys on a nonempty value: the Azure FileIO
+// skips an empty adls.token, so clearing for one would discard working
+// credentials.
+func mergeVendedCredentials(config, credentialConfig iceberg.Properties, 
location string) {
+       if credentialConfig[iceio.ADLSToken] != "" {
+               if acct, ok := adlsauth.AccountFor(location); ok {
+                       adlsauth.ClearTokenShadowingCredentials(config, acct)
+                       for key := range config {
+                               if isCredentialExpiryKey(key) {
+                                       delete(config, key)
+                               }
+                       }
+               }
+       }
+
+       maps.Copy(config, credentialConfig)
+}
+
 // clearOverriddenCredentialProperties prevents a matched credential from being
 // combined with optional fields from another credential. In particular, an S3
 // access/secret pair without a session token must not inherit a stale token
diff --git a/catalog/rest/vended_creds_test.go 
b/catalog/rest/vended_creds_test.go
index 6a4ac6a1b..615f1eaa5 100644
--- a/catalog/rest/vended_creds_test.go
+++ b/catalog/rest/vended_creds_test.go
@@ -692,6 +692,196 @@ func TestPrefixScopedIOReplacesS3CredentialAtomically(t 
*testing.T) {
        assert.NotContains(t, props, keyS3TokenExpiresAtMs)
 }
 
+func TestPrefixScopedIOClearsStaleADLSAuthForVendedToken(t *testing.T) {
+       t.Parallel()
+
+       const (
+               host      = "acct.dfs.core.windows.net"
+               otherHost = "other.dfs.core.windows.net"
+       )
+
+       p := newPrefixScopedIO(context.Background(), iceberg.Properties{
+               iceio.ADLSEndpoint:                         
"dfs.core.windows.net",
+               iceio.ADLSSharedKeyAccountName:             "acct",
+               iceio.ADLSSharedKeyAccountKey:              "stale-key",
+               iceio.ADLSSasTokenPrefix + host:            "stale-sas",
+               keyAdlsSasExpiresAtMs + "." + host:         "1000",
+               keyAdlsSasExpiresAtMs:                      "1000",
+               iceio.ADLSConnectionStringPrefix + "acct":  
"stale-connection-string",
+               iceio.ADLSSasTokenPrefix + otherHost:       "other-sas",
+               keyAdlsSasExpiresAtMs + "." + otherHost:    "1000",
+               iceio.ADLSConnectionStringPrefix + "other": 
"other-connection-string",
+       }, []StorageCredential{{
+               Prefix: "abfss://container@" + host + "/",
+               Config: iceberg.Properties{iceio.ADLSToken: "vended-token"},
+       }})
+
+       props := p.propertiesForLocation("abfss://container@" + host + 
"/table/metadata.json")
+
+       // The vended token and unrelated tuning properties survive.
+       assert.Equal(t, "vended-token", props[iceio.ADLSToken])
+       assert.Equal(t, "dfs.core.windows.net", props[iceio.ADLSEndpoint])
+
+       // Everything that outranks adls.token in the Azure auth chain is gone, 
so the
+       // vended token is what actually authenticates the read.
+       assert.NotContains(t, props, iceio.ADLSSharedKeyAccountName)
+       assert.NotContains(t, props, iceio.ADLSSharedKeyAccountKey)
+       assert.NotContains(t, props, iceio.ADLSSasTokenPrefix+host)
+       assert.NotContains(t, props, keyAdlsSasExpiresAtMs+"."+host)
+       assert.NotContains(t, props, keyAdlsSasExpiresAtMs)
+       assert.NotContains(t, props, iceio.ADLSConnectionStringPrefix+"acct")
+
+       // Another account's SAS expiry would still set this IO's expiry, so it 
goes;
+       // its SAS token and connection string never apply here and are kept.
+       assert.NotContains(t, props, keyAdlsSasExpiresAtMs+"."+otherHost)
+       assert.Equal(t, "other-sas", props[iceio.ADLSSasTokenPrefix+otherHost])
+       assert.Equal(t, "other-connection-string", 
props[iceio.ADLSConnectionStringPrefix+"other"])
+}
+
+func TestPrefixScopedIOKeepsCredentialsWhenVendedADLSTokenIsEmpty(t 
*testing.T) {
+       t.Parallel()
+
+       const host = "acct.dfs.core.windows.net"
+
+       p := newPrefixScopedIO(context.Background(), iceberg.Properties{
+               iceio.ADLSSharedKeyAccountName:  "acct",
+               iceio.ADLSSharedKeyAccountKey:   "working-key",
+               iceio.ADLSSasTokenPrefix + host: "working-sas",
+       }, []StorageCredential{{
+               Prefix: "abfss://container@" + host + "/",
+               Config: iceberg.Properties{iceio.ADLSToken: ""},
+       }})
+
+       props := p.propertiesForLocation("abfss://container@" + host + 
"/table/metadata.json")
+
+       // The Azure bucket factory skips an empty adls.token, so clearing on 
key
+       // presence alone would discard these working credentials and fall 
through to
+       // a weaker auth method.
+       assert.Equal(t, "acct", props[iceio.ADLSSharedKeyAccountName])
+       assert.Equal(t, "working-key", props[iceio.ADLSSharedKeyAccountKey])
+       assert.Equal(t, "working-sas", props[iceio.ADLSSasTokenPrefix+host])
+}
+
+func TestVendedCredsRefreshClearsStaleADLSAuth(t *testing.T) {
+       const (
+               scheme    = "vended-adls-refresh-test"
+               host      = "acct.dfs.core.windows.net"
+               otherHost = "other.dfs.core.windows.net"
+       )
+
+       // loadFS calls the factory synchronously, so captured needs no lock.
+       var captured iceberg.Properties
+       iceio.Register(scheme, func(_ context.Context, _ *url.URL, props 
map[string]string) (iceio.IO, error) {
+               captured = iceberg.Properties(props)
+
+               return iceio.LocalFS{}, nil
+       })
+       t.Cleanup(func() { iceio.Unregister(scheme) })
+
+       r := newTestRefresher(func(context.Context, []string) 
(iceberg.Properties, error) {
+               return iceberg.Properties{iceio.ADLSToken: "fresh-token"}, nil
+       })
+       r.location = scheme + "://container@" + host + "/table/metadata.json"
+       r.props = iceberg.Properties{
+               iceio.ADLSSharedKeyAccountName:       "acct",
+               iceio.ADLSSharedKeyAccountKey:        "stale-key",
+               iceio.ADLSSasTokenPrefix + host:      "stale-sas",
+               iceio.ADLSSasTokenPrefix + otherHost: "other-sas",
+       }
+       // Force the refresh branch: a cached IO whose credentials have expired.
+       r.cachedIO = iceio.LocalFS{}
+       r.expiresAt = time.Now().Add(-time.Hour)
+
+       _, err := r.loadFS(context.Background())
+       require.NoError(t, err)
+
+       // The refresher merges through mergeVendedCredentials, so the freshly 
vended
+       // token is not shadowed by credentials inherited from the catalog 
config.
+       assert.Equal(t, "fresh-token", captured[iceio.ADLSToken])
+       assert.NotContains(t, captured, iceio.ADLSSharedKeyAccountName)
+       assert.NotContains(t, captured, iceio.ADLSSharedKeyAccountKey)
+       assert.NotContains(t, captured, iceio.ADLSSasTokenPrefix+host)
+       assert.Equal(t, "other-sas", 
captured[iceio.ADLSSasTokenPrefix+otherHost])
+}
+
+// TestVendedCredsRefreshedADLSTokenExpiry checks that a vended adls.token gets
+// its expiry from expiration-time, or the default TTL without one, and never
+// from an inherited expiry, whether a SAS expiry for its own account or 
another
+// or an expiry for another kind of credential, which would otherwise win as 
the
+// earliest expiry. An already-expired one would make every
+// later filesystem load fetch credentials and rebuild the IO again.
+func TestVendedCredsRefreshedADLSTokenExpiry(t *testing.T) {
+       const (
+               scheme    = "vended-adls-expiry-test"
+               host      = "acct.dfs.core.windows.net"
+               otherHost = "other.dfs.core.windows.net"
+       )
+
+       iceio.Register(scheme, func(context.Context, *url.URL, 
map[string]string) (iceio.IO, error) {
+               return iceio.LocalFS{}, nil
+       })
+       t.Cleanup(func() { iceio.Unregister(scheme) })
+
+       now := time.Now()
+       tokenExpiry := now.Add(45 * time.Minute)
+       staleExpiry := strconv.FormatInt(now.Add(-time.Hour).UnixMilli(), 10)
+
+       for _, tc := range []struct {
+               name   string
+               vended iceberg.Properties
+               want   time.Time
+       }{
+               {
+                       name: "expiration-time is honored",
+                       vended: iceberg.Properties{
+                               iceio.ADLSToken:   "fresh-token",
+                               keyExpirationTime: 
strconv.FormatInt(tokenExpiry.UnixMilli(), 10),
+                       },
+                       want: tokenExpiry,
+               },
+               {
+                       name:   "no expiry falls back to the default TTL",
+                       vended: iceberg.Properties{iceio.ADLSToken: 
"fresh-token"},
+                       want:   now.Add(defaultVendedCredentialsTTL),
+               },
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       var fetches atomic.Int32
+                       r := newTestRefresher(func(context.Context, []string) 
(iceberg.Properties, error) {
+                               fetches.Add(1)
+
+                               return tc.vended, nil
+                       })
+                       r.nowFunc = func() time.Time { return now }
+                       r.location = scheme + "://container@" + host + 
"/table/metadata.json"
+                       r.props = iceberg.Properties{
+                               iceio.ADLSSasTokenPrefix + host:    "stale-sas",
+                               keyAdlsSasExpiresAtMs + "." + host: staleExpiry,
+                               keyAdlsSasExpiresAtMs:              staleExpiry,
+                               keyS3TokenExpiresAtMs:              staleExpiry,
+                               keyGcsOAuthExpiresAt:               staleExpiry,
+                               // Overwritten when the token is vended with 
its own expiration-time.
+                               keyExpirationTime:                       
staleExpiry,
+                               iceio.ADLSSasTokenPrefix + otherHost:    
"other-sas",
+                               keyAdlsSasExpiresAtMs + "." + otherHost: 
staleExpiry,
+                       }
+                       // Force the refresh branch: a cached IO whose 
credentials have expired.
+                       r.cachedIO = iceio.LocalFS{}
+                       r.expiresAt = now.Add(-time.Minute)
+
+                       _, err := r.loadFS(context.Background())
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.want.UnixMilli(), 
r.expiresAt.UnixMilli())
+
+                       // The token is still valid, so the next load reuses 
the IO instead of
+                       // fetching credentials again.
+                       _, err = r.loadFS(context.Background())
+                       require.NoError(t, err)
+                       assert.Equal(t, int32(1), fetches.Load())
+               })
+       }
+}
+
 func TestPrefixScopedIODoesNotHoldLockDuringFilesystemLoad(t *testing.T) {
        loadStarted := make(chan struct{})
        releaseLoad := make(chan struct{})
diff --git a/internal/adlsauth/adlsauth.go b/internal/adlsauth/adlsauth.go
new file mode 100644
index 000000000..ec14e7c23
--- /dev/null
+++ b/internal/adlsauth/adlsauth.go
@@ -0,0 +1,83 @@
+// 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 adlsauth holds the part of the Azure FileIO's authentication order
+// that code outside io/gocloud/azure has to agree with. The REST catalog uses 
it
+// to keep a vended adls.token from being shadowed by inherited credentials, 
and
+// the io/gocloud/azure tests pin it against the bucket factory's actual
+// selection, so a change to the factory's order fails a test instead of
+// silently shadowing the token again.
+package adlsauth
+
+import (
+       "net/url"
+       "strings"
+
+       iceio "github.com/apache/iceberg-go/io"
+)
+
+// Account identifies the storage account an ADLS location authenticates
+// against, in the two forms the Azure bucket factory keys per-account
+// credentials by.
+type Account struct {
+       // Host keys SAS tokens, for example myaccount.dfs.core.windows.net.
+       Host string
+       // Name keys connection strings, for example myaccount.
+       Name string
+}
+
+// AccountFor returns the storage account for location, or false if location
+// has no account.
+func AccountFor(location string) (Account, bool) {
+       parsed, err := url.Parse(location)
+       if err != nil {
+               return Account{}, false
+       }
+
+       return AccountForURL(parsed)
+}
+
+// AccountForURL returns the storage account for a parsed location, or false if
+// it has no account. The Azure bucket factory derives its account through this
+// too, so credentials are cleared under the same keys they are looked up by.
+func AccountForURL(parsed *url.URL) (Account, bool) {
+       host := parsed.Hostname()
+       name, _, _ := strings.Cut(host, ".")
+       if name == "" {
+               return Account{}, false
+       }
+
+       return Account{Host: host, Name: name}, true
+}
+
+// ClearTokenShadowingCredentials deletes from props, the properties of an IO
+// for acct, the credentials the Azure bucket factory selects ahead of a
+// nonempty adls.token: any shared key, the SAS token for acct's host and the
+// connection string for acct's name. SAS tokens and connection strings for 
other
+// accounts are keyed by account, never apply to acct, and are kept.
+//
+// The shared key is cleared whichever account it names, because the factory
+// does not scope it: a configured shared key signs requests to every account,
+// so one for another account would sign acct's requests with the wrong key.
+// Callers pass a copy, so the shared key stays in the configuration other
+// accounts' IO is built from.
+func ClearTokenShadowingCredentials(props map[string]string, acct Account) {
+       delete(props, iceio.ADLSSharedKeyAccountName)
+       delete(props, iceio.ADLSSharedKeyAccountKey)
+       delete(props, iceio.ADLSSasTokenPrefix+acct.Host)
+       delete(props, iceio.ADLSConnectionStringPrefix+acct.Name)
+}
diff --git a/io/config.go b/io/config.go
index 39a6602d3..bae44883c 100644
--- a/io/config.go
+++ b/io/config.go
@@ -64,9 +64,9 @@ const (
        // managed identity.
        //
        // It is evaluated only after shared-key credentials, a SAS-token 
property for
-       // the target hostname, and a connection-string property for the target 
account
-       // name, all of which take precedence over it; if any of them is 
configured,
-       // this flag has no effect.
+       // the target hostname, a connection-string property for the target 
account
+       // name, and a nonempty ADLSToken, all of which take precedence over 
it; if any
+       // of them is configured, this flag has no effect.
        //
        // This property is specific to this implementation. Neither the Java 
nor the
        // PyIceberg implementation defines an equivalent property, so catalog 
properties
@@ -85,6 +85,16 @@ const (
        // "adls.client-id" at all.
        ADLSClientID = "adls.client-id"
 
+       // ADLSToken is a static OAuth2 access token for Azure Storage, without 
the
+       // "Bearer " prefix. A nonempty token takes precedence over managed 
identity
+       // and the default credential chain, but not shared-key credentials, a 
SAS-token
+       // property for the target hostname, or a connection-string property 
for the
+       // target account name. An empty token is ignored.
+       //
+       // The token is not refreshed. Azure enforces its expiration; callers 
must
+       // recreate the FileIO with a new token when it expires.
+       ADLSToken = "adls.token"
+
        // Not in use yet
        // ADLSReadBlockSize          = "adls.read.block-size-bytes"
        // ADLSWriteBlockSize         = "adls.write.block-size-bytes"
diff --git a/io/gocloud/azure/azure.go b/io/gocloud/azure/azure.go
index c8af8b990..8a8b7eab1 100644
--- a/io/gocloud/azure/azure.go
+++ b/io/gocloud/azure/azure.go
@@ -24,11 +24,14 @@ import (
        "net/url"
        "regexp"
        "strings"
+       "time"
 
        "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+       "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
        "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
        "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
        "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
+       "github.com/apache/iceberg-go/internal/adlsauth"
        "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/io/gocloud/blobfs"
        "gocloud.dev/blob"
@@ -52,6 +55,17 @@ type azureCredentialFactories struct {
        newManagedIdentity   func(*azidentity.ManagedIdentityCredentialOptions) 
(azcore.TokenCredential, error)
 }
 
+type staticTokenCredential struct {
+       token string
+}
+
+func (c staticTokenCredential) GetToken(context.Context, 
policy.TokenRequestOptions) (azcore.AccessToken, error) {
+       // adls.token carries no expiration metadata. Like Java and PyIceberg, 
use a
+       // one-hour SDK cache window. This does not refresh the token or extend 
its
+       // actual validity, which Azure enforces.
+       return azcore.AccessToken{Token: c.token, ExpiresOn: 
time.Now().Add(time.Hour)}, nil
+}
+
 func productionAzureCredentialFactories() azureCredentialFactories {
        return azureCredentialFactories{
                newDefaultCredential: func(opts 
*azidentity.DefaultAzureCredentialOptions) (azcore.TokenCredential, error) {
@@ -102,38 +116,37 @@ func newAdlsLocation(adlsURI *url.URL) (*adlsLocation, 
error) {
                return nil, errors.New("container name is required for azure 
bucket")
        }
 
-       hostname := adlsURI.Hostname()
-       if hostname == "" {
+       if adlsURI.Hostname() == "" {
                return nil, errors.New("hostname is required for azure bucket")
        }
 
-       // Extract account name from hostname (ignore the storage domain)
-       parts := strings.Split(hostname, ".")
-       if len(parts) == 0 || parts[0] == "" {
+       // The account name is the hostname's first label (ignore the storage 
domain).
+       acct, ok := adlsauth.AccountForURL(adlsURI)
+       if !ok {
                return nil, errors.New("account name is required for azure 
bucket")
        }
-       accountName := parts[0]
 
        path := adlsURI.Path
 
        return &adlsLocation{
-               accountName:   accountName,
+               accountName:   acct.Name,
                containerName: containerName,
-               hostname:      hostname,
+               hostname:      acct.Host,
                path:          path,
        }, nil
 }
 
 // Construct a Azure bucket from a URL
 func createAzureBucket(ctx context.Context, parsed *url.URL, props 
map[string]string) (*blob.Bucket, error) {
-       return createAzureBucketWithCredentialFactories(ctx, parsed, props, 
productionAzureCredentialFactories())
+       return createAzureBucketWithOptions(ctx, parsed, props, 
productionAzureCredentialFactories(), nil)
 }
 
-func createAzureBucketWithCredentialFactories(
+func createAzureBucketWithOptions(
        ctx context.Context,
        parsed *url.URL,
        props map[string]string,
        credentialFactories azureCredentialFactories,
+       clientOptions *container.ClientOptions,
 ) (*blob.Bucket, error) {
        adlsSasTokens := propertiesWithPrefix(props, io.ADLSSasTokenPrefix)
        adlsConnectionStrings := propertiesWithPrefix(props, 
io.ADLSConnectionStringPrefix)
@@ -166,7 +179,7 @@ func createAzureBucketWithCredentialFactories(
                        return nil, fmt.Errorf("failed 
azblob.NewSharedKeyCredential: %w", err)
                }
 
-               client, err = 
container.NewClientWithSharedKeyCredential(containerURL, sharedKeyCred, nil)
+               client, err = 
container.NewClientWithSharedKeyCredential(containerURL, sharedKeyCred, 
clientOptions)
                if err != nil {
                        return nil, fmt.Errorf("failed 
container.NewClientWithSharedKeyCredential: %w", err)
                }
@@ -176,16 +189,26 @@ func createAzureBucketWithCredentialFactories(
                        return nil, err
                }
 
-               client, err = container.NewClientWithNoCredential(containerURL, 
nil)
+               client, err = container.NewClientWithNoCredential(containerURL, 
clientOptions)
                if err != nil {
                        return nil, fmt.Errorf("failed 
container.NewClientWithNoCredential: %w", err)
                }
        } else if connectionString, ok := 
adlsConnectionStrings[location.accountName]; ok {
                var err error
-               client, err = 
container.NewClientFromConnectionString(connectionString, 
location.containerName, nil)
+               client, err = 
container.NewClientFromConnectionString(connectionString, 
location.containerName, clientOptions)
                if err != nil {
                        return nil, fmt.Errorf("failed 
container.NewClientFromConnectionString: %w", err)
                }
+       } else if token := props[io.ADLSToken]; token != "" {
+               containerURL, err := createContainerURL(location.accountName, 
protocol, endpoint, "", location.containerName)
+               if err != nil {
+                       return nil, err
+               }
+
+               client, err = container.NewClient(containerURL, 
staticTokenCredential{token: token}, clientOptions)
+               if err != nil {
+                       return nil, fmt.Errorf("failed container.NewClient: 
%w", err)
+               }
        } else if props[io.ADLSManagedIdentityEnabled] == "true" {
                containerURL, err := createContainerURL(location.accountName, 
protocol, endpoint, "", location.containerName)
                if err != nil {
@@ -204,7 +227,7 @@ func createAzureBucketWithCredentialFactories(
                        return nil, fmt.Errorf("failed 
azidentity.NewManagedIdentityCredential: %w", err)
                }
 
-               client, err = container.NewClient(containerURL, cred, nil)
+               client, err = container.NewClient(containerURL, cred, 
clientOptions)
                if err != nil {
                        return nil, fmt.Errorf("failed container.NewClient: 
%w", err)
                }
@@ -219,7 +242,7 @@ func createAzureBucketWithCredentialFactories(
                        return nil, fmt.Errorf("failed 
azidentity.NewDefaultAzureCredential: %w", err)
                }
 
-               client, err = container.NewClient(containerURL, cred, nil)
+               client, err = container.NewClient(containerURL, cred, 
clientOptions)
                if err != nil {
                        return nil, fmt.Errorf("failed container.NewClient: 
%w", err)
                }
diff --git a/io/gocloud/azure/azure_test.go b/io/gocloud/azure/azure_test.go
index 496480df3..a51c945ab 100644
--- a/io/gocloud/azure/azure_test.go
+++ b/io/gocloud/azure/azure_test.go
@@ -80,7 +80,7 @@ func TestCreateAzureBucketDefaultCredentialCalled(t 
*testing.T) {
                },
        }
 
-       bucket, err := createAzureBucketWithCredentialFactories(ctx, parsedURL, 
nil, credentialFactories)
+       bucket, err := createAzureBucketWithOptions(ctx, parsedURL, nil, 
credentialFactories, nil)
 
        require.NoError(t, err)
        assert.NotNil(t, bucket)
@@ -160,7 +160,7 @@ func TestCreateAzureBucketManagedIdentityCredentialCalled(t 
*testing.T) {
                                },
                        }
 
-                       bucket, err := 
createAzureBucketWithCredentialFactories(ctx, parsedURL, test.props, 
credentialFactories)
+                       bucket, err := createAzureBucketWithOptions(ctx, 
parsedURL, test.props, credentialFactories, nil)
 
                        require.NoError(t, err)
                        assert.NotNil(t, bucket)
diff --git a/io/gocloud/azure/token_test.go b/io/gocloud/azure/token_test.go
new file mode 100644
index 000000000..2fde1f744
--- /dev/null
+++ b/io/gocloud/azure/token_test.go
@@ -0,0 +1,346 @@
+// 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 azure
+
+import (
+       "context"
+       "io"
+       "maps"
+       "net/http"
+       "net/url"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+       "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake"
+       "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+       "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+       "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container"
+       "github.com/apache/iceberg-go/internal/adlsauth"
+       icebergio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+type tokenTestTransport func(*http.Request) (*http.Response, error)
+
+func (f tokenTestTransport) Do(req *http.Request) (*http.Response, error) {
+       return f(req)
+}
+
+func TestStaticTokenCredential(t *testing.T) {
+       // Treat access tokens as opaque: this need not be a parseable JWT.
+       credential := staticTokenCredential{token: "opaque-access-token"}
+       for range 2 {
+               before := time.Now().Add(time.Hour)
+               token, err := credential.GetToken(t.Context(), 
policy.TokenRequestOptions{
+                       Scopes: []string{"https://storage.azure.com/.default"},
+               })
+               after := time.Now().Add(time.Hour)
+               require.NoError(t, err)
+               assert.Equal(t, "opaque-access-token", token.Token)
+               assert.False(t, token.ExpiresOn.Before(before))
+               assert.False(t, token.ExpiresOn.After(after))
+       }
+}
+
+func TestAzureTokenAuthentication(t *testing.T) {
+       const token = "opaque-access-token"
+       tests := []struct {
+               name            string
+               props           map[string]string
+               wantAuthPrefix  string
+               wantSAS         string
+               wantDefault     bool
+               wantManaged     bool
+               wantCreateError string
+               rejectToken     bool
+       }{
+               {
+                       name:           "static token",
+                       props:          map[string]string{icebergio.ADLSToken: 
token},
+                       wantAuthPrefix: "Bearer " + token,
+               },
+               {
+                       name: "token precedes managed identity",
+                       props: map[string]string{
+                               icebergio.ADLSToken:                  token,
+                               icebergio.ADLSManagedIdentityEnabled: "true",
+                               icebergio.ADLSClientID:               
"managed-identity-client-id",
+                       },
+                       wantAuthPrefix: "Bearer " + token,
+               },
+               {
+                       name: "unrelated SAS and connection string do not 
override token",
+                       props: map[string]string{
+                               icebergio.ADLSToken: token,
+                               icebergio.ADLSSasTokenPrefix + 
"other.dfs.core.windows.net": "sig=other",
+                               icebergio.ADLSConnectionStringPrefix + "other": 
             "invalid-connection-string",
+                       },
+                       wantAuthPrefix: "Bearer " + token,
+               },
+               {
+                       name: "shared key precedes token",
+                       props: map[string]string{
+                               icebergio.ADLSToken:                token,
+                               icebergio.ADLSSharedKeyAccountName: 
"testaccount",
+                               icebergio.ADLSSharedKeyAccountKey:  "YQ==",
+                       },
+                       wantAuthPrefix: "SharedKey testaccount:",
+               },
+               {
+                       name: "SAS precedes token",
+                       props: map[string]string{
+                               icebergio.ADLSToken: token,
+                               icebergio.ADLSSasTokenPrefix + 
"testaccount.dfs.core.windows.net": "sig=sas-signature",
+                       },
+                       wantSAS: "sas-signature",
+               },
+               {
+                       name: "connection string precedes token",
+                       props: map[string]string{
+                               icebergio.ADLSToken: token,
+                               icebergio.ADLSConnectionStringPrefix + 
"testaccount": 
"DefaultEndpointsProtocol=https;AccountName=connectionaccount;AccountKey=YQ==;EndpointSuffix=core.windows.net",
+                       },
+                       wantAuthPrefix: "SharedKey connectionaccount:",
+               },
+               {
+                       name:           "empty token uses default chain",
+                       props:          map[string]string{icebergio.ADLSToken: 
""},
+                       wantAuthPrefix: "Bearer fake_token",
+                       wantDefault:    true,
+               },
+               {
+                       name: "empty token uses managed identity when enabled",
+                       props: map[string]string{
+                               icebergio.ADLSToken:                  "",
+                               icebergio.ADLSManagedIdentityEnabled: "true",
+                       },
+                       wantAuthPrefix: "Bearer fake_token",
+                       wantManaged:    true,
+               },
+               {
+                       name: "incomplete shared key does not fall back to 
token",
+                       props: map[string]string{
+                               icebergio.ADLSToken:                token,
+                               icebergio.ADLSSharedKeyAccountName: 
"testaccount",
+                       },
+                       wantCreateError: "shared-key requires both",
+               },
+               {
+                       name: "invalid connection string does not fall back to 
token",
+                       props: map[string]string{
+                               icebergio.ADLSToken: token,
+                               icebergio.ADLSConnectionStringPrefix + 
"testaccount": "invalid-connection-string",
+                       },
+                       wantCreateError: "failed 
container.NewClientFromConnectionString",
+               },
+               {
+                       name: "rejected token does not fall back to managed 
identity",
+                       props: map[string]string{
+                               icebergio.ADLSToken:                  token,
+                               icebergio.ADLSManagedIdentityEnabled: "true",
+                       },
+                       wantAuthPrefix: "Bearer " + token,
+                       rejectToken:    true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       parsed, err := 
url.Parse("abfss://[email protected]/path")
+                       require.NoError(t, err)
+                       defaultCalled, managedCalled := false, false
+                       factories := azureCredentialFactories{
+                               newDefaultCredential: 
func(*azidentity.DefaultAzureCredentialOptions) (azcore.TokenCredential, error) 
{
+                                       defaultCalled = true
+
+                                       return &fake.TokenCredential{}, nil
+                               },
+                               newManagedIdentity: 
func(*azidentity.ManagedIdentityCredentialOptions) (azcore.TokenCredential, 
error) {
+                                       managedCalled = true
+
+                                       return &fake.TokenCredential{}, nil
+                               },
+                       }
+                       requests := 0
+                       options := &container.ClientOptions{ClientOptions: 
azcore.ClientOptions{
+                               Retry: policy.RetryOptions{MaxRetries: -1},
+                               Transport: tokenTestTransport(func(req 
*http.Request) (*http.Response, error) {
+                                       requests++
+                                       auth := req.Header.Get("Authorization")
+                                       if strings.HasPrefix(tt.wantAuthPrefix, 
"SharedKey ") {
+                                               assert.True(t, 
strings.HasPrefix(auth, tt.wantAuthPrefix))
+                                       } else {
+                                               assert.Equal(t, 
tt.wantAuthPrefix, auth)
+                                       }
+                                       assert.Equal(t, tt.wantSAS, 
req.URL.Query().Get("sig"))
+                                       assert.NotContains(t, req.URL.String(), 
token)
+                                       status := http.StatusOK
+                                       header := http.Header{}
+                                       header.Set("Last-Modified", "Mon, 21 
Sep 2026 00:00:00 GMT")
+                                       header.Set("x-ms-creation-time", "Mon, 
21 Sep 2026 00:00:00 GMT")
+                                       if tt.rejectToken {
+                                               status = http.StatusUnauthorized
+                                               header.Set("x-ms-error-code", 
"AuthenticationFailed")
+                                       }
+
+                                       return &http.Response{
+                                               StatusCode: status,
+                                               Header:     header,
+                                               Body:       
io.NopCloser(strings.NewReader("")),
+                                               Request:    req,
+                                       }, nil
+                               }),
+                       }}
+                       bucket, err := 
createAzureBucketWithOptions(t.Context(), parsed, tt.props, factories, options)
+                       if tt.wantCreateError != "" {
+                               require.ErrorContains(t, err, 
tt.wantCreateError)
+                               assert.NotContains(t, err.Error(), token)
+                               assert.Zero(t, requests)
+                       } else {
+                               require.NoError(t, err)
+                               defer bucket.Close()
+                               exists, err := 
bucket.Exists(context.Background(), "file.parquet")
+                               if tt.rejectToken {
+                                       require.Error(t, err)
+                                       var responseErr *azcore.ResponseError
+                                       require.ErrorAs(t, err, &responseErr)
+                                       assert.Equal(t, 
http.StatusUnauthorized, responseErr.StatusCode)
+                                       assert.NotContains(t, err.Error(), 
token)
+                               } else {
+                                       require.NoError(t, err)
+                                       assert.True(t, exists)
+                               }
+                               assert.Equal(t, 1, requests)
+                       }
+                       assert.Equal(t, tt.wantDefault, defaultCalled)
+                       assert.Equal(t, tt.wantManaged, managedCalled)
+               })
+       }
+}
+
+// TestClearTokenShadowingCredentialsSelectsToken pins adlsauth, which the REST
+// catalog uses to keep a vended adls.token from being shadowed, to this
+// package's precedence order: once it has cleared a location's account, the
+// factory must authenticate with the token. If the factory gains or reorders 
an
+// auth method ahead of the token, this fails instead of the token silently
+// losing to a stale credential.
+func TestClearTokenShadowingCredentialsSelectsToken(t *testing.T) {
+       const (
+               token    = "opaque-access-token"
+               location = 
"abfss://[email protected]/path"
+       )
+       const wantAuth = "Bearer " + token
+       tests := []struct {
+               name  string
+               props map[string]string
+       }{
+               {
+                       name: "shared key for the account",
+                       props: map[string]string{
+                               icebergio.ADLSSharedKeyAccountName: 
"testaccount",
+                               icebergio.ADLSSharedKeyAccountKey:  "YQ==",
+                       },
+               },
+               {
+                       name: "SAS for the account",
+                       props: map[string]string{
+                               icebergio.ADLSSasTokenPrefix + 
"testaccount.dfs.core.windows.net": "sig=sas-signature",
+                       },
+               },
+               {
+                       name: "connection string for the account",
+                       props: map[string]string{
+                               icebergio.ADLSConnectionStringPrefix + 
"testaccount": 
"DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=YQ==;EndpointSuffix=core.windows.net",
+                       },
+               },
+               {
+                       name: "every method for the account, plus another 
account's",
+                       props: map[string]string{
+                               icebergio.ADLSSharedKeyAccountName:             
                   "testaccount",
+                               icebergio.ADLSSharedKeyAccountKey:              
                   "YQ==",
+                               icebergio.ADLSSasTokenPrefix + 
"testaccount.dfs.core.windows.net": "sig=sas-signature",
+                               icebergio.ADLSConnectionStringPrefix + 
"testaccount":              "invalid-connection-string",
+                               icebergio.ADLSSasTokenPrefix + 
"other.dfs.core.windows.net":       "sig=other",
+                               icebergio.ADLSConnectionStringPrefix + "other": 
                   "invalid-connection-string",
+                       },
+               },
+               {
+                       // The factory applies a shared key to every account, 
so one naming
+                       // another account would otherwise sign this account's 
requests.
+                       name: "shared key for another account",
+                       props: map[string]string{
+                               icebergio.ADLSSharedKeyAccountName: 
"otheraccount",
+                               icebergio.ADLSSharedKeyAccountKey:  "YQ==",
+                       },
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       acct, ok := adlsauth.AccountFor(location)
+                       require.True(t, ok)
+                       props := map[string]string{icebergio.ADLSToken: token}
+                       maps.Copy(props, tt.props)
+                       adlsauth.ClearTokenShadowingCredentials(props, acct)
+
+                       parsed, err := url.Parse(location)
+                       require.NoError(t, err)
+                       factories := azureCredentialFactories{
+                               newDefaultCredential: 
func(*azidentity.DefaultAzureCredentialOptions) (azcore.TokenCredential, error) 
{
+                                       t.Error("default credential chain must 
not be selected")
+
+                                       return &fake.TokenCredential{}, nil
+                               },
+                               newManagedIdentity: 
func(*azidentity.ManagedIdentityCredentialOptions) (azcore.TokenCredential, 
error) {
+                                       t.Error("managed identity must not be 
selected")
+
+                                       return &fake.TokenCredential{}, nil
+                               },
+                       }
+                       requests := 0
+                       options := &container.ClientOptions{ClientOptions: 
azcore.ClientOptions{
+                               Retry: policy.RetryOptions{MaxRetries: -1},
+                               Transport: tokenTestTransport(func(req 
*http.Request) (*http.Response, error) {
+                                       requests++
+                                       assert.Equal(t, wantAuth, 
req.Header.Get("Authorization"))
+                                       assert.Empty(t, 
req.URL.Query().Get("sig"))
+                                       header := http.Header{}
+                                       header.Set("Last-Modified", "Mon, 21 
Sep 2026 00:00:00 GMT")
+                                       header.Set("x-ms-creation-time", "Mon, 
21 Sep 2026 00:00:00 GMT")
+
+                                       return &http.Response{
+                                               StatusCode: http.StatusOK,
+                                               Header:     header,
+                                               Body:       
io.NopCloser(strings.NewReader("")),
+                                               Request:    req,
+                                       }, nil
+                               }),
+                       }}
+
+                       bucket, err := 
createAzureBucketWithOptions(t.Context(), parsed, props, factories, options)
+                       require.NoError(t, err)
+                       defer bucket.Close()
+                       _, err = bucket.Exists(t.Context(), "file.parquet")
+                       require.NoError(t, err)
+                       assert.Equal(t, 1, requests)
+               })
+       }
+}
diff --git a/website/src/configuration.md b/website/src/configuration.md
index 0faebce78..a249117ca 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -227,15 +227,22 @@ Tuning properties:
 
 ### Azure Data Lake Storage / Blob
 
-Authentication is selected in the following order 
(`io/gocloud/azure/azure.go`); the first applicable option wins, and if none of 
1-4 apply the default credential chain (5) is used:
+Authentication is selected in the following order 
(`io/gocloud/azure/azure.go`); the first applicable option wins, and if none of 
1-5 apply the default credential chain (6) is used:
 
 1. Shared key: a nonempty `adls.auth.shared-key.account.name` selects this 
path and requires a nonempty `adls.auth.shared-key.account.key`.
 2. Per-host SAS token: `adls.sas-token.<hostname>`, where `<hostname>` exactly 
matches the storage account hostname (for example, 
`myaccount.dfs.core.windows.net`).
 3. Per-account connection string: `adls.connection-string.<account-name>` (for 
example, `adls.connection-string.myaccount`).
-4. Managed identity: `adls.auth.managed-identity.enabled` set to exactly 
`"true"` selects this path and uses `ManagedIdentityCredential` directly. Set 
`adls.client-id` to select a user-assigned managed identity; otherwise, the 
system-assigned managed identity is used.
-5. Default credential chain: `DefaultAzureCredential`, which includes managed 
identity among its credential sources.
+4. Static access token: a nonempty `adls.token`, supplied without the `Bearer 
` prefix.
+5. Managed identity: `adls.auth.managed-identity.enabled` set to exactly 
`"true"` selects this path and uses `ManagedIdentityCredential` directly. Set 
`adls.client-id` to select a user-assigned managed identity; otherwise, the 
system-assigned managed identity is used.
+6. Default credential chain: `DefaultAzureCredential`, which includes managed 
identity among its credential sources.
 
-> **Note on cross-client parity.** The two properties in step 4 diverge from 
the
+`adls.token` accepts an OAuth2 access token for Azure Storage, including a 
token supplied by a catalog. An empty value is ignored. A rejected or expired 
token does not fall back to managed identity or the default credential chain.
+
+The token is static and is not refreshed. As in 
[Java](https://github.com/apache/iceberg/blob/main/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java)
 and 
[PyIceberg](https://github.com/apache/iceberg-python/blob/main/pyiceberg/io/fsspec.py),
 the credential reports a one-hour cache window to the Azure SDK each time the 
SDK requests the token. This does not extend the token's actual validity: Azure 
enforces its expiration. Callers must obtain a new token and recreate the Fi 
[...]
+
+A REST catalog can also vend `adls.token` as a storage credential. The token 
carries no expiry of its own, so a vended token's expiry comes from 
`expiration-time` in the same credential when the catalog supplies one. Without 
it, a table FileIO that can fetch new credentials assumes a 60-minute lifetime 
and renews them shortly before it ends, and one that cannot, such as a FileIO 
for a scan plan's credentials, treats them as never expiring. A token whose 
actual lifetime is shorter than th [...]
+
+> **Note on cross-client parity.** The two properties in step 5 diverge from 
the
 > other clients. `adls.auth.managed-identity.enabled` is Go-specific: neither
 > Iceberg Java nor PyIceberg defines a property that forces
 > `ManagedIdentityCredential`, so catalog properties that set it do not carry
@@ -244,7 +251,7 @@ Authentication is selected in the following order 
(`io/gocloud/azure/azure.go`);
 > `adls.tenant-id`, neither of which iceberg-go defines (see the
 > [PyIceberg ADLS 
 > configuration](https://py.iceberg.apache.org/configuration/#azure-data-lake)),
 > while Java's `AzureProperties` defines no `adls.client-id` at all. Here it is
-> only ever read as a user-assigned managed identity ID, and only when step 4 
is
+> only ever read as a user-assigned managed identity ID, and only when step 5 
is
 > selected.
 
 Tuning properties:
@@ -255,6 +262,7 @@ Tuning properties:
 | `adls.auth.shared-key.account.key` (`io.ADLSSharedKeyAccountKey`) | Account 
key. |
 | `adls.sas-token.<host>` (prefix `io.ADLSSasTokenPrefix`) | Per-host SAS 
token. |
 | `adls.connection-string.<account-name>` (prefix 
`io.ADLSConnectionStringPrefix`) | Per-account connection string. |
+| `adls.token` (`io.ADLSToken`) | Static Azure Storage access token, without 
the `Bearer ` prefix. Subject to the precedence above; not refreshed. |
 | `adls.client-id` (`io.ADLSClientID`) | Client ID of a user-assigned managed 
identity. Used only when the explicit managed-identity authentication path is 
selected. |
 | `adls.endpoint` (`io.ADLSEndpoint`) | Storage domain (e.g. 
`blob.core.windows.net`). |
 | `adls.protocol` (`io.ADLSProtocol`) | `http` or `https`. |

Reply via email to