Skip to content

Commit e96c720

Browse files
authored
feat(auth): add pluggable authenticator support via registry and entry_points (#3445)
Currently, flytekit's auth system only supports a fixed set of built-in authentication modes (PKCE, ClientSecret, ExternalCommand, DeviceFlow). Adding a new mode requires modifying flytekit core. This makes it impossible for downstream consumers to provide custom authenticators (e.g., native GCP ID token auth) without forking flytekit or shelling out to external processes. This change makes the authenticator system extensible: 1. `register_authenticator_plugin(name, factory)` — explicit registration that works in every environment (pip, Bazel, vendored mono-repos). 2. `flytekit.auth` entry_point group — automatic discovery for pip-installed plugins. When `auth_mode` is set to a value that doesn't match any built-in AuthType, the function checks the explicit registry first, then falls back to entry_point discovery. Names are compared case-insensitively for consistency with the built-in auth mode handling. Plugin contract: a callable `(PlatformConfig, ClientConfigStore) -> Authenticator`. Signed-off-by: Hongxin Liang <honnix@users.noreply.github.com>
1 parent 735f322 commit e96c720

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

flytekit/clients/auth_helper.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
import ssl
3+
import typing
34
from http import HTTPStatus
5+
from importlib.metadata import entry_points
46

57
import grpc
68
import requests
@@ -22,6 +24,28 @@
2224
from flytekit.clients.grpc_utils.wrap_exception_interceptor import RetryExceptionWrapperInterceptor
2325
from flytekit.configuration import AuthType, PlatformConfig
2426

27+
AUTH_ENTRY_POINT_GROUP = "flytekit.auth"
28+
29+
_authenticator_registry: dict[str, typing.Callable[["PlatformConfig", ClientConfigStore], Authenticator]] = {}
30+
31+
32+
def register_authenticator_plugin(
33+
name: str,
34+
factory: typing.Callable[["PlatformConfig", ClientConfigStore], Authenticator],
35+
) -> None:
36+
"""Register an authenticator factory by name.
37+
38+
This is the primary registration mechanism and works in every environment
39+
(pip, Bazel, mono-repo vendoring, etc.). Entry-point discovery is attempted
40+
as a fallback when no explicit registration exists.
41+
42+
Example::
43+
44+
from flytekit.clients.auth_helper import register_authenticator_plugin
45+
register_authenticator_plugin("gcp_id_token", GcpIdTokenAuthenticator)
46+
"""
47+
_authenticator_registry[name.lower()] = factory
48+
2549

2650
class RemoteClientConfigStore(ClientConfigStore):
2751
"""
@@ -50,17 +74,62 @@ def get_client_config(self) -> ClientConfig:
5074
)
5175

5276

77+
def _load_authenticator_plugin(
78+
auth_type_name: str, cfg: "PlatformConfig", cfg_store: ClientConfigStore
79+
) -> typing.Optional[Authenticator]:
80+
"""Load an authenticator by name (case-insensitive).
81+
82+
Resolution order:
83+
84+
1. Explicit registry (populated via :func:`register_authenticator_plugin`).
85+
2. ``importlib.metadata`` entry-point group ``flytekit.auth``.
86+
87+
Names are compared in lowercase so that ``GCP_ID_TOKEN``,
88+
``gcp_id_token``, and ``Gcp_Id_Token`` all resolve to the same plugin.
89+
90+
The loaded object must be a callable (class or factory function) with the
91+
signature ``(PlatformConfig, ClientConfigStore) -> Authenticator``.
92+
"""
93+
name_lower = auth_type_name.lower()
94+
95+
factory = _authenticator_registry.get(name_lower)
96+
if factory is not None:
97+
logging.info(f"Using registered auth plugin '{name_lower}'")
98+
return factory(cfg, cfg_store)
99+
100+
eps = entry_points(group=AUTH_ENTRY_POINT_GROUP)
101+
matching = [ep for ep in eps if ep.name.lower() == name_lower]
102+
if not matching:
103+
return None
104+
factory = matching[0].load()
105+
logging.info(f"Loaded auth plugin '{name_lower}' from entry point {matching[0]}")
106+
return factory(cfg, cfg_store)
107+
108+
53109
def get_authenticator(cfg: PlatformConfig, cfg_store: ClientConfigStore) -> Authenticator:
54110
"""
55111
Returns a new authenticator based on the platform config.
112+
113+
Built-in auth types (PKCE, ClientSecret, ExternalCommand, DeviceFlow) are
114+
tried first. If ``auth_mode`` is a string that does not match any built-in
115+
type, the function falls back to entry-point discovery: any installed
116+
package can register an authenticator factory under the
117+
``flytekit.auth`` entry point group and it will be loaded automatically.
56118
"""
57119
cfg_auth = cfg.auth_mode
58120
if type(cfg_auth) is str:
59121
try:
60122
cfg_auth = AuthType[cfg_auth.upper()]
61123
except KeyError:
62-
logging.warning(f"Authentication type {cfg_auth} does not exist, defaulting to standard")
63-
cfg_auth = AuthType.STANDARD
124+
authenticator = _load_authenticator_plugin(cfg_auth, cfg, cfg_store)
125+
if authenticator is not None:
126+
return authenticator
127+
raise ValueError(
128+
f"Unknown authentication type '{cfg_auth}'. "
129+
f"Install a flytekit auth plugin that registers under the "
130+
f"'{AUTH_ENTRY_POINT_GROUP}' entry point group with name '{cfg_auth}', "
131+
f"or use a built-in type: {', '.join(t.value for t in AuthType)}"
132+
)
64133

65134
verify = None
66135
if cfg.insecure_skip_verify:

tests/flytekit/unit/clients/test_auth_helper.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from http import HTTPStatus
2+
from importlib.metadata import EntryPoint
23
from unittest.mock import MagicMock, patch
34

45
import pytest
56
import requests
67
from flyteidl.service.auth_pb2 import OAuth2MetadataResponse, PublicClientAuthConfigResponse
78

89
from flytekit.clients.auth.authenticator import (
10+
Authenticator,
911
ClientConfig,
1012
ClientConfigStore,
1113
ClientCredentialsAuthenticator,
@@ -14,10 +16,14 @@
1416
PKCEAuthenticator,
1517
)
1618
from flytekit.clients.auth.exceptions import AuthenticationError
19+
from flytekit.clients.auth.keyring import Credentials
1720
from flytekit.clients.auth_helper import (
21+
AUTH_ENTRY_POINT_GROUP,
1822
RemoteClientConfigStore,
23+
_authenticator_registry,
1924
get_authenticator,
2025
get_session,
26+
register_authenticator_plugin,
2127
upgrade_channel_to_authenticated,
2228
upgrade_channel_to_proxy_authenticated,
2329
wrap_exceptions_channel,
@@ -199,3 +205,98 @@ def test_get_proxy_authenticated_session():
199205
session.send(prepared_request)
200206

201207
assert prepared_request.headers["proxy-authorization"] == f"Bearer {expected_token}"
208+
209+
210+
class _StubAuthenticator(Authenticator):
211+
"""Minimal authenticator for plugin tests."""
212+
213+
def __init__(self, cfg, cfg_store):
214+
super().__init__(cfg.endpoint, "authorization")
215+
self._cfg = cfg
216+
217+
def refresh_credentials(self):
218+
self._creds = Credentials("stub-token")
219+
220+
221+
def _stub_factory(cfg, cfg_store):
222+
return _StubAuthenticator(cfg, cfg_store)
223+
224+
225+
def _make_entry_point(name, factory):
226+
"""Build an EntryPoint whose .load() returns *factory*."""
227+
ep = MagicMock(spec=EntryPoint)
228+
ep.name = name
229+
ep.load.return_value = factory
230+
return ep
231+
232+
233+
@patch("flytekit.clients.auth_helper.entry_points")
234+
def test_get_authenticator_plugin(mock_entry_points):
235+
ep = _make_entry_point("my_custom_auth", _stub_factory)
236+
mock_entry_points.return_value = [ep]
237+
238+
cfg = PlatformConfig(auth_mode="my_custom_auth")
239+
authn = get_authenticator(cfg, get_client_config())
240+
241+
assert isinstance(authn, _StubAuthenticator)
242+
mock_entry_points.assert_called_once_with(group=AUTH_ENTRY_POINT_GROUP)
243+
ep.load.assert_called_once()
244+
245+
246+
@patch("flytekit.clients.auth_helper.entry_points")
247+
def test_get_authenticator_plugin_not_found(mock_entry_points):
248+
mock_entry_points.return_value = []
249+
250+
cfg = PlatformConfig(auth_mode="nonexistent_auth")
251+
with pytest.raises(ValueError, match="Unknown authentication type 'nonexistent_auth'"):
252+
get_authenticator(cfg, get_client_config())
253+
254+
255+
@patch("flytekit.clients.auth_helper.entry_points")
256+
def test_get_authenticator_builtin_types_skip_plugin_lookup(mock_entry_points):
257+
"""Built-in auth types must not trigger entry point discovery."""
258+
cfg = PlatformConfig(auth_mode=AuthType.EXTERNAL_PROCESS, command=["echo"])
259+
authn = get_authenticator(cfg, get_client_config())
260+
261+
assert isinstance(authn, CommandAuthenticator)
262+
mock_entry_points.assert_not_called()
263+
264+
265+
@patch("flytekit.clients.auth_helper.entry_points")
266+
def test_get_authenticator_explicit_registry(mock_entry_points):
267+
"""Explicitly registered plugins take precedence over entry_points."""
268+
register_authenticator_plugin("registered_auth", _stub_factory)
269+
try:
270+
cfg = PlatformConfig(auth_mode="registered_auth")
271+
authn = get_authenticator(cfg, get_client_config())
272+
273+
assert isinstance(authn, _StubAuthenticator)
274+
mock_entry_points.assert_not_called()
275+
finally:
276+
_authenticator_registry.pop("registered_auth", None)
277+
278+
279+
@patch("flytekit.clients.auth_helper.entry_points")
280+
def test_get_authenticator_plugin_case_insensitive(mock_entry_points):
281+
"""Plugin lookup is case-insensitive for both registry and entry_points."""
282+
register_authenticator_plugin("gcp_id_token", _stub_factory)
283+
try:
284+
cfg = PlatformConfig(auth_mode="GCP_ID_TOKEN")
285+
authn = get_authenticator(cfg, get_client_config())
286+
287+
assert isinstance(authn, _StubAuthenticator)
288+
mock_entry_points.assert_not_called()
289+
finally:
290+
_authenticator_registry.pop("gcp_id_token", None)
291+
292+
293+
@patch("flytekit.clients.auth_helper.entry_points")
294+
def test_get_authenticator_entry_point_case_insensitive(mock_entry_points):
295+
"""Entry point names are matched case-insensitively."""
296+
ep = _make_entry_point("Gcp_Id_Token", _stub_factory)
297+
mock_entry_points.return_value = [ep]
298+
299+
cfg = PlatformConfig(auth_mode="GCP_ID_TOKEN")
300+
authn = get_authenticator(cfg, get_client_config())
301+
302+
assert isinstance(authn, _StubAuthenticator)

0 commit comments

Comments
 (0)