Skip to content
Open
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
2 changes: 1 addition & 1 deletion providers/keycloak/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion providers/keycloak/docs/auth-manager/token.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "<current CLI 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:<port>/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.
2 changes: 1 addition & 1 deletion providers/keycloak/docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions providers/keycloak/provider.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion providers/keycloak/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""

Expand Down Expand Up @@ -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"),
Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -109,7 +162,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/TokenPasswordBody'
$ref: '#/components/schemas/TokenCliBody'
required: true
responses:
'201':
Expand Down Expand Up @@ -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'
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading