Files
Nguyễn Đăng Minh LựcandGitHub c968084b34 iceberg: fix OAuth token expiry handling (401 + token-exchange + configurable TTL) (#11242)
* iceberg: return 401 for invalid or expired Bearer tokens

BUG-0001: when the OAuth JWT expired, Server.Auth fell through to the S3
SigV4 authenticator, which rejects the "Authorization: Bearer" scheme
with NotImplemented — a 501. Iceberg clients (Java OAuth2Manager,
pyiceberg) only refresh tokens on 401, so they retried the dead token
forever: RisingWave sinks stalled and Doris catalog queries failed every
token TTL (1h) until the client process was restarted.

A request carrying a Bearer header is an Iceberg REST client: answer 401
(+ WWW-Authenticate: Bearer, RFC 6750) when the token fails, and only
fall through to the S3 authenticator when no Bearer header is present.

* iceberg: make OAuth token TTL configurable via ICEBERG_OAUTH_TOKEN_EXPIRY

BUG-0001 follow-up: production evidence shows Iceberg Java 1.10.x
clients (RisingWave connector node, Doris FE) never re-fetch tokens on
401 — the sink stalled again on token expiry even with the 501→401 fix,
and no POST /v1/oauth/tokens appeared in server logs across dozens of
retries. 401 is necessary but not sufficient for these clients.

The TTL was hardcoded to 3600 with no knob. Read the expiry (seconds)
from ICEBERG_OAUTH_TOKEN_EXPIRY, defaulting to 3600, so deployments can
issue longer-lived tokens (e.g. 86400) to survive client restart cycles.

* iceberg: support OAuth token exchange (RFC 8693) for client refresh

Decompiling the Iceberg Java 1.10.1 client bundled with Doris FE showed
the missing half of BUG-0001: OAuth2Manager refreshes via token-exchange
(AuthConfig.exchangeEnabled defaults to true — the client_credentials
re-fetch branch only runs with exchange disabled), so a server that only
accepts client_credentials leaves Iceberg clients unable to ever refresh
their token, regardless of 401 correctness.

Accept grant_type=urn:ietf:params:oauth:grant-type:token-exchange on
POST /v1/oauth/tokens: verify the subject_token signature against the
issuing credential, allow exchange within a recovery grace window
(max(2*TTL, 1h), capped 24h) so clients holding tokens that expired
while the grant was unsupported recover without a restart, and mint a
fresh access token with the configured TTL.

* iceberg: harden OAuth token exchange and Bearer matching per review

- match the Bearer scheme case-insensitively (RFC 7235), like
  authenticateBearer already does
- accept optional client authentication on the token-exchange grant
  (Basic or form credentials, bound to the subject token's client);
  expired subject tokens now require it. Iceberg Java's proactive
  refresh sends Bearer-only headers, so the grant cannot require it
- reject subject tokens without an exp claim, and re-check the issuer
  on the verified claims
- unauthenticated exchange cannot extend the lifetime past the
  subject token's own expiry (no chain-refresh from a leaked token)
- return 400 invalid_grant per RFC 6749 §5.2 (was 401)
- include issued_token_type on exchange responses (RFC 8693)
- clamp ICEBERG_OAUTH_TOKEN_EXPIRY to 365d so Duration math cannot
  overflow into already-expired tokens

* iceberg: give authenticated token exchanges a fresh full TTL

The remaining-lifetime cap only guards unauthenticated (Bearer-only)
exchanges; an authenticated client renewing a live token must get the
full configured TTL, matching client_credentials.

* iceberg: reject token exchange when no lifetime remains

A Bearer-only exchange with under a second of subject lifetime would
mint a token with expires_in: 0. Reject with invalid_grant instead.

* iceberg: pin near-expiry test token to the next second boundary

jwt/v5 serializes exp at one-second precision, so a 300 ms offset can
round into the current second and route the test through the expired
branch instead of the ttlSeconds<=0 guard. Mint the subject with the
next whole-second expiry: live at exchange time, deterministically
under a second of remaining lifetime.

* iceberg: drop internal ticket reference from comments

* iceberg: clamp oversized OAuth TTLs on 32-bit platforms

strconv.Atoi on an int-sized value fails with ErrRange on 386, so an
oversized ICEBERG_OAUTH_TOKEN_EXPIRY silently fell back to the default
instead of clamping. Parse in 64-bit space and clamp, then narrow.

* iceberg: make OAuth TTL narrowing explicit

* iceberg: disable legacy OAuth in PyIceberg integration tests
2026-09-09 10:54:39 -07:00

165 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""PyIceberg half of the table lifecycle, run one phase per invocation.
The Go test calls this three times - write, verify, drop - and runs the
maintenance worker between the first two. Splitting it that way is the whole
point: a tally taken before compaction and the same tally taken after are the
only thing that catches a merge that rewrote every dictionary-encoded column
onto a single value and said nothing.
"""
import argparse
import hashlib
import json
import sys
import time
import pyarrow as pa
from pyiceberg.catalog import load_catalog
from pyiceberg.exceptions import NamespaceAlreadyExistsError, NoSuchTableError
from pyiceberg.schema import Schema
from pyiceberg.types import LongType, NestedField, StringType, TimestamptzType
# Few enough distinct values in the two string columns that any writer worth
# the name dictionary-encodes them, which is the encoding that broke.
CATEGORIES = 7
VALUES = 13
ROWS_PER_BATCH = 4000
BATCHES = 3
SCHEMA = Schema(
NestedField(1, "id", LongType(), required=True),
NestedField(2, "category", StringType(), required=True),
NestedField(3, "value", StringType(), required=True),
NestedField(4, "ts", TimestamptzType(), required=True),
)
ARROW_SCHEMA = pa.schema(
[
pa.field("id", pa.int64(), nullable=False),
pa.field("category", pa.string(), nullable=False),
pa.field("value", pa.string(), nullable=False),
pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
]
)
def batch(start, count):
ids = list(range(start, start + count))
return pa.Table.from_pydict(
{
"id": ids,
"category": [f"cat-{i % CATEGORIES}" for i in ids],
"value": [f"v-{i % VALUES}" for i in ids],
# An hour apart, so the rows spread over months the way a real
# table's do without needing a partition spec to prove it.
"ts": [1772323200000000 + i * 3600000000 for i in ids],
},
schema=ARROW_SCHEMA,
)
def tally(table):
"""Row count, per-column cardinality, and a digest of every row.
The cardinalities catch a column collapsed onto one dictionary entry; the
digest catches everything else, including a merge that keeps the right
number of distinct values while handing them to the wrong rows. Every
column goes into it, not just the two the cardinalities watch - compaction
rewrites the whole row. ts goes in as microseconds so no timezone sits
between the two runs.
"""
scanned = table.scan().to_arrow()
categories = scanned.column("category").to_pylist()
values = scanned.column("value").to_pylist()
rows = [
f"{i}|{t}|{c}|{v}"
for i, t, c, v in zip(
scanned.column("id").to_pylist(),
scanned.column("ts").cast(pa.int64()).to_pylist(),
categories,
values,
strict=True,
)
]
digest = hashlib.md5(
"\n".join(sorted(rows)).encode(), usedforsecurity=False
).hexdigest()
return {
"rows": scanned.num_rows,
"categories": len(set(categories)),
"values": len(set(values)),
"digest": digest,
}
def connect(args):
properties = {
"type": "rest",
"uri": args.catalog_url,
"warehouse": f"s3://{args.bucket}/",
"prefix": args.bucket,
"auth": {"type": "noop"},
"s3.endpoint": args.s3_endpoint,
"s3.access-key-id": args.access_key,
"s3.secret-access-key": args.secret_key,
"s3.region": "us-east-1",
"s3.path-style-access": "true",
}
last = None
for attempt in range(10):
try:
return load_catalog("rest", **properties)
except Exception as err: # the gateway may still be coming up
last = err
print(f"connect attempt {attempt + 1} failed: {err}", file=sys.stderr)
time.sleep(2)
raise last
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--phase", required=True, choices=["write", "verify", "drop"])
parser.add_argument("--catalog-url", required=True)
parser.add_argument("--s3-endpoint", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--namespace", required=True)
parser.add_argument("--table", required=True)
parser.add_argument("--access-key", required=True)
parser.add_argument("--secret-key", required=True)
args = parser.parse_args()
catalog = connect(args)
identifier = f"{args.namespace}.{args.table}"
if args.phase == "write":
try:
catalog.create_namespace(args.namespace)
except NamespaceAlreadyExistsError:
pass
table = catalog.create_table(identifier, schema=SCHEMA)
# One append per batch, so compaction has several files to merge
# rather than one it would leave alone.
for i in range(BATCHES):
table.append(batch(i * ROWS_PER_BATCH + 1, ROWS_PER_BATCH))
table = catalog.load_table(identifier)
print(json.dumps(tally(table)))
return
if args.phase == "verify":
print(json.dumps(tally(catalog.load_table(identifier))))
return
catalog.drop_table(identifier)
try:
catalog.load_table(identifier)
except NoSuchTableError:
pass
else:
raise SystemExit("the table is still in the catalog after a drop")
catalog.drop_namespace(args.namespace)
if __name__ == "__main__":
main()