diff --git a/providers/keycloak/README.rst b/providers/keycloak/README.rst index 43ead6d49647a..61b43b5245476 100644 --- a/providers/keycloak/README.rst +++ b/providers/keycloak/README.rst @@ -55,7 +55,7 @@ PIP package Version required ========================================== ================== ``apache-airflow`` ``>=3.0.0`` ``apache-airflow-providers-common-compat`` ``>=1.18.0`` -``python-keycloak`` ``>=5.0.0`` +``python-keycloak`` ``>=5.12.0`` ========================================== ================== The changelog for the provider package can be found in the diff --git a/providers/keycloak/docs/auth-manager/token.rst b/providers/keycloak/docs/auth-manager/token.rst index 491797e32ccbd..00f90c5862bca 100644 --- a/providers/keycloak/docs/auth-manager/token.rst +++ b/providers/keycloak/docs/auth-manager/token.rst @@ -30,7 +30,9 @@ Several endpoints exist to create tokens depending on the authentication method If a user or service needs to interact with the Airflow public API, they can create a token using their credentials. - ``/auth/token``: Create token using username and password, client credentials, or a Keycloak-issued JWT assertion, with a ``[config][api_auth]jwt_expiration_time`` expiration time. -- ``/auth/token/cli``: Create token for Airflow CLI using username and password with a ``[config][api_auth]jwt_cli_expiration_time`` expiration time. +- ``/auth/token/cli``: Create token for command-line clients such as ``airflowctl`` with a ``[config][api_auth]jwt_cli_expiration_time`` expiration time. + It accepts username and password, a refresh of a CLI token, and, when enabled, the browser login described in + :ref:`keycloak-cli-browser-login`. Example @@ -135,3 +137,42 @@ token for subsequent public API calls: curl -s "${ENDPOINT_URL}/api/v2/dags" \ -H "Authorization: Bearer ${AIRFLOW_TOKEN}" + + +.. _keycloak-cli-browser-login: + +Command-line clients +'''''''''''''''''''' + +Command-line clients read which login methods are offered from ``GET /api/v2/auth/cli/metadata``, then call +``/auth/token/cli``. Besides username and password, two grants are available. + +**Refresh.** The CLI token embeds the Keycloak access token, which usually expires within minutes. A client can +exchange its current CLI token for one with fresh Keycloak tokens: + +.. code-block:: bash + + curl -X 'POST' \ + "${ENDPOINT_URL}/auth/token/cli" \ + -H 'Content-Type: application/json' \ + -d '{"grant_type": "refresh_token", "token": ""}' + +The new token expires at the same time as the current one, so refreshing never extends a CLI session. The current +token is revoked once the new one is issued. Expired and revoked tokens cannot be refreshed. A Keycloak client with +"Use Refresh Tokens" turned off issues no refresh token, so its CLI tokens cannot be refreshed either; refreshing one +returns ``400`` and the user has to log in again. + +**Browser login.** With ``[keycloak_auth_manager] cli_authorization_code_enabled = True``, users log in through +Keycloak in their browser, with the authorization code flow and PKCE: + +1. The client listens on ``http://127.0.0.1:/callback`` (or ``[::1]``) and opens ``/auth/login/cli`` with + ``redirect_uri``, ``state``, ``code_challenge`` and ``code_challenge_method=S256``. +2. Keycloak authenticates the user and redirects the browser to the client with ``code`` and ``state``. +3. The client sends ``grant_type=authorization_code``, ``code``, ``code_verifier`` and ``redirect_uri`` to + ``/auth/token/cli``. + +Airflow requires the ``S256`` challenge and refuses redirect URIs that are not loopback callbacks. Do not turn on +PKCE on the Keycloak client itself, because the web login does not use it. + +In the Keycloak client, add ``http://127.0.0.1/callback`` and ``http://[::1]/callback`` to the valid redirect URIs. +Keycloak ignores the port of loopback redirect URIs, as RFC 8252 requires, so the client can listen on any port. diff --git a/providers/keycloak/docs/index.rst b/providers/keycloak/docs/index.rst index b1701ef428d83..da7c83e59d0d1 100644 --- a/providers/keycloak/docs/index.rst +++ b/providers/keycloak/docs/index.rst @@ -103,7 +103,7 @@ PIP package Version required ========================================== ================== ``apache-airflow`` ``>=3.0.0`` ``apache-airflow-providers-common-compat`` ``>=1.18.0`` -``python-keycloak`` ``>=5.0.0`` +``python-keycloak`` ``>=5.12.0`` ========================================== ================== Downloading official packages diff --git a/providers/keycloak/provider.yaml b/providers/keycloak/provider.yaml index e999894c1b15b..52b66e3c2083f 100644 --- a/providers/keycloak/provider.yaml +++ b/providers/keycloak/provider.yaml @@ -96,6 +96,15 @@ config: version_added: 0.0.1 example: ~ default: ~ + cli_authorization_code_enabled: + description: | + Let command-line clients such as ``airflowctl`` log in through the browser, with the + authorization code flow and PKCE. The Keycloak client must allow the loopback redirect + URIs ``http://127.0.0.1/callback`` and ``http://[::1]/callback``. + type: boolean + version_added: 0.12.0 + example: ~ + default: "False" jwt_federated_client_ids: description: | Comma-separated allow-list of Keycloak client ids (``azp`` claim) permitted to diff --git a/providers/keycloak/pyproject.toml b/providers/keycloak/pyproject.toml index 52b6671fadf5b..b7afda03f48a4 100644 --- a/providers/keycloak/pyproject.toml +++ b/providers/keycloak/pyproject.toml @@ -60,7 +60,7 @@ requires-python = ">=3.11" dependencies = [ "apache-airflow>=3.0.0", "apache-airflow-providers-common-compat>=1.18.0", - "python-keycloak>=5.0.0", + "python-keycloak>=5.12.0", ] [dependency-groups] diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py index 262166de5b697..0836f1b9d5406 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/constants.py @@ -30,6 +30,7 @@ # Comma-separated allow-list of federated Keycloak client ids (azp claim) permitted # to use the jwt-bearer grant. Empty/unset denies all callers. CONF_JWT_FEDERATED_CLIENT_IDS_KEY = "jwt_federated_client_ids" +CONF_CLI_AUTHORIZATION_CODE_ENABLED_KEY = "cli_authorization_code_enabled" # Extra Cookie names COOKIE_NAME_ACCESS_TOKEN = "_access_token" diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py index f61fdd3615c66..a6b28a92bf8b9 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/datamodels/token.py @@ -17,7 +17,7 @@ # under the License. from __future__ import annotations -from typing import Annotated, Literal +from typing import Annotated, Any, Literal from pydantic import Field, RootModel, model_validator @@ -26,9 +26,18 @@ create_client_credentials_token, create_jwt_federated_token, create_token_for, + create_token_from_authorization_code, + refresh_cli_token, ) +def default_to_password_grant(data: Any) -> Any: + """Add the default ``password`` grant_type, leaving a malformed body to the union.""" + if isinstance(data, dict) and "grant_type" not in data: + return {**data, "grant_type": "password"} + return data + + class TokenResponse(BaseModel): """Token serializer for responses.""" @@ -82,6 +91,36 @@ def create_token(self, expiration_time_in_seconds: int) -> str: ) +class TokenRefreshBody(StrictBaseModel): + """Refresh grant serializer for post bodies; ``token`` is the current CLI token.""" + + grant_type: Literal["refresh_token"] + token: str = Field() + + def create_token(self, expiration_time_in_seconds: int) -> str: + """Create token from a CLI token that is still valid, keeping its expiry.""" + return refresh_cli_token(self.token) + + +class TokenAuthorizationCodeBody(StrictBaseModel): + """Authorization code grant serializer for post bodies, used by the browser login of CLI clients.""" + + grant_type: Literal["authorization_code"] + code: str = Field() + # RFC 7636 section 4.1 length bounds. + code_verifier: str = Field(min_length=43, max_length=128) + redirect_uri: str = Field() + + def create_token(self, expiration_time_in_seconds: int) -> str: + """Create token from an authorization code and its PKCE verifier.""" + return create_token_from_authorization_code( + self.code, + self.code_verifier, + self.redirect_uri, + expiration_time_in_seconds=expiration_time_in_seconds, + ) + + TokenUnion = Annotated[ TokenPasswordBody | TokenClientCredentialsBody | TokenJwtFederatedBody, Field(discriminator="grant_type"), @@ -93,8 +132,20 @@ class TokenBody(RootModel[TokenUnion]): @model_validator(mode="before") @classmethod - def default_grant_type(cls, data): - """Add default grant_type for discrimination.""" - if "grant_type" not in data: - data["grant_type"] = "password" - return data + def default_grant_type(cls, data: Any) -> Any: + return default_to_password_grant(data) + + +TokenCliUnion = Annotated[ + TokenPasswordBody | TokenRefreshBody | TokenAuthorizationCodeBody, + Field(discriminator="grant_type"), +] + + +class TokenCliBody(RootModel[TokenCliUnion]): + """Token request body for command-line clients.""" + + @model_validator(mode="before") + @classmethod + def default_grant_type(cls, data: Any) -> Any: + return default_to_password_grant(data) diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py index c195170ca0496..a726260d154e2 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py @@ -51,10 +51,17 @@ from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod as ExtendedResourceMethod from airflow.api_fastapi.common.types import MenuItem + +try: + from airflow.api_fastapi.common.types import CliLoginMethods +except ImportError: + # Airflow versions without CLI login discovery never call ``get_cli_login_methods``. + CliLoginMethods = None # type: ignore[assignment,misc] from airflow.cli.cli_config import CLICommand from airflow.providers.common.compat.sdk import AirflowException, conf from airflow.providers.keycloak.auth_manager.cache import single_flight from airflow.providers.keycloak.auth_manager.constants import ( + CONF_CLI_AUTHORIZATION_CODE_ENABLED_KEY, CONF_CLIENT_ID_KEY, CONF_CLIENT_SECRET_KEY, CONF_REALM_KEY, @@ -107,6 +114,10 @@ ) +def is_cli_authorization_code_enabled() -> bool: + return conf.getboolean(CONF_SECTION_NAME, CONF_CLI_AUTHORIZATION_CODE_ENABLED_KEY, fallback=False) + + class KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]): """ Keycloak auth manager. @@ -234,6 +245,19 @@ def get_url_logout(self) -> str | None: base_url = conf.get("api", "base_url", fallback="/") return urljoin(base_url, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/logout") + def get_cli_login_methods(self) -> CliLoginMethods: + token_endpoint = f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/token/cli" + if not is_cli_authorization_code_enabled(): + return CliLoginMethods( + grant_types_supported=["password", "refresh_token"], token_endpoint=token_endpoint + ) + return CliLoginMethods( + grant_types_supported=["password", "refresh_token", "authorization_code"], + code_challenge_methods_supported=["S256"], + authorization_endpoint=f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login/cli", + token_endpoint=token_endpoint, + ) + def refresh_user(self, *, user: KeycloakAuthManagerUser | None) -> KeycloakAuthManagerUser | None: # According to RFC6749 section 4.4.3, a refresh token should not be included when using # the Service accounts/client_credentials flow. diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml index 2158cc3a65b92..f92cd9e74c016 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/openapi/v2-keycloak-auth-manager-generated.yaml @@ -19,6 +19,59 @@ paths: content: application/json: schema: {} + /auth/login/cli: + get: + tags: + - KeycloakAuthManagerLogin + summary: Login Cli + description: Start the browser login of a command-line client. + operationId: login_cli + parameters: + - name: redirect_uri + in: query + required: true + schema: + type: string + title: Redirect Uri + - name: state + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 512 + title: State + - name: code_challenge + in: query + required: true + schema: + type: string + pattern: ^[A-Za-z0-9_-]{43}$ + title: Code Challenge + - name: code_challenge_method + in: query + required: true + schema: + type: string + title: Code Challenge Method + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Bad Request + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /auth/login_callback: get: tags: @@ -109,7 +162,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TokenPasswordBody' + $ref: '#/components/schemas/TokenCliBody' required: true responses: '201': @@ -160,6 +213,33 @@ components: title: Detail type: object title: HTTPValidationError + TokenAuthorizationCodeBody: + properties: + grant_type: + type: string + const: authorization_code + title: Grant Type + code: + type: string + title: Code + code_verifier: + type: string + maxLength: 128 + minLength: 43 + title: Code Verifier + redirect_uri: + type: string + title: Redirect Uri + additionalProperties: false + type: object + required: + - grant_type + - code + - code_verifier + - redirect_uri + title: TokenAuthorizationCodeBody + description: Authorization code grant serializer for post bodies, used by the + browser login of CLI clients. TokenBody: oneOf: - $ref: '#/components/schemas/TokenPasswordBody' @@ -173,6 +253,19 @@ components: client_credentials: '#/components/schemas/TokenClientCredentialsBody' password: '#/components/schemas/TokenPasswordBody' urn:ietf:params:oauth:grant-type:jwt-bearer: '#/components/schemas/TokenJwtFederatedBody' + TokenCliBody: + oneOf: + - $ref: '#/components/schemas/TokenPasswordBody' + - $ref: '#/components/schemas/TokenRefreshBody' + - $ref: '#/components/schemas/TokenAuthorizationCodeBody' + title: TokenCliBody + description: Token request body for command-line clients. + discriminator: + propertyName: grant_type + mapping: + authorization_code: '#/components/schemas/TokenAuthorizationCodeBody' + password: '#/components/schemas/TokenPasswordBody' + refresh_token: '#/components/schemas/TokenRefreshBody' TokenClientCredentialsBody: properties: grant_type: @@ -237,6 +330,23 @@ components: - password title: TokenPasswordBody description: Password grant token serializer for post bodies. + TokenRefreshBody: + properties: + grant_type: + type: string + const: refresh_token + title: Grant Type + token: + type: string + title: Token + additionalProperties: false + type: object + required: + - grant_type + - token + title: TokenRefreshBody + description: Refresh grant serializer for post bodies; ``token`` is the current + CLI token. TokenResponse: properties: access_token: diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py index 3040bb16c5d89..e3312c6c1e576 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py @@ -20,10 +20,10 @@ import json import logging import secrets -from typing import cast +from typing import Annotated, cast from urllib.parse import quote, urljoin -from fastapi import Request # noqa: TC002 +from fastapi import HTTPException, Query, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX, get_auth_manager @@ -50,6 +50,7 @@ class AuthManagerRefreshTokenExpiredException(Exception): # type: ignore[no-red from airflow.api_fastapi.common.router import AirflowRouter +from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc from airflow.providers.common.compat.sdk import conf from airflow.providers.keycloak.auth_manager.constants import ( COOKIE_NAME_ACCESS_TOKEN, @@ -57,7 +58,11 @@ class AuthManagerRefreshTokenExpiredException(Exception): # type: ignore[no-red COOKIE_NAME_OAUTH_STATE, COOKIE_NAME_REFRESH_TOKEN, ) -from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import KeycloakAuthManager +from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import ( + KeycloakAuthManager, + is_cli_authorization_code_enabled, +) +from airflow.providers.keycloak.auth_manager.services.token import is_cli_loopback_redirect_uri from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser log = logging.getLogger(__name__) @@ -96,6 +101,42 @@ def login(request: Request) -> RedirectResponse: return response +@login_router.get( + "/login/cli", + responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST]), +) +def login_cli( + redirect_uri: str, + state: Annotated[str, Query(min_length=1, max_length=512)], + # An S256 challenge is 43 base64url characters. ``auth_url`` does not encode its parameters, + # so nothing else may reach it. + code_challenge: Annotated[str, Query(pattern=r"^[A-Za-z0-9_-]{43}$")], + code_challenge_method: str, +) -> RedirectResponse: + """Start the browser login of a command-line client.""" + if not is_cli_authorization_code_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Browser login is not enabled for command-line clients", + ) + if not is_cli_loopback_redirect_uri(redirect_uri): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid redirect_uri") + if code_challenge_method != "S256": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="code_challenge_method must be S256" + ) + + client = KeycloakAuthManager.get_keycloak_client() + auth_url = client.auth_url( + redirect_uri=quote(redirect_uri, safe=""), + scope="openid", + state=quote(state, safe=""), + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + return RedirectResponse(auth_url) + + @login_router.get("/login_callback") def login_callback(request: Request): """Authenticate the user.""" diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py index 11b40f5c265fc..84f7c97947069 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/token.py @@ -26,7 +26,7 @@ from airflow.providers.common.compat.sdk import conf from airflow.providers.keycloak.auth_manager.datamodels.token import ( TokenBody, - TokenPasswordBody, + TokenCliBody, TokenResponse, ) @@ -51,8 +51,8 @@ def create_token(body: TokenBody) -> TokenResponse: status_code=status.HTTP_201_CREATED, responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, status.HTTP_403_FORBIDDEN]), ) -def create_token_cli(body: TokenPasswordBody) -> TokenResponse: - token = body.create_token( +def create_token_cli(body: TokenCliBody) -> TokenResponse: + token = body.root.create_token( expiration_time_in_seconds=int(conf.getint("api_auth", "jwt_cli_expiration_time")) ) return TokenResponse(access_token=token) diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py index 2f57e080efa65..3fb9a845f2030 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/services/token.py @@ -18,12 +18,16 @@ from __future__ import annotations import json +import time from functools import cache +from typing import cast +from urllib.parse import urlsplit import jwt +from asgiref.sync import async_to_sync from fastapi import HTTPException, status from jwt import PyJWKClient -from keycloak import KeycloakAuthenticationError, KeycloakError +from keycloak import KeycloakAuthenticationError, KeycloakError, KeycloakOpenID from airflow.api_fastapi.app import get_auth_manager from airflow.providers.common.compat.sdk import conf @@ -34,9 +38,57 @@ CONF_SECTION_NAME, CONF_SERVER_URL_KEY, ) -from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import KeycloakAuthManager +from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import ( + KeycloakAuthManager, + is_cli_authorization_code_enabled, +) from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser +# RFC 8252 section 7.3 loopback redirects. ``localhost`` is left out because it can resolve +# to a non-loopback interface. +CLI_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) +CLI_CALLBACK_PATH = "/callback" + + +def is_cli_loopback_redirect_uri(redirect_uri: str) -> bool: + """Return whether ``redirect_uri`` is a loopback callback a command-line client listens on.""" + try: + parts = urlsplit(redirect_uri) + port = parts.port + except ValueError: + return False + return ( + parts.scheme == "http" + and parts.hostname in CLI_LOOPBACK_HOSTS + and port is not None + # Reject userinfo: ``:x@127.0.0.1:1`` still has a loopback hostname. + and "@" not in parts.netloc + and parts.path == CLI_CALLBACK_PATH + and not parts.query + and not parts.fragment + ) + + +def _build_user_from_userinfo(client: KeycloakOpenID, tokens: dict) -> KeycloakAuthManagerUser: + """Build the Airflow user that Keycloak's UserInfo endpoint describes for ``tokens``.""" + userinfo_raw: dict | bytes = client.userinfo(tokens["access_token"]) + userinfo: dict = json.loads(userinfo_raw) if isinstance(userinfo_raw, bytes) else userinfo_raw + + return KeycloakAuthManagerUser( + user_id=userinfo["sub"], + name=userinfo.get("preferred_username", userinfo.get("clientId", "service-account")), + access_token=tokens["access_token"], + # Absent for client_credentials and clients with "Use Refresh Tokens" off. + refresh_token=tokens.get("refresh_token"), + ) + + +def _create_api_jwt_from_keycloak_tokens( + client: KeycloakOpenID, tokens: dict, expiration_time_in_seconds: int +) -> str: + user = _build_user_from_userinfo(client, tokens) + return get_auth_manager().generate_api_jwt(user, expiration_time_in_seconds=expiration_time_in_seconds) + @cache def _get_jwks_client(issuer: str) -> PyJWKClient: @@ -59,18 +111,7 @@ def create_token_for( detail="Invalid credentials", ) - userinfo_raw: dict | bytes = client.userinfo(tokens["access_token"]) - # Decode bytes to dict if necessary - userinfo: dict = json.loads(userinfo_raw) if isinstance(userinfo_raw, bytes) else userinfo_raw - - user = KeycloakAuthManagerUser( - user_id=userinfo["sub"], - name=userinfo["preferred_username"], - access_token=tokens["access_token"], - refresh_token=tokens.get("refresh_token"), - ) - - return get_auth_manager().generate_api_jwt(user, expiration_time_in_seconds=expiration_time_in_seconds) + return _create_api_jwt_from_keycloak_tokens(client, tokens, expiration_time_in_seconds) def create_jwt_federated_token( @@ -129,25 +170,16 @@ def create_jwt_federated_token( detail="Invalid Keycloak assertion", ) - # Confirm the assertion is still live (not revoked) and fetch the same shape of - # user info create_client_credentials_token uses, rather than trusting the JWT's + # Confirm the assertion is still live (not revoked) rather than trusting the JWT's # own claims alone. client = KeycloakAuthManager.get_keycloak_client() try: - userinfo_raw: dict | bytes = client.userinfo(assertion) + user = _build_user_from_userinfo(client, {"access_token": assertion}) except KeycloakError: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid Keycloak assertion", ) - userinfo: dict = json.loads(userinfo_raw) if isinstance(userinfo_raw, bytes) else userinfo_raw - - user = KeycloakAuthManagerUser( - user_id=userinfo["sub"], - name=userinfo.get("preferred_username", userinfo.get("clientId", "service-account")), - access_token=assertion, - refresh_token=None, - ) return get_auth_manager().generate_api_jwt(user, expiration_time_in_seconds=expiration_time_in_seconds) @@ -194,19 +226,80 @@ def create_client_credentials_token( detail="Client credentials authentication failed", ) - # For client_credentials, get the service account user info - # The token represents the service account associated with the client - userinfo_raw: dict | bytes = client.userinfo(tokens["access_token"]) - # Decode bytes to dict if necessary - userinfo: dict = json.loads(userinfo_raw) if isinstance(userinfo_raw, bytes) else userinfo_raw + return _create_api_jwt_from_keycloak_tokens(client, tokens, expiration_time_in_seconds) - user = KeycloakAuthManagerUser( - user_id=userinfo["sub"], - name=userinfo.get("preferred_username", userinfo.get("clientId", "service-account")), - access_token=tokens["access_token"], - refresh_token=tokens.get( - "refresh_token" - ), # client_credentials may not return refresh_token (RFC6749 section 4.4.3) - ) - return get_auth_manager().generate_api_jwt(user, expiration_time_in_seconds=expiration_time_in_seconds) +def create_token_from_authorization_code( + code: str, + code_verifier: str, + redirect_uri: str, + expiration_time_in_seconds: int, +) -> str: + """ + Create a CLI token from an authorization code obtained through ``/login/cli``. + + Keycloak checks ``code_verifier`` against the challenge ``/login/cli`` forwarded, so + the code is useless to anyone who intercepted it on the loopback redirect. + """ + if not is_cli_authorization_code_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The authorization_code grant is not enabled for command-line clients", + ) + if not is_cli_loopback_redirect_uri(redirect_uri): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid redirect_uri") + + client = KeycloakAuthManager.get_keycloak_client() + try: + tokens = client.token( + grant_type="authorization_code", + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + scope="openid", + ) + except KeycloakError: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid authorization code") + + return _create_api_jwt_from_keycloak_tokens(client, tokens, expiration_time_in_seconds) + + +def refresh_cli_token(token: str) -> str: + """ + Create a CLI token with fresh Keycloak tokens from a CLI token that is still valid. + + The new token expires with the old one, so refreshing never extends a CLI session, and the old + token is revoked. Expired and revoked tokens are refused by ``get_user_from_token``. + """ + auth_manager = get_auth_manager() + try: + user = cast("KeycloakAuthManagerUser | None", async_to_sync(auth_manager.get_user_from_token)(token)) + except jwt.PyJWTError: + user = None + if not user: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token") + if not user.refresh_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This token cannot be refreshed because Keycloak issued it without a refresh token", + ) + + # get_user_from_token allows [api_auth] jwt_leeway past exp. + expires_at = jwt.decode(token, options={"verify_signature": False})["exp"] + expiration_time_in_seconds = int(expires_at - time.time()) + if expiration_time_in_seconds <= 0: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token") + + client = KeycloakAuthManager.get_keycloak_client() + try: + tokens = client.refresh_token(user.refresh_token) + except KeycloakError: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token") + + user.access_token = tokens["access_token"] + # RFC 6749 section 6: a refresh response may omit a new refresh token, and the old one stays valid. + user.refresh_token = tokens.get("refresh_token", user.refresh_token) + new_token = auth_manager.generate_api_jwt(user, expiration_time_in_seconds=expiration_time_in_seconds) + # Otherwise the old token stays usable and refreshable until it expires. + auth_manager.revoke_token(token) + return new_token diff --git a/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py b/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py index f4b0ae3a890a6..6b2be79c0b323 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py +++ b/providers/keycloak/src/airflow/providers/keycloak/get_provider_info.py @@ -71,6 +71,13 @@ def get_provider_info(): "example": None, "default": None, }, + "cli_authorization_code_enabled": { + "description": "Let command-line clients such as ``airflowctl`` log in through the browser, with the\nauthorization code flow and PKCE. The Keycloak client must allow the loopback redirect\nURIs ``http://127.0.0.1/callback`` and ``http://[::1]/callback``.\n", + "type": "boolean", + "version_added": "0.12.0", + "example": None, + "default": "False", + }, "jwt_federated_client_ids": { "description": "Comma-separated allow-list of Keycloak client ids (``azp`` claim) permitted to\nexchange a Keycloak-issued access token for an Airflow token via the\n``urn:ietf:params:oauth:grant-type:jwt-bearer`` grant at ``/auth/token``. Unset\nor empty denies every caller.\n", "type": "string", diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py index bfe9b852b8c1b..122d3ef4a6e4b 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_login.py @@ -25,8 +25,58 @@ from tests_common.test_utils.config import conf_vars from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS +CLI_LOGIN_PARAMS = { + "redirect_uri": "http://127.0.0.1:53682/callback", + "state": "state", + "code_challenge": "c" * 43, + "code_challenge_method": "S256", +} + class TestLoginRouter: + @conf_vars({("keycloak_auth_manager", "cli_authorization_code_enabled"): "True"}) + @patch("airflow.providers.keycloak.auth_manager.routes.login.KeycloakAuthManager.get_keycloak_client") + def test_login_cli(self, mock_get_keycloak_client, client): + mock_get_keycloak_client.return_value.auth_url.return_value = "https://keycloak/auth" + + response = client.get( + AUTH_MANAGER_FASTAPI_APP_PREFIX + "/login/cli", params=CLI_LOGIN_PARAMS, follow_redirects=False + ) + + assert response.status_code == 307 + assert response.headers["location"] == "https://keycloak/auth" + mock_get_keycloak_client.return_value.auth_url.assert_called_once_with( + # auth_url does not encode its parameters, so the route has to + redirect_uri="http%3A%2F%2F127.0.0.1%3A53682%2Fcallback", + scope="openid", + state="state", + code_challenge="c" * 43, + code_challenge_method="S256", + ) + + @pytest.mark.parametrize( + ("enabled", "overrides", "expected_status"), + [ + pytest.param("False", {}, 400, id="disabled"), + pytest.param( + "True", {"redirect_uri": "https://evil.example.com/callback"}, 400, id="not-loopback" + ), + pytest.param("True", {"code_challenge_method": "plain"}, 400, id="plain-challenge"), + pytest.param("True", {"code_challenge": "c" * 42 + "&"}, 422, id="challenge-injection"), + pytest.param("True", {"code_challenge": None}, 422, id="missing-challenge"), + ], + ) + @patch("airflow.providers.keycloak.auth_manager.routes.login.KeycloakAuthManager.get_keycloak_client") + def test_login_cli_rejected(self, mock_get_keycloak_client, client, enabled, overrides, expected_status): + params = {k: v for k, v in {**CLI_LOGIN_PARAMS, **overrides}.items() if v is not None} + with conf_vars({("keycloak_auth_manager", "cli_authorization_code_enabled"): enabled}): + response = client.get( + AUTH_MANAGER_FASTAPI_APP_PREFIX + "/login/cli", params=params, follow_redirects=False + ) + + assert response.status_code == expected_status + mock_get_keycloak_client.assert_not_called() + @patch("airflow.providers.keycloak.auth_manager.routes.login.KeycloakAuthManager.get_keycloak_client") def test_login(self, mock_get_keycloak_client, client): redirect_url = "redirect_url" diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py index d7903ca48797d..49b15ebea8706 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/routes/test_token.py @@ -126,6 +126,10 @@ def test_create_token_jwt_bearer_grant(self, mock_create_jwt_federated_token, cl {"grant_type": "client_credentials", "username": "username", "client_secret": "client_secret"}, {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer"}, {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "username": "username"}, + # A body that is valid JSON but not an object reaches the grant_type default + "username", + ["username"], + 1, ], ) @conf_vars( @@ -143,3 +147,75 @@ def test_create_token_invalid_body(self, mock_create_client_credentials_token, c assert response.status_code == 422 mock_create_client_credentials_token.assert_not_called() + + @conf_vars({("api_auth", "jwt_cli_expiration_time"): "10"}) + @patch("airflow.providers.keycloak.auth_manager.datamodels.token.refresh_cli_token") + def test_create_token_cli_refresh_grant(self, mock_refresh_cli_token, client): + mock_refresh_cli_token.return_value = self.token + response = client.post( + AUTH_MANAGER_FASTAPI_APP_PREFIX + "/token/cli", + json={"grant_type": "refresh_token", "token": "old"}, + ) + + assert response.status_code == 201 + assert response.json() == {"access_token": self.token} + mock_refresh_cli_token.assert_called_once_with("old") + + @conf_vars({("api_auth", "jwt_cli_expiration_time"): "10"}) + @patch("airflow.providers.keycloak.auth_manager.datamodels.token.create_token_from_authorization_code") + def test_create_token_cli_authorization_code_grant(self, mock_create_token, client): + mock_create_token.return_value = self.token + response = client.post( + AUTH_MANAGER_FASTAPI_APP_PREFIX + "/token/cli", + json={ + "grant_type": "authorization_code", + "code": "code", + "code_verifier": "v" * 43, + "redirect_uri": "http://127.0.0.1:53682/callback", + }, + ) + + assert response.status_code == 201 + assert response.json() == {"access_token": self.token} + mock_create_token.assert_called_once_with( + "code", "v" * 43, "http://127.0.0.1:53682/callback", expiration_time_in_seconds=10 + ) + + @pytest.mark.parametrize( + "body", + [ + pytest.param( + {"grant_type": "client_credentials", "client_id": "id", "client_secret": "secret"}, + id="client-credentials", + ), + pytest.param( + {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": "assertion"}, + id="jwt-bearer", + ), + pytest.param( + { + "grant_type": "authorization_code", + "code": "code", + "redirect_uri": "http://127.0.0.1:53682/callback", + }, + id="missing-code-verifier", + ), + pytest.param( + { + "grant_type": "authorization_code", + "code": "code", + "code_verifier": "short", + "redirect_uri": "http://127.0.0.1:53682/callback", + }, + id="short-code-verifier", + ), + # A body that is valid JSON but not an object reaches the grant_type default + pytest.param("token", id="json-string"), + pytest.param(["token"], id="json-list"), + pytest.param(1, id="json-number"), + ], + ) + def test_create_token_cli_rejects_body(self, client, body): + response = client.post(AUTH_MANAGER_FASTAPI_APP_PREFIX + "/token/cli", json=body) + + assert response.status_code == 422 diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py b/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py index ada21b9b8db6b..f588730801524 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/services/test_token.py @@ -16,20 +16,26 @@ # under the License. from __future__ import annotations -from unittest.mock import Mock, patch +import time +from unittest.mock import AsyncMock, Mock, patch import fastapi import jwt import pytest -from keycloak import KeycloakAuthenticationError -from keycloak.exceptions import KeycloakGetError +import time_machine +from keycloak import KeycloakAuthenticationError, KeycloakOpenID +from keycloak.exceptions import KeycloakGetError, KeycloakPostError from airflow.providers.common.compat.sdk import conf from airflow.providers.keycloak.auth_manager.services.token import ( create_client_credentials_token, create_jwt_federated_token, create_token_for, + create_token_from_authorization_code, + is_cli_loopback_redirect_uri, + refresh_cli_token, ) +from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser from tests_common.test_utils.config import conf_vars @@ -377,3 +383,243 @@ def test_create_jwt_federated_token_userinfo_rejected( create_jwt_federated_token(assertion=self.test_assertion) assert exc_info.value.status_code == 403 + + +class TestCliLoopbackRedirectUri: + @pytest.mark.parametrize( + ("redirect_uri", "expected"), + [ + ("http://127.0.0.1:53682/callback", True), + ("http://[::1]:53682/callback", True), + ("http://127.0.0.1/callback", False), + ("http://localhost:53682/callback", False), + ("https://127.0.0.1:53682/callback", False), + ("http://evil.example.com:53682/callback", False), + ("http://user@127.0.0.1:53682/callback", False), + # Userinfo whose username is empty still leaves a loopback ``hostname`` + ("http://:x&code_challenge=evil&code_challenge_method=plain@127.0.0.1:5/callback", False), + ("http://127.0.0.1:53682/other", False), + ("http://127.0.0.1:53682/callback?next=x", False), + ("http://127.0.0.1:53682/callback#x", False), + ("http://127.0.0.1:99999/callback", False), + ], + ) + def test_is_cli_loopback_redirect_uri(self, redirect_uri, expected): + assert is_cli_loopback_redirect_uri(redirect_uri) is expected + + +class TestCliTokenService: + token = "token" + redirect_uri = "http://127.0.0.1:53682/callback" + code_verifier = "v" * 43 + + @staticmethod + def cli_token(expires_in: int) -> str: + """Build a CLI token expiring ``expires_in`` seconds from now; negative values are past.""" + return jwt.encode({"exp": int(time.time()) + expires_in}, "secret", algorithm="HS256") + + @pytest.mark.parametrize( + ("tokens", "expected_refresh_token"), + [ + pytest.param( + {"access_token": "access", "refresh_token": "refresh"}, "refresh", id="with_refresh" + ), + # Keycloak clients with "Use Refresh Tokens" disabled return no refresh_token + pytest.param({"access_token": "access"}, None, id="without_refresh"), + ], + ) + @conf_vars({("keycloak_auth_manager", "cli_authorization_code_enabled"): "True"}) + @patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager") + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_create_token_from_authorization_code( + self, mock_get_keycloak_client, mock_get_auth_manager, tokens, expected_refresh_token + ): + mock_keycloak_client = Mock(spec=KeycloakOpenID) + mock_keycloak_client.token.return_value = tokens + mock_keycloak_client.userinfo.return_value = {"sub": "sub", "preferred_username": "username"} + mock_get_keycloak_client.return_value = mock_keycloak_client + mock_get_auth_manager.return_value.generate_api_jwt.return_value = self.token + + token = create_token_from_authorization_code( + "code", self.code_verifier, self.redirect_uri, expiration_time_in_seconds=10 + ) + + assert token == self.token + mock_keycloak_client.token.assert_called_once_with( + grant_type="authorization_code", + code="code", + redirect_uri=self.redirect_uri, + code_verifier=self.code_verifier, + scope="openid", + ) + user = mock_get_auth_manager.return_value.generate_api_jwt.call_args.args[0] + assert (user.get_id(), user.access_token, user.refresh_token) == ( + "sub", + "access", + expected_refresh_token, + ) + mock_get_auth_manager.return_value.generate_api_jwt.assert_called_once_with( + user, expiration_time_in_seconds=10 + ) + + @pytest.mark.parametrize( + ("enabled", "redirect_uri", "expected_detail"), + [ + ( + "False", + "http://127.0.0.1:53682/callback", + "The authorization_code grant is not enabled for command-line clients", + ), + ("True", "https://evil.example.com/callback", "Invalid redirect_uri"), + ], + ) + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_create_token_from_authorization_code_rejected( + self, mock_get_keycloak_client, enabled, redirect_uri, expected_detail + ): + with conf_vars({("keycloak_auth_manager", "cli_authorization_code_enabled"): enabled}): + with pytest.raises(fastapi.exceptions.HTTPException) as exc_info: + create_token_from_authorization_code( + "code", self.code_verifier, redirect_uri, expiration_time_in_seconds=10 + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == expected_detail + mock_get_keycloak_client.assert_not_called() + + @conf_vars({("keycloak_auth_manager", "cli_authorization_code_enabled"): "True"}) + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_create_token_from_authorization_code_keycloak_error(self, mock_get_keycloak_client): + mock_get_keycloak_client.return_value = Mock(spec=KeycloakOpenID) + mock_get_keycloak_client.return_value.token.side_effect = KeycloakPostError("invalid_grant") + + with pytest.raises(fastapi.exceptions.HTTPException) as exc_info: + create_token_from_authorization_code( + "code", self.code_verifier, self.redirect_uri, expiration_time_in_seconds=10 + ) + + assert exc_info.value.status_code == 403 + + @time_machine.travel("2026-01-01 00:00:00", tick=False) + @patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager") + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_refresh_cli_token_keeps_original_expiry(self, mock_get_keycloak_client, mock_get_auth_manager): + old_token = self.cli_token(expires_in=600) + mock_auth_manager = mock_get_auth_manager.return_value + mock_auth_manager.get_user_from_token = AsyncMock( + return_value=KeycloakAuthManagerUser( + user_id="sub", name="username", access_token="old_access", refresh_token="old_refresh" + ) + ) + mock_auth_manager.generate_api_jwt.return_value = self.token + mock_get_keycloak_client.return_value = Mock(spec=KeycloakOpenID) + mock_get_keycloak_client.return_value.refresh_token.return_value = { + "access_token": "new_access", + "refresh_token": "new_refresh", + } + + assert refresh_cli_token(old_token) == self.token + + mock_get_keycloak_client.return_value.refresh_token.assert_called_once_with("old_refresh") + user = mock_auth_manager.generate_api_jwt.call_args.args[0] + assert (user.access_token, user.refresh_token) == ("new_access", "new_refresh") + mock_auth_manager.generate_api_jwt.assert_called_once_with(user, expiration_time_in_seconds=600) + mock_auth_manager.revoke_token.assert_called_once_with(old_token) + + @time_machine.travel("2026-01-01 00:00:00", tick=False) + @patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager") + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_refresh_cli_token_keeps_refresh_token_keycloak_did_not_replace( + self, mock_get_keycloak_client, mock_get_auth_manager + ): + mock_auth_manager = mock_get_auth_manager.return_value + mock_auth_manager.get_user_from_token = AsyncMock( + return_value=KeycloakAuthManagerUser( + user_id="sub", name="username", access_token="old_access", refresh_token="old_refresh" + ) + ) + mock_auth_manager.generate_api_jwt.return_value = self.token + mock_get_keycloak_client.return_value = Mock(spec=KeycloakOpenID) + mock_get_keycloak_client.return_value.refresh_token.return_value = {"access_token": "new_access"} + + assert refresh_cli_token(self.cli_token(expires_in=600)) == self.token + + user = mock_auth_manager.generate_api_jwt.call_args.args[0] + assert (user.access_token, user.refresh_token) == ("new_access", "old_refresh") + + @pytest.mark.parametrize( + "get_user_from_token", + [ + pytest.param(AsyncMock(side_effect=jwt.ExpiredSignatureError()), id="expired"), + pytest.param( + AsyncMock(side_effect=jwt.InvalidTokenError("Token has been revoked")), id="revoked" + ), + pytest.param(AsyncMock(return_value=None), id="no-keycloak-tokens"), + ], + ) + @patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager") + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_refresh_cli_token_rejected( + self, mock_get_keycloak_client, mock_get_auth_manager, get_user_from_token + ): + mock_get_auth_manager.return_value.get_user_from_token = get_user_from_token + + with pytest.raises(fastapi.exceptions.HTTPException) as exc_info: + refresh_cli_token(self.token) + + assert exc_info.value.status_code == 403 + mock_get_keycloak_client.assert_not_called() + mock_get_auth_manager.return_value.revoke_token.assert_not_called() + + @patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager") + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_refresh_cli_token_without_refresh_token(self, mock_get_keycloak_client, mock_get_auth_manager): + mock_get_auth_manager.return_value.get_user_from_token = AsyncMock( + return_value=KeycloakAuthManagerUser( + user_id="sub", name="sa", access_token="access", refresh_token=None + ) + ) + + with pytest.raises(fastapi.exceptions.HTTPException) as exc_info: + refresh_cli_token(self.token) + + assert exc_info.value.status_code == 400 + assert "without a refresh token" in exc_info.value.detail + mock_get_keycloak_client.assert_not_called() + + @time_machine.travel("2026-01-01 00:00:00", tick=False) + @patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager") + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_refresh_cli_token_within_leeway_of_expiry(self, mock_get_keycloak_client, mock_get_auth_manager): + """A token [api_auth] jwt_leeway still accepts must not refresh into a negative lifetime.""" + mock_get_auth_manager.return_value.get_user_from_token = AsyncMock( + return_value=KeycloakAuthManagerUser( + user_id="sub", name="username", access_token="access", refresh_token="refresh" + ) + ) + + with pytest.raises(fastapi.exceptions.HTTPException) as exc_info: + refresh_cli_token(self.cli_token(expires_in=-5)) + + assert exc_info.value.status_code == 403 + mock_get_keycloak_client.assert_not_called() + + @time_machine.travel("2026-01-01 00:00:00", tick=False) + @patch("airflow.providers.keycloak.auth_manager.services.token.get_auth_manager") + @patch("airflow.providers.keycloak.auth_manager.services.token.KeycloakAuthManager.get_keycloak_client") + def test_refresh_cli_token_keycloak_session_expired( + self, mock_get_keycloak_client, mock_get_auth_manager + ): + mock_get_auth_manager.return_value.get_user_from_token = AsyncMock( + return_value=KeycloakAuthManagerUser( + user_id="sub", name="username", access_token="access", refresh_token="refresh" + ) + ) + mock_get_keycloak_client.return_value = Mock(spec=KeycloakOpenID) + mock_get_keycloak_client.return_value.refresh_token.side_effect = KeycloakPostError("invalid_grant") + + with pytest.raises(fastapi.exceptions.HTTPException) as exc_info: + refresh_cli_token(self.cli_token(expires_in=600)) + + assert exc_info.value.status_code == 403 + mock_get_auth_manager.return_value.generate_api_jwt.assert_not_called() diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py index 03f3d5182051b..075a6695b5743 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py @@ -17,6 +17,7 @@ from __future__ import annotations import base64 +import dataclasses import json from concurrent.futures import ThreadPoolExecutor from contextlib import ExitStack @@ -376,6 +377,36 @@ def test_get_url_logout(self, auth_manager): result = auth_manager.get_url_logout() assert result == f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/logout" + @pytest.mark.skipif(not AIRFLOW_V_3_4_PLUS, reason="CLI login discovery was added in Airflow 3.4") + @pytest.mark.parametrize( + ("authorization_code_enabled", "expected"), + [ + ( + "False", + { + "grant_types_supported": ["password", "refresh_token"], + "code_challenge_methods_supported": [], + "authorization_endpoint": None, + "device_authorization_endpoint": None, + "token_endpoint": f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/token/cli", + }, + ), + ( + "True", + { + "grant_types_supported": ["password", "refresh_token", "authorization_code"], + "code_challenge_methods_supported": ["S256"], + "authorization_endpoint": f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login/cli", + "device_authorization_endpoint": None, + "token_endpoint": f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/token/cli", + }, + ), + ], + ) + def test_get_cli_login_methods(self, auth_manager, authorization_code_enabled, expected): + with conf_vars({(CONF_SECTION_NAME, "cli_authorization_code_enabled"): authorization_code_enabled}): + assert dataclasses.asdict(auth_manager.get_cli_login_methods()) == expected + @patch.object(KeycloakAuthManager, "_token_expired") def test_refresh_user_not_expired(self, mock_token_expired, auth_manager): mock_token_expired.return_value = False diff --git a/uv.lock b/uv.lock index 37884bf29cf31..dfb7fe51f4a92 100644 --- a/uv.lock +++ b/uv.lock @@ -6672,7 +6672,7 @@ docs = [ requires-dist = [ { name = "apache-airflow", editable = "." }, { name = "apache-airflow-providers-common-compat", editable = "providers/common/compat" }, - { name = "python-keycloak", specifier = ">=5.0.0" }, + { name = "python-keycloak", specifier = ">=5.12.0" }, ] [package.metadata.requires-dev]