Skip to content

Add Support for Custom AuthManager implementation #2055

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion mkdocs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,9 @@ catalog:

#### Authentication Options

##### OAuth2
##### Legacy OAuth2

Legacy OAuth2 Properties will be removed in PyIceberg 1.0 in place of pluggable AuthManager properties below

| Key | Example | Description |
| ------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------- |
Expand All @@ -378,6 +380,77 @@ catalog:
| rest.signing-region | us-east-1 | The region to use when SigV4 signing a request |
| rest.signing-name | execute-api | The service signing name to use when SigV4 signing a request |

##### Pluggable Authentication via AuthManager

The RESTCatalog supports pluggable authentication via the `auth` configuration block. This allows you to specify which how the access token will be fetched and managed for use with the HTTP requests to the RESTCatalog server. The authentication method is selected by setting the `auth.type` property, and additional configuration can be provided as needed for each method.

###### Supported Authentication Types

- `noop`: No authentication (no Authorization header sent).
- `basic`: HTTP Basic authentication.
- `custom`: Custom authentication manager (requires `auth.impl`).

###### Configuration Properties

The `auth` block is structured as follows:

```yaml
catalog:
default:
type: rest
uri: http://rest-catalog/ws/
auth:
type: <auth_type>
<auth_type>:
# Type-specific configuration
impl: <custom_class_path> # Only for custom auth
```

###### Property Reference

| Property | Required | Description |
|------------------|----------|-------------------------------------------------------------------------------------------------|
| `auth.type` | Yes | The authentication type to use (`noop`, `basic`, or `custom`). |
| `auth.impl` | Conditionally | The fully qualified class path for a custom AuthManager. Required if `auth.type` is `custom`. |
| `auth.basic` | If type is `basic` | Block containing `username` and `password` for HTTP Basic authentication. |
| `auth.custom` | If type is `custom` | Block containing configuration for the custom AuthManager. |

###### Examples

No Authentication:

```yaml
auth:
type: noop
```

Basic Authentication:

```yaml
auth:
type: basic
basic:
username: myuser
password: mypass
```

Custom Authentication:

```yaml
auth:
type: custom
impl: mypackage.module.MyAuthManager
custom:
property1: value1
property2: value2
```

###### Notes

- If `auth.type` is `custom`, you **must** specify `auth.impl` with the full class path to your custom AuthManager.
- If `auth.type` is not `custom`, specifying `auth.impl` is not allowed.
- The configuration block under each type (e.g., `basic`, `custom`) is passed as keyword arguments to the corresponding AuthManager.

<!-- markdown-link-check-enable-->

#### Common Integrations & Examples
Expand Down
20 changes: 19 additions & 1 deletion pyiceberg/catalog/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ class IdentifierKind(Enum):
SIGV4_SERVICE = "rest.signing-name"
OAUTH2_SERVER_URI = "oauth2-server-uri"
SNAPSHOT_LOADING_MODE = "snapshot-loading-mode"
AUTH = "auth"
CUSTOM = "custom"

NAMESPACE_SEPARATOR = b"\x1f".decode(UTF8)

Expand Down Expand Up @@ -247,7 +249,23 @@ def _create_session(self) -> Session:
elif ssl_client_cert := ssl_client.get(CERT):
session.cert = ssl_client_cert

session.auth = AuthManagerAdapter(self._create_legacy_oauth2_auth_manager(session))
if auth_config := self.properties.get(AUTH):
auth_type = auth_config.get("type")
if auth_type is None:
raise ValueError("auth.type must be defined")
auth_type_config = auth_config.get(auth_type, {})
auth_impl = auth_config.get("impl")

if auth_type == CUSTOM and not auth_impl:
raise ValueError("auth.impl must be specified when using custom auth.type")

if auth_type != CUSTOM and auth_impl:
raise ValueError("auth.impl can only be specified when using custom auth.type")

session.auth = AuthManagerAdapter(AuthManagerFactory.create(auth_impl or auth_type, auth_type_config))
else:
session.auth = AuthManagerAdapter(self._create_legacy_oauth2_auth_manager(session))

# Set HTTP headers
self._config_headers(session)

Expand Down
10 changes: 10 additions & 0 deletions pyiceberg/catalog/rest/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@ def auth_header(self) -> Optional[str]:


class NoopAuthManager(AuthManager):
"""Auth Manager implementation with no auth."""

def auth_header(self) -> Optional[str]:
return None


class BasicAuthManager(AuthManager):
"""AuthManager implementation that supports basic password auth."""

def __init__(self, username: str, password: str):
credentials = f"{username}:{password}"
self._token = base64.b64encode(credentials.encode()).decode()
Expand All @@ -56,6 +60,12 @@ def auth_header(self) -> str:


class LegacyOAuth2AuthManager(AuthManager):
"""Legacy OAuth2 AuthManager implementation.

This class exists for backward compatibility, and will be removed in
PyIceberg 1.0.0 in favor of OAuth2AuthManager.
"""

