Skip to content

Commit 6b9f3cd

Browse files
authored
Update refresh token flow (#55506) (#58649)
1 parent abcac64 commit 6b9f3cd

File tree

22 files changed

+292
-297
lines changed

22 files changed

+292
-297
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
init_config,
3030
init_error_handlers,
3131
init_flask_plugins,
32+
init_middlewares,
3233
init_ui_plugins,
3334
init_views,
3435
)
@@ -99,6 +100,7 @@ def create_app(apps: str = "all") -> FastAPI:
99100
init_ui_plugins(app)
100101
init_views(app) # Core views need to be the last routes added - it has a catch all route
101102
init_error_handlers(app)
103+
init_middlewares(app)
102104

103105
init_config(app)
104106

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
@@ -135,12 +135,15 @@ def get_url_logout(self) -> str | None:
135135
"""
136136
return None
137137

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 starlette.middleware.base import BaseHTTPMiddleware
22+
23+
from airflow.api_fastapi.app import get_auth_manager
24+
from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
25+
from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
26+
from airflow.api_fastapi.core_api.security import resolve_user_from_token
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+
user = await resolve_user_from_token(current_token)
68+
return get_auth_manager().refresh_user(user=user)

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ def init_error_handlers(app: FastAPI) -> None:
181181
app.add_exception_handler(handler.exception_cls, handler.exception_handler)
182182

183183

184+
def init_middlewares(app: FastAPI) -> None:
185+
from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware
186+
187+
app.add_middleware(JWTRefreshMiddleware)
188+
189+
184190
def init_ui_plugins(app: FastAPI) -> None:
185191
"""Initialize UI plugins."""
186192
from airflow import plugins_manager

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
@@ -8478,40 +8478,6 @@ paths:
84788478
application/json:
84798479
schema:
84808480
$ref: '#/components/schemas/HTTPExceptionResponse'
8481-
/api/v2/auth/refresh:
8482-
get:
8483-
tags:
8484-
- Login
8485-
summary: Refresh
8486-
description: Refresh the authentication token.
8487-
operationId: refresh
8488-
parameters:
8489-
- name: next
8490-
in: query
8491-
required: false
8492-
schema:
8493-
anyOf:
8494-
- type: string
8495-
- type: 'null'
8496-
title: Next
8497-
responses:
8498-
'200':
8499-
description: Successful Response
8500-
content:
8501-
application/json:
8502-
schema: {}
8503-
'307':
8504-
content:
8505-
application/json:
8506-
schema:
8507-
$ref: '#/components/schemas/HTTPExceptionResponse'
8508-
description: Temporary Redirect
8509-
'422':
8510-
description: Validation Error
8511-
content:
8512-
application/json:
8513-
schema:
8514-
$ref: '#/components/schemas/HTTPValidationError'
85158481
components:
85168482
schemas:
85178483
AppBuilderMenuItemResponse:

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

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,3 @@ def logout(request: Request) -> RedirectResponse:
6565
)
6666

6767
return response
68-
69-
70-
@auth_router.get(
71-
"/refresh",
72-
responses=create_openapi_http_exception_doc([status.HTTP_307_TEMPORARY_REDIRECT]),
73-
)
74-
def refresh(request: Request, next: None | str = None) -> RedirectResponse:
75-
"""Refresh the authentication token."""
76-
refresh_url = request.app.state.auth_manager.get_url_refresh()
77-
78-
if not refresh_url:
79-
return RedirectResponse(f"{conf.get('api', 'base_url', fallback='/')}auth/logout")
80-
81-
if next and not is_safe_url(next, request=request):
82-
raise HTTPException(status_code=400, detail="Invalid or unsafe next URL")
83-
84-
if next:
85-
refresh_url += f"?next={next}"
86-
87-
return RedirectResponse(refresh_url)

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -753,12 +753,6 @@ export type LoginServiceLogoutDefaultResponse = Awaited<ReturnType<typeof LoginS
753753
export type LoginServiceLogoutQueryResult<TData = LoginServiceLogoutDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
754754
export const useLoginServiceLogoutKey = "LoginServiceLogout";
755755
export const UseLoginServiceLogoutKeyFn = (queryKey?: Array<unknown>) => [useLoginServiceLogoutKey, ...(queryKey ?? [])];
756-
export type LoginServiceRefreshDefaultResponse = Awaited<ReturnType<typeof LoginService.refresh>>;
757-
export type LoginServiceRefreshQueryResult<TData = LoginServiceRefreshDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
758-
export const useLoginServiceRefreshKey = "LoginServiceRefresh";
759-
export const UseLoginServiceRefreshKeyFn = ({ next }: {
760-
next?: string;
761-
} = {}, queryKey?: Array<unknown>) => [useLoginServiceRefreshKey, ...(queryKey ?? [{ next }])];
762756
export type AuthLinksServiceGetAuthMenusDefaultResponse = Awaited<ReturnType<typeof AuthLinksService.getAuthMenus>>;
763757
export type AuthLinksServiceGetAuthMenusQueryResult<TData = AuthLinksServiceGetAuthMenusDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
764758
export const useAuthLinksServiceGetAuthMenusKey = "AuthLinksServiceGetAuthMenus";

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,17 +1431,6 @@ export const ensureUseLoginServiceLoginData = (queryClient: QueryClient, { next
14311431
*/
14321432
export const ensureUseLoginServiceLogoutData = (queryClient: QueryClient) => queryClient.ensureQueryData({ queryKey: Common.UseLoginServiceLogoutKeyFn(), queryFn: () => LoginService.logout() });
14331433
/**
1434-
* Refresh
1435-
* Refresh the authentication token.
1436-
* @param data The data for the request.
1437-
* @param data.next
1438-
* @returns unknown Successful Response
1439-
* @throws ApiError
1440-
*/
1441-
export const ensureUseLoginServiceRefreshData = (queryClient: QueryClient, { next }: {
1442-
next?: string;
1443-
} = {}) => queryClient.ensureQueryData({ queryKey: Common.UseLoginServiceRefreshKeyFn({ next }), queryFn: () => LoginService.refresh({ next }) });
1444-
/**
14451434
* Get Auth Menus
14461435
* @returns MenuItemCollectionResponse Successful Response
14471436
* @throws ApiError

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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,17 +1431,6 @@ export const prefetchUseLoginServiceLogin = (queryClient: QueryClient, { next }:
14311431
*/
14321432
export const prefetchUseLoginServiceLogout = (queryClient: QueryClient) => queryClient.prefetchQuery({ queryKey: Common.UseLoginServiceLogoutKeyFn(), queryFn: () => LoginService.logout() });
14331433
/**
1434-
* Refresh
1435-
* Refresh the authentication token.
1436-
* @param data The data for the request.
1437-
* @param data.next
1438-
* @returns unknown Successful Response
1439-
* @throws ApiError
1440-
*/
1441-
export const prefetchUseLoginServiceRefresh = (queryClient: QueryClient, { next }: {
1442-
next?: string;
1443-
} = {}) => queryClient.prefetchQuery({ queryKey: Common.UseLoginServiceRefreshKeyFn({ next }), queryFn: () => LoginService.refresh({ next }) });
1444-
/**
14451434
* Get Auth Menus
14461435
* @returns MenuItemCollectionResponse Successful Response
14471436
* @throws ApiError

0 commit comments

Comments
 (0)