Skip to content

Commit 11e127b

Browse files
authored
Create create_token method in FAB auth manager (#59245)
1 parent 12a66db commit 11e127b

File tree

10 files changed

+238
-109
lines changed

10 files changed

+238
-109
lines changed

providers/fab/docs/auth-manager/api-authentication.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ command as in the example below.
3838
3939
.. versionchanged:: 3.0.0
4040

41-
In Airflow, the default setting is using token based authentication.
42-
This approach is independent from which ``auth_backend`` is used.
43-
The default setting is using Airflow public API to create a token (JWT) first and use this token in the requests to access the API.
41+
Airflow now uses token-based authentication for the public API.
42+
This mechanism is independent of the configured ``auth_backend``.
43+
Clients must first obtain a JWT token using :doc:`token`, then include that token in subsequent API requests.
4444

4545
Kerberos authentication
4646
'''''''''''''''''''''''

providers/fab/docs/auth-manager/token.rst

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,18 @@ Generate JWT token with FAB auth manager
2121
.. note::
2222
This guide only applies if your environment is configured with FAB auth manager.
2323

24-
In order to use the :doc:`Airflow public API <apache-airflow:stable-rest-api-ref>`, you need a JWT token for authentication.
25-
You can then include this token in your Airflow public API requests.
26-
To generate a JWT token, use the ``Create Token`` API in :doc:`/api-ref/fab-token-api-ref`.
24+
To use the :doc:`Airflow public API <apache-airflow:stable-rest-api-ref>`, you first need to obtain a JWT Token for
25+
authentication.
26+
Once you have the token, include it in the ``Authorization`` header when making requests to the public API.
27+
28+
You can generate a JWT token using the ``Create Token`` API endpoint,
29+
documented in :doc:`/api-ref/fab-token-api-ref`.
2730

2831
Example
2932
'''''''
3033

34+
Use the following example to generate a token via username and password.
35+
3136
.. code-block:: bash
3237
3338
ENDPOINT_URL="http://localhost:8080"
@@ -39,7 +44,108 @@ Example
3944
"password": "<password>"
4045
}'
4146
42-
This process will return a token that you can use in the Airflow public API requests.
47+
If successful, this request returns a JWT token that you can use for subsequent Airflow public API calls.
48+
49+
Only users authenticated via the database (``AUTH_TYPE = AUTH_DB``) or LDAP
50+
(``AUTH_TYPE = AUTH_LDAP``) can generate tokens using this method.
51+
For more details, see :doc:`webserver-authentication`.
52+
53+
If you need to generate a token using a different authentication mechanism, see the next section.
54+
55+
Custom authentication implementation
56+
------------------------------------
57+
58+
By default, JWT tokens for the Airflow public API can only be generated using
59+
basic authentication (username and password) for database or LDAP users.
60+
61+
If you want to support another authentication mechanism, such as oauth, you can do so by overriding the
62+
``create_token`` method in the FAB auth manager.
63+
64+
Example
65+
'''''''
66+
67+
.. code-block:: python
68+
69+
class MyAuthManager(FabAuthManager):
70+
71+
def create_token(self, headers: dict[str, str], body: dict[str, Any]) -> User:
72+
"""
73+
Return the authenticated user for a given payload.
74+
75+
Implement your own custom token creation logic here.
76+
"""
77+
...
78+
79+
Oauth example
80+
'''''''''''''
81+
82+
Below is an example implementation that uses OAuth to allow users to obtain a JWT token.
83+
This custom logic overrides the default ``create_token`` method from the FAB authentication manager.
84+
85+
.. warning::
86+
The example shown below disables signature verification (``verify_signature=False``).
87+
This is **insecure** and should only be used for testing. Always validate tokens properly in production.
88+
89+
.. code-block:: python
90+
91+
class MyAuthManager(FabAuthManager):
92+
93+
def create_token(self, headers: dict[str, str], body: dict[str, Any]) -> User:
94+
"""
95+
Return the authenticated user derived from an OAuth access token.
96+
97+
Implement your own custom token validation and user mapping logic here.
98+
"""
99+
user = None
100+
101+
# Handle OAuth-based authentication
102+
if self.security_manager.auth_type == AUTH_OAUTH:
103+
# Require a Bearer token
104+
auth_header = headers.get("Authorization")
105+
if not auth_header:
106+
return None
107+
108+
token = auth_header.replace("Bearer ", "")
109+
110+
# Example token decoding
111+
#
112+
# With signature validation (recommended):
113+
# me = jwt.decode(
114+
# token,
115+
# public_key,
116+
# algorithms=['HS256', 'RS256'],
117+
# audience=CLIENT_ID
118+
# )
119+
#
120+
# Without signature validation (not recommended):
121+
me = jwt.decode(token, options={"verify_signature": False})
122+
123+
# Extract groups/roles (example schema — adjust to your provider)
124+
groups = me["resource_access"]["airflow"]["roles"] # requires validation
125+
if not groups:
126+
groups = ["airflow_public"]
127+
else:
128+
groups = [g for g in groups if "airflow" in g]
129+
130+
# Build user info payload for FAB
131+
userinfo = {
132+
"username": me.get("preferred_username"),
133+
"email": me.get("email"),
134+
"first_name": me.get("given_name"),
135+
"last_name": me.get("family_name"),
136+
"role_keys": groups,
137+
}
138+
139+
user = self.security_manager.auth_user_oauth(userinfo)
140+
141+
# Fall back to the default implementation
142+
else:
143+
user = super().create_token(headers=headers, body=body)
144+
145+
log.info("User: %s", user)
146+
147+
# Log user into the session
148+
if user is not None:
149+
login_user(user, remember=False)
43150
44-
Only users from database (`AUTH_TYPE = AUTH_DB`) or from LDAP (`AUTH_TYPE = AUTH_LDAP`) can be used to generate a token.
45-
See :doc:`Airflow public API <webserver-authentication>` for more details.
151+
return user

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/login.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,3 @@ class LoginResponse(BaseModel):
2323
"""API Token serializer for responses."""
2424

2525
access_token: str
26-
27-
28-
class LoginBody(BaseModel):
29-
"""API Token serializer for requests."""
30-
31-
username: str
32-
password: str

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ paths:
1717
content:
1818
application/json:
1919
schema:
20-
$ref: '#/components/schemas/LoginBody'
20+
additionalProperties: true
21+
type: object
22+
title: Body
2123
required: true
2224
responses:
2325
'201':
@@ -55,7 +57,9 @@ paths:
5557
content:
5658
application/json:
5759
schema:
58-
$ref: '#/components/schemas/LoginBody'
60+
additionalProperties: true
61+
type: object
62+
title: Body
5963
required: true
6064
responses:
6165
'201':
@@ -440,20 +444,6 @@ components:
440444
title: Detail
441445
type: object
442446
title: HTTPValidationError
443-
LoginBody:
444-
properties:
445-
username:
446-
type: string
447-
title: Username
448-
password:
449-
type: string
450-
title: Password
451-
type: object
452-
required:
453-
- username
454-
- password
455-
title: LoginBody
456-
description: API Token serializer for requests.
457447
LoginResponse:
458448
properties:
459449
access_token:

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19+
from typing import Any
20+
21+
from fastapi import Body
1922
from starlette import status
2023
from starlette.requests import Request # noqa: TC002
2124
from starlette.responses import RedirectResponse
@@ -25,7 +28,7 @@
2528
from airflow.api_fastapi.common.router import AirflowRouter
2629
from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
2730
from airflow.configuration import conf
28-
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginBody, LoginResponse
31+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginResponse
2932
from airflow.providers.fab.auth_manager.api_fastapi.services.login import FABAuthManagerLogin
3033
from airflow.providers.fab.auth_manager.cli_commands.utils import get_application_builder
3134

@@ -38,10 +41,10 @@
3841
status_code=status.HTTP_201_CREATED,
3942
responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]),
4043
)
41-
def create_token(body: LoginBody) -> LoginResponse:
44+
def create_token(request: Request, body: dict[str, Any] = Body(...)) -> LoginResponse:
4245
"""Generate a new API token."""
4346
with get_application_builder():
44-
return FABAuthManagerLogin.create_token(body=body)
47+
return FABAuthManagerLogin.create_token(headers=dict(request.headers), body=body)
4548

4649

4750
@login_router.post(
@@ -50,11 +53,13 @@ def create_token(body: LoginBody) -> LoginResponse:
5053
status_code=status.HTTP_201_CREATED,
5154
responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]),
5255
)
53-
def create_token_cli(body: LoginBody) -> LoginResponse:
56+
def create_token_cli(request: Request, body: dict[str, Any] = Body(...)) -> LoginResponse:
5457
"""Generate a new CLI API token."""
5558
with get_application_builder():
5659
return FABAuthManagerLogin.create_token(
57-
body=body, expiration_time_in_seconds=conf.getint("api_auth", "jwt_cli_expiration_time")
60+
headers=dict(request.headers),
61+
body=body,
62+
expiration_time_in_seconds=conf.getint("api_auth", "jwt_cli_expiration_time"),
5863
)
5964

6065

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/login.py

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,45 +16,32 @@
1616
# under the License.
1717
from __future__ import annotations
1818

19-
from typing import TYPE_CHECKING, cast
19+
from typing import Any
2020

21-
from flask_appbuilder.const import AUTH_LDAP
2221
from starlette import status
2322
from starlette.exceptions import HTTPException
2423

25-
from airflow.api_fastapi.app import get_auth_manager
2624
from airflow.configuration import conf
27-
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginBody, LoginResponse
28-
29-
if TYPE_CHECKING:
30-
from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager
31-
from airflow.providers.fab.auth_manager.models import User
25+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginResponse
26+
from airflow.providers.fab.www.utils import get_fab_auth_manager
3227

3328

3429
class FABAuthManagerLogin:
3530
"""Login Service for FABAuthManager."""
3631

3732
@classmethod
3833
def create_token(
39-
cls, body: LoginBody, expiration_time_in_seconds: int = conf.getint("api_auth", "jwt_expiration_time")
34+
cls,
35+
headers: dict[str, str],
36+
body: dict[str, Any],
37+
expiration_time_in_seconds: int = conf.getint("api_auth", "jwt_expiration_time"),
4038
) -> LoginResponse:
4139
"""Create a new token."""
42-
if not body.username or not body.password:
43-
raise HTTPException(
44-
status_code=status.HTTP_400_BAD_REQUEST, detail="Username and password must be provided"
45-
)
46-
47-
auth_manager = cast("FabAuthManager", get_auth_manager())
48-
user: User | None = None
49-
50-
if auth_manager.security_manager.auth_type == AUTH_LDAP:
51-
user = auth_manager.security_manager.auth_user_ldap(
52-
body.username, body.password, rotate_session_id=False
53-
)
54-
if user is None:
55-
user = auth_manager.security_manager.auth_user_db(
56-
body.username, body.password, rotate_session_id=False
57-
)
40+
auth_manager = get_fab_auth_manager()
41+
try:
42+
user = auth_manager.create_token(headers=headers, body=body)
43+
except ValueError as e:
44+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5845

5946
if not user:
6047
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")

providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from connexion import FlaskApi
2828
from fastapi import FastAPI
2929
from flask import Blueprint, current_app, g
30+
from flask_appbuilder.const import AUTH_LDAP
3031
from sqlalchemy import select
3132
from sqlalchemy.orm import Session, joinedload
3233
from starlette.middleware.wsgi import WSGIMiddleware
@@ -299,6 +300,32 @@ def is_logged_in(self) -> bool:
299300
or (not user.is_anonymous and user.is_active)
300301
)
301302

303+
def create_token(self, headers: dict[str, str], body: dict[str, Any]) -> User:
304+
"""
305+
Create a new token from a payload.
306+
307+
By default, it uses basic authentication (username and password).
308+
Override this method to use a different authentication method (e.g. oauth).
309+
310+
:param headers: request headers
311+
:param body: request body
312+
"""
313+
if not body.get("username") or not body.get("password"):
314+
raise ValueError("Username and password must be provided")
315+
316+
user: User | None = None
317+
318+
if self.security_manager.auth_type == AUTH_LDAP:
319+
user = self.security_manager.auth_user_ldap(
320+
body["username"], body["password"], rotate_session_id=False
321+
)
322+
if user is None:
323+
user = self.security_manager.auth_user_db(
324+
body["username"], body["password"], rotate_session_id=False
325+
)
326+
327+
return user
328+
302329
def is_authorized_configuration(
303330
self,
304331
*,

providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@
2222
import pytest
2323

2424
from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
25-
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginBody, LoginResponse
25+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginResponse
2626

2727

2828
@pytest.mark.db_test
2929
class TestLogin:
30-
dummy_login_body = LoginBody(username="dummy", password="dummy")
30+
dummy_login_body = {"username": "dummy", "password": "dummy"}
3131
dummy_token = LoginResponse(access_token="DUMMY_TOKEN")
3232

3333
@patch("airflow.providers.fab.auth_manager.api_fastapi.routes.login.FABAuthManagerLogin")
@@ -36,7 +36,7 @@ def test_create_token(self, mock_fab_auth_manager_login, test_client):
3636

3737
response = test_client.post(
3838
"/token",
39-
json=self.dummy_login_body.model_dump(),
39+
json=self.dummy_login_body,
4040
)
4141
assert response.status_code == 201
4242
assert response.json()["access_token"] == self.dummy_token.access_token
@@ -47,7 +47,7 @@ def test_create_token_cli(self, mock_fab_auth_manager_login, test_client):
4747

4848
response = test_client.post(
4949
"/token/cli",
50-
json=self.dummy_login_body.model_dump(),
50+
json=self.dummy_login_body,
5151
)
5252
assert response.status_code == 201
5353
assert response.json()["access_token"] == self.dummy_token.access_token

0 commit comments

Comments
 (0)