_session: Session
_auth_url: Optional[str]
_token: Optional[str]
Expand Down
127 changes: 127 additions & 0 deletions tests/catalog/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=redefined-outer-name,unused-argument
import base64
import os
from typing import Any, Callable, Dict, cast
from unittest import mock
Expand Down Expand Up @@ -1519,6 +1520,132 @@ def test_request_session_with_ssl_client_cert() -> None:
assert "Could not find the TLS certificate file, invalid path: path_to_client_cert" in str(e.value)


def test_rest_catalog_with_basic_auth_type(rest_mock: Mocker) -> None:
# Given
rest_mock.get(
f"{TEST_URI}v1/config",
json={"defaults": {}, "overrides": {}},
status_code=200,
)
# Given
catalog_properties = {
"uri": TEST_URI,
"auth": {
"type": "basic",
"basic": {
"username": "one",
"password": "two",
},
},
}
catalog = RestCatalog("rest", **catalog_properties) # type: ignore
assert catalog.uri == TEST_URI

encoded_user_pass = base64.b64encode(b"one:two").decode()
expected_auth_header = f"Basic {encoded_user_pass}"
assert rest_mock.last_request.headers["Authorization"] == expected_auth_header


def test_rest_catalog_with_custom_auth_type() -> None:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could imagine doing a version of this test that specifies the basic auth as the implementation and just verifies that the custom implementation is actually used/that properties specified for custom are actually passed to the implementation.

# Given
catalog_properties = {
"uri": TEST_URI,
"auth": {
"type": "custom",
"impl": "dummy.nonexistent.package",
"custom": {
"property1": "one",
"property2": "two",
},
},
}
with pytest.raises(ValueError) as e:
# Missing namespace
RestCatalog("rest", **catalog_properties) # type: ignore
assert "Could not load AuthManager class for 'dummy.nonexistent.package'" in str(e.value)


def test_rest_catalog_with_custom_basic_auth_type(rest_mock: Mocker) -> None:
# Given
catalog_properties = {
"uri": TEST_URI,
"auth": {
"type": "custom",
"impl": "pyiceberg.catalog.rest.auth.BasicAuthManager",
"custom": {
"username": "one",
"password": "two",
},
},
}
rest_mock.get(
f"{TEST_URI}v1/config",
json={"defaults": {}, "overrides": {}},
status_code=200,
)
catalog = RestCatalog("rest", **catalog_properties) # type: ignore
assert catalog.uri == TEST_URI

encoded_user_pass = base64.b64encode(b"one:two").decode()
expected_auth_header = f"Basic {encoded_user_pass}"
assert rest_mock.last_request.headers["Authorization"] == expected_auth_header


def test_rest_catalog_with_custom_auth_type_no_impl() -> None:
# Given
catalog_properties = {
"uri": TEST_URI,
"auth": {
"type": "custom",
"custom": {
"property1": "one",
"property2": "two",
},
},
}
with pytest.raises(ValueError) as e:
# Missing namespace
Copy link
Preview

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment 'Missing namespace' is misleading. The test is actually checking for a missing impl property when using custom auth type, not a missing namespace.

Suggested change
# Missing namespace
# Missing 'impl' property in custom auth configuration

Copilot uses AI. Check for mistakes.

RestCatalog("rest", **catalog_properties) # type: ignore
assert "auth.impl must be specified when using custom auth.type" in str(e.value)


def test_rest_catalog_with_non_custom_auth_type_impl() -> None:
# Given
catalog_properties = {
"uri": TEST_URI,
"auth": {
"type": "basic",
"impl": "basic.package",
"basic": {
"username": "one",
"password": "two",
},
},
}
with pytest.raises(ValueError) as e:
# Missing namespace
Copy link
Preview

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment 'Missing namespace' is misleading. The test is actually checking for an invalid impl property usage, not a missing namespace.

Suggested change
# Missing namespace
# Invalid impl property usage in non-custom auth.type

Copilot uses AI. Check for mistakes.

RestCatalog("rest", **catalog_properties) # type: ignore
assert "auth.impl can only be specified when using custom auth.type" in str(e.value)


def test_rest_catalog_with_unsupported_auth_type() -> None:
# Given
catalog_properties = {
"uri": TEST_URI,
"auth": {
"type": "unsupported",
"unsupported": {
"property1": "one",
"property2": "two",
},
},
}
with pytest.raises(ValueError) as e:
# Missing namespace
Copy link
Preview

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment 'Missing namespace' is misleading. The test is actually checking for an unsupported auth type, not a missing namespace.

Suggested change
# Missing namespace
# Unsupported authentication type

Copilot uses AI. Check for mistakes.

RestCatalog("rest", **catalog_properties) # type: ignore
assert "Could not load AuthManager class for 'unsupported'" in str(e.value)


EXAMPLE_ENV = {"PYICEBERG_CATALOG__PRODUCTION__URI": TEST_URI}


Expand Down