Skip to content

Commit ce67f86

Browse files
committed
Update UI authentication
1 parent 78b8b3c commit ce67f86

File tree

35 files changed

+342
-554
lines changed

35 files changed

+342
-554
lines changed

airflow-core/docs/core-concepts/auth-manager/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ cookie named ``_token`` before redirecting to the Airflow UI. The Airflow UI wil
166166
response = RedirectResponse(url="/")
167167
168168
secure = request.base_url.scheme == "https" or bool(conf.get("api", "ssl_cert", fallback=""))
169-
response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure)
169+
response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure, httponly=True)
170170
return response
171171
172172
.. note::

airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,15 @@ def get_url_logout(self) -> str | None:
141141
"""
142142
return None
143143

144-
def get_url_refresh(self) -> str | None:
144+
def refresh_user(self, *, user: T) -> T | None:
145145
"""
146-
Return the URL to refresh the authentication token.
146+
Refresh the user if needed.
147147
148-
This is used to refresh the authentication token when it expires.
149-
The default implementation returns None, which means that the auth manager does not support refresh token.
148+
By default, does nothing. Some auth managers might need to refresh the user to, for instance,
149+
refresh some tokens that are needed to communicate with a service/tool.
150+
151+
This method is called by every single request, it must be lightweight otherwise the overall API
152+
server latency will increase.
150153
"""
151154
return None
152155

airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def login_all_admins(request: Request) -> RedirectResponse:
9494
COOKIE_NAME_JWT_TOKEN,
9595
SimpleAuthManagerLogin.create_token_all_admins(),
9696
secure=secure,
97+
httponly=True,
9798
)
9899
return response
99100

airflow-core/src/airflow/api_fastapi/auth/managers/simple/middleware.py renamed to airflow-core/src/airflow/api_fastapi/auth/middlewares/__init__.py

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#
12
# Licensed to the Apache Software Foundation (ASF) under one
23
# or more contributor license agreements. See the NOTICE file
34
# distributed with this work for additional information
@@ -14,19 +15,3 @@
1415
# KIND, either express or implied. See the License for the
1516
# specific language governing permissions and limitations
1617
# under the License.
17-
18-
from __future__ import annotations
19-
20-
from fastapi import Request
21-
from starlette.middleware.base import BaseHTTPMiddleware
22-
23-
from airflow.api_fastapi.auth.managers.simple.services.login import SimpleAuthManagerLogin
24-
25-
26-
class SimpleAllAdminMiddleware(BaseHTTPMiddleware):
27-
"""Middleware that automatically generates and includes auth header for simple auth manager."""
28-
29-
async def dispatch(self, request: Request, call_next):
30-
token = SimpleAuthManagerLogin.create_token_all_admins()
31-
request.scope["headers"].append((b"authorization", f"Bearer {token}".encode()))
32-
return await call_next(request)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
from __future__ import annotations
19+
20+
from fastapi import Request
21+
from jwt import ExpiredSignatureError, InvalidTokenError
22+
from starlette.middleware.base import BaseHTTPMiddleware
23+
24+
from airflow.api_fastapi.app import get_auth_manager
25+
from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
26+
from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
27+
from airflow.configuration import conf
28+
29+
30+
class JWTRefreshMiddleware(BaseHTTPMiddleware):
31+
"""
32+
Middleware to handle JWT token refresh.
33+
34+
This middleware:
35+
1. Extracts JWT token from cookies and build the user from the token
36+
2. Calls ``refresh_user`` method from auth manager with the user
37+
3. If ``refresh_user`` returns a user, generate a JWT token based upon this user and send it in the
38+
response as cookie
39+
"""
40+
41+
async def dispatch(self, request: Request, call_next):
42+
new_user = None
43+
current_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
44+
if current_token:
45+
new_user = await self._refresh_user(current_token)
46+
if new_user:
47+
request.state.user = new_user
48+
49+
response = await call_next(request)
50+
51+
if new_user:
52+
# If we created a new user, serialize it and set it as a cookie
53+
new_token = get_auth_manager().generate_jwt(new_user)
54+
secure = bool(conf.get("api", "ssl_cert", fallback=""))
55+
response.set_cookie(
56+
COOKIE_NAME_JWT_TOKEN,
57+
new_token,
58+
httponly=True,
59+
secure=secure,
60+
samesite="lax",
61+
)
62+
63+
return response
64+
65+
@staticmethod
66+
async def _refresh_user(current_token: str) -> BaseUser | None:
67+
try:
68+
# If the token is expired or not valid, then we stop the refresh token flow
69+
# This will be caught and handled by the route downstream
70+
user = await get_auth_manager().get_user_from_token(current_token)
71+
except ExpiredSignatureError:
72+
return None
73+
except InvalidTokenError:
74+
return None
75+
76+
return get_auth_manager().refresh_user(user=user)

airflow-core/src/airflow/api_fastapi/core_api/app.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,9 @@ def init_error_handlers(app: FastAPI) -> None:
182182

183183

184184
def init_middlewares(app: FastAPI) -> None:
185-
from airflow.configuration import conf
186-
187-
if "SimpleAuthManager" in conf.get("core", "auth_manager") and conf.getboolean(
188-
"core", "simple_auth_manager_all_admins"
189-
):
190-
from airflow.api_fastapi.auth.managers.simple.middleware import SimpleAllAdminMiddleware
185+
from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware
191186

192-
app.add_middleware(SimpleAllAdminMiddleware)
187+
app.add_middleware(JWTRefreshMiddleware)
193188

194189

195190
def init_ui_plugins(app: FastAPI) -> None:

airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8440,40 +8440,6 @@ paths:
84408440
application/json:
84418441
schema:
84428442
$ref: '#/components/schemas/HTTPValidationError'
8443-
/api/v2/auth/refresh:
8444-
get:
8445-
tags:
8446-
- Login
8447-
summary: Refresh
8448-
description: Refresh the authentication token.
8449-
operationId: refresh
8450-
parameters:
8451-
- name: next
8452-
in: query
8453-
required: false
8454-
schema:
8455-
anyOf:
8456-
- type: string
8457-
- type: 'null'
8458-
title: Next
8459-
responses:
8460-
'200':
8461-
description: Successful Response
8462-
content:
8463-
application/json:
8464-
schema: {}
8465-
'307':
8466-
content:
8467-
application/json:
8468-
schema:
8469-
$ref: '#/components/schemas/HTTPExceptionResponse'
8470-
description: Temporary Redirect
8471-
'422':
8472-
description: Validation Error
8473-
content:
8474-
application/json:
8475-
schema:
8476-
$ref: '#/components/schemas/HTTPValidationError'
84778443
components:
84788444
schemas:
84798445
AppBuilderMenuItemResponse:

airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from airflow.api_fastapi.common.router import AirflowRouter
2323
from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
2424
from airflow.api_fastapi.core_api.security import is_safe_url
25-
from airflow.configuration import conf
2625

2726
auth_router = AirflowRouter(tags=["Login"], prefix="/auth")
2827

@@ -56,23 +55,3 @@ def logout(request: Request, next: None | str = None) -> RedirectResponse:
5655
logout_url = request.app.state.auth_manager.get_url_login()
5756

5857
return RedirectResponse(logout_url)
59-
60-
61-
@auth_router.get(
62-
"/refresh",
63-
responses=create_openapi_http_exception_doc([status.HTTP_307_TEMPORARY_REDIRECT]),
64-
)
65-
def refresh(request: Request, next: None | str = None) -> RedirectResponse:
66-
"""Refresh the authentication token."""
67-
refresh_url = request.app.state.auth_manager.get_url_refresh()
68-
69-
if not refresh_url:
70-
return RedirectResponse(f"{conf.get('api', 'base_url', fallback='/')}auth/logout")
71-
72-
if next and not is_safe_url(next, request=request):
73-
raise HTTPException(status_code=400, detail="Invalid or unsafe next URL")
74-
75-
if next:
76-
refresh_url += f"?next={next}"
77-
78-
return RedirectResponse(refresh_url)

airflow-core/src/airflow/api_fastapi/core_api/security.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from pydantic import NonNegativeInt
2828

2929
from airflow.api_fastapi.app import get_auth_manager
30+
from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
3031
from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
3132
from airflow.api_fastapi.auth.managers.models.batch_apis import (
3233
IsAuthorizedConnectionRequest,
@@ -96,14 +97,22 @@ async def resolve_user_from_token(token_str: str | None) -> BaseUser:
9697

9798

9899
async def get_user(
100+
request: Request,
99101
oauth_token: str | None = Depends(oauth2_scheme),
100102
bearer_credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
101103
) -> BaseUser:
102-
token_str = None
104+
# A user might have been already built by a middleware, if so, it is stored in `request.state.user`
105+
user: BaseUser | None = getattr(request.state, "user", None)
106+
if user:
107+
return user
108+
109+
token_str: str | None
103110
if bearer_credentials and bearer_credentials.scheme.lower() == "bearer":
104111
token_str = bearer_credentials.credentials
105112
elif oauth_token:
106113
token_str = oauth_token
114+
else:
115+
token_str = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
107116

108117
return await resolve_user_from_token(token_str)
109118

airflow-core/src/airflow/ui/openapi-gen/queries/common.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -762,12 +762,6 @@ export const useLoginServiceLogoutKey = "LoginServiceLogout";
762762
export const UseLoginServiceLogoutKeyFn = ({ next }: {
763763
next?: string;
764764
} = {}, queryKey?: Array<unknown>) => [useLoginServiceLogoutKey, ...(queryKey ?? [{ next }])];
765-
export type LoginServiceRefreshDefaultResponse = Awaited<ReturnType<typeof LoginService.refresh>>;
766-
export type LoginServiceRefreshQueryResult<TData = LoginServiceRefreshDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
767-
export const useLoginServiceRefreshKey = "LoginServiceRefresh";
768-
export const UseLoginServiceRefreshKeyFn = ({ next }: {
769-
next?: string;
770-
} = {}, queryKey?: Array<unknown>) => [useLoginServiceRefreshKey, ...(queryKey ?? [{ next }])];
771765
export type AuthLinksServiceGetAuthMenusDefaultResponse = Awaited<ReturnType<typeof AuthLinksService.getAuthMenus>>;
772766
export type AuthLinksServiceGetAuthMenusQueryResult<TData = AuthLinksServiceGetAuthMenusDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
773767
export const useAuthLinksServiceGetAuthMenusKey = "AuthLinksServiceGetAuthMenus";

0 commit comments

Comments
 (0)