Authentication Concepts
Authentication lifecycle
A remote client does not attach a token directly in ClientSet. The asynchronous entry points are ClientSet.for_endpoint() and ClientSet.for_api_key() in remote/_client/controlplane.py; higher-level initialization routes endpoint and API-key options to these methods. for_endpoint() passes the endpoint to create_channel(). for_api_key() decodes the key once to obtain the endpoint for the resulting ClientSet, while create_channel() decodes it again to obtain the client credentials used by authentication.
client = await ClientSet.for_endpoint("dns:///flyte.example.com")
api_key_client = await ClientSet.for_api_key("<api-key>")
Both calls are asynchronous. ClientSet builds Admin, Task, Run, DataProxy, RunLogs, and Secret gRPC stubs over the channel returned by create_channel(). The channel construction in remote/_client/auth/_channel.py has two stages:
- It creates an unauthenticated secure or insecure channel to the endpoint.
- It uses that channel to discover authentication metadata, creates the authentication interceptors, and returns a new channel with those interceptors installed.
The returned channel contains one authentication interceptor for each gRPC call shape: unary-unary, unary-stream, stream-unary, and stream-stream. Default metadata interceptors are also installed. If proxy_command is configured, proxy-authentication interceptors are added separately; proxy authentication is not one of the OAuth auth_type values.
An API key selects client-secret authentication inside create_channel():
if api_key:
from flyte.remote._client.auth._auth_utils import decode_api_key
endpoint, client_id, client_secret, org = decode_api_key(api_key)
kwargs["auth_type"] = "ClientSecret"
kwargs["client_id"] = client_id
kwargs["client_secret"] = client_secret
kwargs["client_credentials_secret"] = client_secret
ClientSet exposes the authenticated services through properties such as metadata_service, task_service, run_service, dataproxy_service, logs_service, and secrets_service. Its close() method closes the underlying channel. The for_serverless() and from_env() class methods currently raise NotImplementedError.
Discovering and overriding client configuration
ClientConfig in remote/_client/auth/_client_config.py contains the OAuth metadata required by the authenticators:
- required
token_endpoint,authorization_endpoint,redirect_uri, andclient_id; - optional
device_authorization_endpoint,scopes, andaudience; header_key, defaulting to"authorization".
ClientConfigStore is an asynchronous provider interface with one method, get_client_config(). StaticClientConfigStore returns a configuration supplied to its constructor. The channel path normally uses RemoteClientConfigStore, passing it the unauthenticated channel created before authentication is installed.
RemoteClientConfigStore.get_client_config() creates an AuthMetadataServiceStub and starts both metadata requests before awaiting them together:
metadata_service = AuthMetadataServiceStub(self._unauthenticated_channel)
OAuth2_metadata_task = metadata_service.GetOAuth2Metadata(OAuth2MetadataRequest())
public_client_config_task = metadata_service.GetPublicClientConfig(PublicClientAuthConfigRequest())
oauth2_metadata, public_client_config = await asyncio.gather(
OAuth2_metadata_task, public_client_config_task
)
return ClientConfig(
token_endpoint=oauth2_metadata.token_endpoint,
authorization_endpoint=oauth2_metadata.authorization_endpoint,
redirect_uri=public_client_config.redirect_uri,
client_id=public_client_config.client_id,
scopes=public_client_config.scopes,
header_key=public_client_config.authorization_metadata_key,
device_authorization_endpoint=oauth2_metadata.device_authorization_endpoint,
audience=public_client_config.audience,
)
The actual source uses the local variable name oauth2_metadata_task; the shortened capitalization above is not a separate API. In the source form, the complete method is:
oauth2_metadata_task = metadata_service.GetOAuth2Metadata(OAuth2MetadataRequest())
public_client_config_task = metadata_service.GetPublicClientConfig(PublicClientAuthConfigRequest())
oauth2_metadata, public_client_config = await asyncio.gather(
oauth2_metadata_task, public_client_config_task
)
Authenticator._resolve_config() calls the configured store and caches the result in _resolved_config. When a local client_config is supplied, it calls remote_config.with_override(self._client_config), so truthy local fields replace the corresponding remote fields. with_override() uses or, meaning an empty string or empty scopes list does not clear a value already present in the remote configuration. If no ClientConfigStore is supplied, _resolve_config() raises ValueError.
The resolved header_key is used when it is available. Before configuration has been resolved, Authenticator uses its default_header_key, whose default is "authorization". Configuration resolution is therefore deferred until a flow needs OAuth metadata; the implementation caches the result, although the initial cache check and assignment are not protected by the authenticator's refresh lock.
Credentials and keyring persistence
Credentials is a Pydantic model, not a token validator. It stores an access_token, endpoint identity (for_endpoint), optional refresh_token, optional expires_in, and an id. The endpoint validator strips its URL scheme before storing it. Thus, keyring service names use normalized endpoint values rather than the original URL scheme.
The model validator recomputes id from the access token:
@pydantic.model_validator(mode="after")
def compute_id(self) -> "Credentials":
"""Computes the id field as a hash of the access_token."""
if self.access_token:
self.id = hashlib.md5(self.access_token.encode()).hexdigest()
return self
The ID is used to coordinate refreshes; it is not an expiration check or a password-storage mechanism. Credentials does not validate token format or token expiration. In particular, KeyringStore.retrieve() reconstructs credentials with expires_in=None, and the base authenticator does not proactively refresh based on that field.
KeyringStore is a best-effort wrapper around Python keyring. It uses the normalized endpoint as the service name and stores the access token under access_token. When present, the refresh token is stored under refresh_token. Retrieval returns None when the backend is unavailable, an exception occurs, or no access token is found. Those failures are logged at debug level rather than raised, so an unusable keyring disables caching rather than preventing authentication.
The base authenticator first prefers explicitly supplied credentials and otherwise retrieves credentials for the endpoint:
self._creds = credentials or KeyringStore.retrieve(endpoint)
Successful refreshes call KeyringStore.store(). If refresh fails, Authenticator.refresh_credentials() calls KeyringStore.delete(self._endpoint) and re-raises the original failure. Deletion attempts to remove both access and refresh tokens, while missing keys, unavailable backends, unsupported deletion, and other deletion failures are logged and ignored.
The common authenticator contract
Authenticator owns the endpoint, credentials, HTTP session, TLS/proxy settings, resolved configuration, and an asyncio.Lock. Concrete authenticators implement _do_refresh_credentials(). The factory supports Pkce, ClientSecret, ExternalCommand, and DeviceFlow, and constructs the corresponding concrete authenticator lazily for each gRPC interceptor.
For an RPC, get_grpc_call_auth_metadata() returns no metadata when there are no credentials. Otherwise it returns a GrpcAuthMetadata containing the credential ID and a gRPC metadata pair containing a bearer token:
creds = self.get_credentials()
if creds:
header_key = self._default_header_key
if self._resolved_config is not None:
header_key = self._resolved_config.header_key
return GrpcAuthMetadata(
creds_id=creds.id,
pairs=Metadata((header_key, f"Bearer {creds.access_token}")),
)
return None
The auth interceptors capture the credential ID used for the call. If a call returns UNAUTHENTICATED or UNKNOWN, they call refresh_credentials(creds_id=creds_id), obtain fresh metadata, and retry once. Other gRPC errors are re-raised. The same authentication adapter pattern exists for HTTP: AsyncAuthenticatedClient and AsyncAuthenticationHTTPAdapter add the header and retry once after HTTP 401.
The credential ID makes concurrent failure handling conditional. refresh_credentials() returns immediately if the caller's ID differs from the current ID, because another caller has already refreshed the token. Otherwise it takes the async lock, repeats that check, performs _do_refresh_credentials(), persists the result, and updates _creds_id:
if creds_id and creds_id != self._creds_id:
return
async with self._async_lock:
if creds_id and creds_id != self._creds_id:
return
try:
self._creds = await self._do_refresh_credentials()
KeyringStore.store(self._creds)
except Exception:
KeyringStore.delete(self._endpoint)
raise
self._creds_id = self._creds.id
Passing None as creds_id forces a refresh. The lock protects the refresh operation, but configuration's first remote fetch is separately cached and is not enclosed by this lock.
Authentication flows
PKCE
PKCEAuthenticator is the default (auth_type="Pkce"). It initializes an AuthorizationClient with the resolved redirect URI, client ID, authorization and token endpoints, audience, scopes, and HTTP settings. It generates a code verifier and S256 code challenge, sends the challenge in the authorization request, and sends the verifier when exchanging the authorization code. The flow opens a browser for the full authorization flow. If credentials already exist, it first attempts refresh_access_token(); when that raises AccessTokenNotFoundError, it falls back to obtaining credentials remotely.
The redirect URI must be usable by the local callback handler, including a host and port that can be bound locally. The PKCE implementation also checks the OAuth state value. Its class docstring notes that deployments such as Auth0 may require scopes including offline_access or offline to receive a refresh token; without a refresh token, later authentication uses the full browser flow again.
Device flow
DeviceCodeAuthenticator requires the server to return device_authorization_endpoint. If that field is absent, refresh raises AuthenticationError with the message that device authentication is unavailable. If a refresh token exists, the authenticator first calls the token endpoint with the refresh-token grant. On AuthenticationError or AuthenticationPending, it logs in again through device flow: it requests a device code, prints the verification URL and user code, and polls the token endpoint.
This makes device flow suitable for a headless login interaction, but it is not available merely because auth_type is set: the server metadata must advertise the device endpoint.
Client secret and API keys
ClientCredentialsAuthenticator requires both client_id and client_credentials_secret; missing either raises ValueError. It resolves the server configuration, creates an HTTP Basic authorization header with those credentials, and calls the token endpoint with configured scopes and audience. The resulting access and refresh tokens become a Credentials object.
ClientSet.for_api_key() selects this flow by parsing the API key in create_channel(). The parsed client ID and secret are passed as client_id and client_credentials_secret, and the secret is sent to the token endpoint through Basic authorization rather than as a bearer token supplied directly by the caller.
External command
AsyncCommandAuthenticator requires a nonempty command. It runs the command with asyncio.create_subprocess_exec(), captures stdout and stderr, and treats stripped stdout as the complete access token. A nonzero exit code, or another command failure, is converted to AuthenticationError. This flow does not obtain OAuth metadata through ClientConfigStore merely to read a token; its token source is the external process.
Configuration that selects the flow
PlatformConfig reads these settings from the admin configuration namespace. Per-entry environment variables take precedence over YAML configuration. The generated environment names are shown below.
| Setting | Environment name | Effect |
|---|---|---|
admin.authType | FLYTE_ADMIN_AUTHTYPE | Selects Pkce, ClientSecret, ExternalCommand, or DeviceFlow; the default is Pkce. |
admin.endpoint | FLYTE_ADMIN_ENDPOINT | Supplies the Flyte server endpoint. |
admin.clientId | — | Supplies the client ID used by client-secret authentication. |
admin.clientSecretLocation | — | Loads a client secret from a mounted file. |
admin.clientSecretEnvVar | — | Names the environment variable from which to load a client secret. |
admin.scopes | FLYTE_ADMIN_SCOPES | Supplies scopes, especially for client credentials and provider-specific refresh-token behavior. |
admin.command | — | Supplies the external token-producing command. Its stdout is used as the bearer token. |
admin.proxyCommand | — | Supplies a separate command for proxy authorization. |
admin.insecure | FLYTE_ADMIN_INSECURE | Selects an insecure gRPC channel. |
admin.insecureSkipVerify | FLYTE_ADMIN_INSECURE_SKIP_VERIFY | Bootstraps SSL from the server instead of normal certificate verification. |
admin.caCertFilePath | FLYTE_ADMIN_CA_CERT_FILE_PATH | Loads a custom CA certificate asynchronously. |
admin.httpProxyURL | FLYTE_ADMIN_HTTP_PROXY_URL | Configures the OAuth HTTP session's proxy. |
When either configured client-secret source yields a value, PlatformConfig.auto() changes the selected authentication mode to ClientSecret. TLS settings affect channel creation and OAuth HTTP verification separately; proxyCommand activates proxy interceptors and is not the same setting as httpProxyURL.
Operational constraints
Authentication is asynchronous from channel creation through token refresh, so callers must await ClientSet factory methods. Keyring persistence is optional and best effort. A cached token is keyed by the endpoint after its scheme is stripped; path components remain for non-dns URLs, so equivalent endpoint spellings should be used consistently.
Refresh is normally reactive: the gRPC and HTTP layers refresh after the server rejects the current token, and the retry is limited to one attempt. The base credential model records expires_in, but cached credentials restore it as None and no proactive expiry refresh is implemented. A failed refresh removes both cached token entries, which means the next attempt may require a complete login.
Finally, authentication depends on server-provided configuration for the flows that call _resolve_config(). An authenticator without a ClientConfigStore or an already available configuration fails with ValueError when it needs that configuration. PKCE needs a bindable redirect callback and appropriate provider scopes, device flow needs a server device endpoint, client-secret flow needs both client credentials, and external-command flow needs a nonempty command.