You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ep...@apache.org on 2025/12/08 08:31:21 UTC
(airflow) 03/27: Revert "Update refresh token flow (#55506) (#58649)"
This is an automated email from the ASF dual-hosted git repository.
ephraimanierobi pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 9aabbe5e06fd7f761c065d0e077cff20fd61c23c
Author: Ephraim Anierobi <sp...@gmail.com>
AuthorDate: Mon Dec 8 09:04:34 2025 +0100
Revert "Update refresh token flow (#55506) (#58649)"
This reverts commit 147eca049d0741fe7f7e945b26526d92e09cafaf.
---
airflow-core/src/airflow/api_fastapi/app.py | 2 -
.../api_fastapi/auth/managers/base_auth_manager.py | 11 +--
.../api_fastapi/auth/middlewares/__init__.py | 17 ----
.../api_fastapi/auth/middlewares/refresh_token.py | 68 -------------
.../src/airflow/api_fastapi/core_api/app.py | 6 --
.../core_api/openapi/v2-rest-api-generated.yaml | 34 +++++++
.../api_fastapi/core_api/routes/public/auth.py | 20 ++++
.../src/airflow/ui/openapi-gen/queries/common.ts | 6 ++
.../ui/openapi-gen/queries/ensureQueryData.ts | 11 +++
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 11 +++
.../src/airflow/ui/openapi-gen/queries/queries.ts | 11 +++
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 11 +++
.../ui/openapi-gen/requests/services.gen.ts | 24 ++++-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 25 +++++
.../auth/managers/test_base_auth_manager.py | 3 -
.../unit/api_fastapi/auth/middlewares/__init__.py | 17 ----
.../auth/middlewares/test_refresh_token.py | 106 ---------------------
.../core_api/routes/public/test_auth.py | 61 ++++++++++++
.../api_fastapi/core_api/routes/test_routes.py | 1 +
.../keycloak/auth_manager/keycloak_auth_manager.py | 28 +-----
.../keycloak/auth_manager/routes/test_login.py | 77 ++++++++++++++-
.../auth_manager/test_keycloak_auth_manager.py | 39 --------
22 files changed, 297 insertions(+), 292 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/app.py b/airflow-core/src/airflow/api_fastapi/app.py
index 7c05295807e..58cfb157083 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -29,7 +29,6 @@ from airflow.api_fastapi.core_api.app import (
init_config,
init_error_handlers,
init_flask_plugins,
- init_middlewares,
init_ui_plugins,
init_views,
)
@@ -100,7 +99,6 @@ def create_app(apps: str = "all") -> FastAPI:
init_ui_plugins(app)
init_views(app) # Core views need to be the last routes added - it has a catch all route
init_error_handlers(app)
- init_middlewares(app)
init_config(app)
diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
index b8656cd068f..d57dd3cdc39 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
@@ -135,15 +135,12 @@ class BaseAuthManager(Generic[T], LoggingMixin, metaclass=ABCMeta):
"""
return None
- def refresh_user(self, *, user: T) -> T | None:
+ def get_url_refresh(self) -> str | None:
"""
- Refresh the user if needed.
+ Return the URL to refresh the authentication token.
- By default, does nothing. Some auth managers might need to refresh the user to, for instance,
- refresh some tokens that are needed to communicate with a service/tool.
-
- This method is called by every single request, it must be lightweight otherwise the overall API
- server latency will increase.
+ This is used to refresh the authentication token when it expires.
+ The default implementation returns None, which means that the auth manager does not support refresh token.
"""
return None
diff --git a/airflow-core/src/airflow/api_fastapi/auth/middlewares/__init__.py b/airflow-core/src/airflow/api_fastapi/auth/middlewares/__init__.py
deleted file mode 100644
index 217e5db9607..00000000000
--- a/airflow-core/src/airflow/api_fastapi/auth/middlewares/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
diff --git a/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py b/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py
deleted file mode 100644
index f304eb9517f..00000000000
--- a/airflow-core/src/airflow/api_fastapi/auth/middlewares/refresh_token.py
+++ /dev/null
@@ -1,68 +0,0 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-from __future__ import annotations
-
-from fastapi import Request
-from starlette.middleware.base import BaseHTTPMiddleware
-
-from airflow.api_fastapi.app import get_auth_manager
-from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
-from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
-from airflow.api_fastapi.core_api.security import resolve_user_from_token
-from airflow.configuration import conf
-
-
-class JWTRefreshMiddleware(BaseHTTPMiddleware):
- """
- Middleware to handle JWT token refresh.
-
- This middleware:
- 1. Extracts JWT token from cookies and build the user from the token
- 2. Calls ``refresh_user`` method from auth manager with the user
- 3. If ``refresh_user`` returns a user, generate a JWT token based upon this user and send it in the
- response as cookie
- """
-
- async def dispatch(self, request: Request, call_next):
- new_user = None
- current_token = request.cookies.get(COOKIE_NAME_JWT_TOKEN)
- if current_token:
- new_user = await self._refresh_user(current_token)
- if new_user:
- request.state.user = new_user
-
- response = await call_next(request)
-
- if new_user:
- # If we created a new user, serialize it and set it as a cookie
- new_token = get_auth_manager().generate_jwt(new_user)
- secure = bool(conf.get("api", "ssl_cert", fallback=""))
- response.set_cookie(
- COOKIE_NAME_JWT_TOKEN,
- new_token,
- httponly=True,
- secure=secure,
- samesite="lax",
- )
-
- return response
-
- @staticmethod
- async def _refresh_user(current_token: str) -> BaseUser | None:
- user = await resolve_user_from_token(current_token)
- return get_auth_manager().refresh_user(user=user)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py b/airflow-core/src/airflow/api_fastapi/core_api/app.py
index 8db1fa66680..cd34b28f3cb 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -181,12 +181,6 @@ def init_error_handlers(app: FastAPI) -> None:
app.add_exception_handler(handler.exception_cls, handler.exception_handler)
-def init_middlewares(app: FastAPI) -> None:
- from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware
-
- app.add_middleware(JWTRefreshMiddleware)
-
-
def init_ui_plugins(app: FastAPI) -> None:
"""Initialize UI plugins."""
from airflow import plugins_manager
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index ceaf90b60f8..bef548133c6 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -8478,6 +8478,40 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPExceptionResponse'
+ /api/v2/auth/refresh:
+ get:
+ tags:
+ - Login
+ summary: Refresh
+ description: Refresh the authentication token.
+ operationId: refresh
+ parameters:
+ - name: next
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Next
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ '307':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Temporary Redirect
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
AppBuilderMenuItemResponse:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
index a97b7fd9972..d1e770b9277 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
@@ -65,3 +65,23 @@ def logout(request: Request) -> RedirectResponse:
)
return response
+
+
+@auth_router.get(
+ "/refresh",
+ responses=create_openapi_http_exception_doc([status.HTTP_307_TEMPORARY_REDIRECT]),
+)
+def refresh(request: Request, next: None | str = None) -> RedirectResponse:
+ """Refresh the authentication token."""
+ refresh_url = request.app.state.auth_manager.get_url_refresh()
+
+ if not refresh_url:
+ return RedirectResponse(f"{conf.get('api', 'base_url', fallback='/')}auth/logout")
+
+ if next and not is_safe_url(next, request=request):
+ raise HTTPException(status_code=400, detail="Invalid or unsafe next URL")
+
+ if next:
+ refresh_url += f"?next={next}"
+
+ return RedirectResponse(refresh_url)
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index 994fe2d91c1..de2bbc9647e 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -753,6 +753,12 @@ export type LoginServiceLogoutDefaultResponse = Awaited<ReturnType<typeof LoginS
export type LoginServiceLogoutQueryResult<TData = LoginServiceLogoutDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
export const useLoginServiceLogoutKey = "LoginServiceLogout";
export const UseLoginServiceLogoutKeyFn = (queryKey?: Array<unknown>) => [useLoginServiceLogoutKey, ...(queryKey ?? [])];
+export type LoginServiceRefreshDefaultResponse = Awaited<ReturnType<typeof LoginService.refresh>>;
+export type LoginServiceRefreshQueryResult<TData = LoginServiceRefreshDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
+export const useLoginServiceRefreshKey = "LoginServiceRefresh";
+export const UseLoginServiceRefreshKeyFn = ({ next }: {
+ next?: string;
+} = {}, queryKey?: Array<unknown>) => [useLoginServiceRefreshKey, ...(queryKey ?? [{ next }])];
export type AuthLinksServiceGetAuthMenusDefaultResponse = Awaited<ReturnType<typeof AuthLinksService.getAuthMenus>>;
export type AuthLinksServiceGetAuthMenusQueryResult<TData = AuthLinksServiceGetAuthMenusDefaultResponse, TError = unknown> = UseQueryResult<TData, TError>;
export const useAuthLinksServiceGetAuthMenusKey = "AuthLinksServiceGetAuthMenus";
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index 41fe0005dcd..c38c3958500 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1431,6 +1431,17 @@ export const ensureUseLoginServiceLoginData = (queryClient: QueryClient, { next
*/
export const ensureUseLoginServiceLogoutData = (queryClient: QueryClient) => queryClient.ensureQueryData({ queryKey: Common.UseLoginServiceLogoutKeyFn(), queryFn: () => LoginService.logout() });
/**
+* Refresh
+* Refresh the authentication token.
+* @param data The data for the request.
+* @param data.next
+* @returns unknown Successful Response
+* @throws ApiError
+*/
+export const ensureUseLoginServiceRefreshData = (queryClient: QueryClient, { next }: {
+ next?: string;
+} = {}) => queryClient.ensureQueryData({ queryKey: Common.UseLoginServiceRefreshKeyFn({ next }), queryFn: () => LoginService.refresh({ next }) });
+/**
* Get Auth Menus
* @returns MenuItemCollectionResponse Successful Response
* @throws ApiError
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index fa6162ec588..ec8e3471a4c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1431,6 +1431,17 @@ export const prefetchUseLoginServiceLogin = (queryClient: QueryClient, { next }:
*/
export const prefetchUseLoginServiceLogout = (queryClient: QueryClient) => queryClient.prefetchQuery({ queryKey: Common.UseLoginServiceLogoutKeyFn(), queryFn: () => LoginService.logout() });
/**
+* Refresh
+* Refresh the authentication token.
+* @param data The data for the request.
+* @param data.next
+* @returns unknown Successful Response
+* @throws ApiError
+*/
+export const prefetchUseLoginServiceRefresh = (queryClient: QueryClient, { next }: {
+ next?: string;
+} = {}) => queryClient.prefetchQuery({ queryKey: Common.UseLoginServiceRefreshKeyFn({ next }), queryFn: () => LoginService.refresh({ next }) });
+/**
* Get Auth Menus
* @returns MenuItemCollectionResponse Successful Response
* @throws ApiError
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index 955e5049e60..5211c77b349 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -1431,6 +1431,17 @@ export const useLoginServiceLogin = <TData = Common.LoginServiceLoginDefaultResp
*/
export const useLoginServiceLogout = <TData = Common.LoginServiceLogoutDefaultResponse, TError = unknown, TQueryKey extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: Common.UseLoginServiceLogoutKeyFn(queryKey), queryFn: () => LoginService.logout() as TData, ...options });
/**
+* Refresh
+* Refresh the authentication token.
+* @param data The data for the request.
+* @param data.next
+* @returns unknown Successful Response
+* @throws ApiError
+*/
+export const useLoginServiceRefresh = <TData = Common.LoginServiceRefreshDefaultResponse, TError = unknown, TQueryKey extends Array<unknown> = unknown[]>({ next }: {
+ next?: string;
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: Common.UseLoginServiceRefreshKeyFn({ next }, queryKey), queryFn: () => LoginService.refresh({ next }) as TData, ...options });
+/**
* Get Auth Menus
* @returns MenuItemCollectionResponse Successful Response
* @throws ApiError
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index aafe12ed9bc..9b980d1cbec 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -1431,6 +1431,17 @@ export const useLoginServiceLoginSuspense = <TData = Common.LoginServiceLoginDef
*/
export const useLoginServiceLogoutSuspense = <TData = Common.LoginServiceLogoutDefaultResponse, TError = unknown, TQueryKey extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: Common.UseLoginServiceLogoutKeyFn(queryKey), queryFn: () => LoginService.logout() as TData, ...options });
/**
+* Refresh
+* Refresh the authentication token.
+* @param data The data for the request.
+* @param data.next
+* @returns unknown Successful Response
+* @throws ApiError
+*/
+export const useLoginServiceRefreshSuspense = <TData = Common.LoginServiceRefreshDefaultResponse, TError = unknown, TQueryKey extends Array<unknown> = unknown[]>({ next }: {
+ next?: string;
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: Common.UseLoginServiceRefreshKeyFn({ next }, queryKey), queryFn: () => LoginService.refresh({ next }) as TData, ...options });
+/**
* Get Auth Menus
* @returns MenuItemCollectionResponse Successful Response
* @throws ApiError
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index 91ded2463e7..c9cb3594cae 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
-import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, Dele [...]
+import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, Dele [...]
export class AssetService {
/**
@@ -3714,6 +3714,28 @@ export class LoginService {
});
}
+ /**
+ * Refresh
+ * Refresh the authentication token.
+ * @param data The data for the request.
+ * @param data.next
+ * @returns unknown Successful Response
+ * @throws ApiError
+ */
+ public static refresh(data: RefreshData = {}): CancelablePromise<RefreshResponse> {
+ return __request(OpenAPI, {
+ method: 'GET',
+ url: '/api/v2/auth/refresh',
+ query: {
+ next: data.next
+ },
+ errors: {
+ 307: 'Temporary Redirect',
+ 422: 'Validation Error'
+ }
+ });
+ }
+
}
export class AuthLinksService {
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 006f33286fd..18efcbc82ba 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -3208,6 +3208,12 @@ export type LoginResponse = unknown;
export type LogoutResponse = unknown;
+export type RefreshData = {
+ next?: string | null;
+};
+
+export type RefreshResponse = unknown;
+
export type GetAuthMenusResponse = MenuItemCollectionResponse;
export type GetDependenciesData = {
@@ -6278,6 +6284,25 @@ export type $OpenApiTs = {
};
};
};
+ '/api/v2/auth/refresh': {
+ get: {
+ req: RefreshData;
+ res: {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+ /**
+ * Temporary Redirect
+ */
+ 307: HTTPExceptionResponse;
+ /**
+ * Validation Error
+ */
+ 422: HTTPValidationError;
+ };
+ };
+ };
'/ui/auth/menus': {
get: {
res: {
diff --git a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
index 98217cef115..fde84665609 100644
--- a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
+++ b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
@@ -165,9 +165,6 @@ class TestBaseAuthManager:
def test_get_fastapi_app_return_none(self, auth_manager):
assert auth_manager.get_fastapi_app() is None
- def test_refresh_user_default_returns_none(self, auth_manager):
- assert auth_manager.refresh_user(user=BaseAuthManagerUserTest(name="test")) is None
-
def test_get_url_logout_return_none(self, auth_manager):
assert auth_manager.get_url_logout() is None
diff --git a/airflow-core/tests/unit/api_fastapi/auth/middlewares/__init__.py b/airflow-core/tests/unit/api_fastapi/auth/middlewares/__init__.py
deleted file mode 100644
index 217e5db9607..00000000000
--- a/airflow-core/tests/unit/api_fastapi/auth/middlewares/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
diff --git a/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py b/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py
deleted file mode 100644
index 87648a2be2b..00000000000
--- a/airflow-core/tests/unit/api_fastapi/auth/middlewares/test_refresh_token.py
+++ /dev/null
@@ -1,106 +0,0 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-from __future__ import annotations
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from fastapi import Request, Response
-
-from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
-from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
-from airflow.api_fastapi.auth.middlewares.refresh_token import JWTRefreshMiddleware
-
-
-class TestJWTRefreshMiddleware:
- @pytest.fixture
- def middleware(self):
- return JWTRefreshMiddleware(app=MagicMock())
-
- @pytest.fixture
- def mock_request(self):
- request = MagicMock(spec=Request)
- request.cookies = {}
- request.state = MagicMock()
- return request
-
- @pytest.fixture
- def mock_user(self):
- return MagicMock(spec=BaseUser)
-
- @patch.object(JWTRefreshMiddleware, "_refresh_user")
- @pytest.mark.asyncio
- async def test_dispatch_no_token(self, mock_refresh_user, middleware, mock_request):
- call_next = AsyncMock(return_value=Response())
-
- await middleware.dispatch(mock_request, call_next)
-
- call_next.assert_called_once_with(mock_request)
- mock_refresh_user.assert_not_called()
-
- @patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_auth_manager")
- @patch("airflow.api_fastapi.auth.middlewares.refresh_token.resolve_user_from_token")
- @pytest.mark.asyncio
- async def test_dispatch_no_refreshed_token(
- self, mock_resolve_user_from_token, mock_get_auth_manager, middleware, mock_request, mock_user
- ):
- mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "valid_token"}
- mock_resolve_user_from_token.return_value = mock_user
- mock_auth_manager = MagicMock()
- mock_get_auth_manager.return_value = mock_auth_manager
- mock_auth_manager.refresh_user.return_value = None
-
- call_next = AsyncMock(return_value=Response())
- await middleware.dispatch(mock_request, call_next)
-
- call_next.assert_called_once_with(mock_request)
- mock_resolve_user_from_token.assert_called_once_with("valid_token")
- mock_auth_manager.generate_jwt.assert_not_called()
-
- @pytest.mark.asyncio
- @patch("airflow.api_fastapi.auth.middlewares.refresh_token.get_auth_manager")
- @patch("airflow.api_fastapi.auth.middlewares.refresh_token.resolve_user_from_token")
- @patch("airflow.api_fastapi.auth.middlewares.refresh_token.conf")
- async def test_dispatch_with_refreshed_user(
- self,
- mock_conf,
- mock_resolve_user_from_token,
- mock_get_auth_manager,
- middleware,
- mock_request,
- mock_user,
- ):
- refreshed_user = MagicMock(spec=BaseUser)
- mock_request.cookies = {COOKIE_NAME_JWT_TOKEN: "valid_token"}
- mock_resolve_user_from_token.return_value = mock_user
- mock_auth_manager = MagicMock()
- mock_get_auth_manager.return_value = mock_auth_manager
- mock_auth_manager.refresh_user.return_value = refreshed_user
- mock_auth_manager.generate_jwt.return_value = "new_token"
- mock_conf.get.return_value = ""
-
- call_next = AsyncMock(return_value=Response())
- response = await middleware.dispatch(mock_request, call_next)
-
- assert mock_request.state.user == refreshed_user
- call_next.assert_called_once_with(mock_request)
- mock_resolve_user_from_token.assert_called_once_with("valid_token")
- mock_auth_manager.refresh_user.assert_called_once_with(user=mock_user)
- mock_auth_manager.generate_jwt.assert_called_once_with(refreshed_user)
- set_cookie_headers = response.headers.get("set-cookie", "")
- assert f"{COOKIE_NAME_JWT_TOKEN}=new_token" in set_cookie_headers
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
index d4a5e5869e3..20a8e329a2a 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
@@ -16,13 +16,17 @@
# under the License.
from __future__ import annotations
+import os
from unittest.mock import MagicMock, patch
import pytest
+from airflow.api_fastapi.auth.managers.base_auth_manager import COOKIE_NAME_JWT_TOKEN
+
from tests_common.test_utils.config import conf_vars
AUTH_MANAGER_LOGIN_URL = "http://some_login_url"
+AUTH_MANAGER_REFRESH_URL = "http://some_refresh_url"
AUTH_MANAGER_LOGOUT_URL = "http://some_logout_url"
pytestmark = pytest.mark.db_test
@@ -33,6 +37,7 @@ class TestAuthEndpoint:
def setup(self, test_client) -> None:
auth_manager_mock = MagicMock()
auth_manager_mock.get_url_login.return_value = AUTH_MANAGER_LOGIN_URL
+ auth_manager_mock.get_url_refresh.return_value = AUTH_MANAGER_REFRESH_URL
auth_manager_mock.get_url_logout.return_value = AUTH_MANAGER_LOGOUT_URL
test_client.app.state.auth_manager = auth_manager_mock
@@ -94,3 +99,59 @@ class TestLogout(TestAuthEndpoint):
assert response.status_code == 307
assert response.headers["location"] == expected_redirection
+ if delete_cookies:
+ cookies = response.headers.get_list("set-cookie")
+ assert any(f"{COOKIE_NAME_JWT_TOKEN}=" in c for c in cookies)
+
+
+class TestRefresh(TestAuthEndpoint):
+ @pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"next": None},
+ {"next": "http://localhost:8080"},
+ {"next": "http://localhost:8080", "other_param": "something_else"},
+ ],
+ )
+ @patch("airflow.api_fastapi.core_api.routes.public.auth.is_safe_url", return_value=True)
+ def test_should_respond_307(self, mock_is_safe_url, test_client, params):
+ response = test_client.get("/auth/refresh", follow_redirects=False, params=params)
+
+ assert response.status_code == 307
+ assert (
+ response.headers["location"] == f"{AUTH_MANAGER_REFRESH_URL}?next={params.get('next')}"
+ if params.get("next")
+ else AUTH_MANAGER_REFRESH_URL
+ )
+
+ @patch.dict(os.environ, {"AIRFLOW__API__BASE_URL": "http://localhost:8080/"})
+ @pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"next": None},
+ {"next": "http://localhost:8080"},
+ {"next": "http://localhost:8080", "other_param": "something_else"},
+ ],
+ )
+ @patch("airflow.api_fastapi.core_api.routes.public.auth.is_safe_url", return_value=True)
+ def test_refresh_url_is_none(self, mock_is_safe_url, test_client, params):
+ test_client.app.state.auth_manager.get_url_refresh.return_value = None
+ response = test_client.get("/auth/refresh", follow_redirects=False, params=params)
+
+ assert response.status_code == 307
+ assert response.headers["location"] == "http://localhost:8080/auth/logout"
+
+ @pytest.mark.parametrize(
+ "params",
+ [
+ {"next": "http://fake_domain.com:8080"},
+ {"next": "http://localhost:8080/../../up"},
+ ],
+ )
+ @conf_vars({("api", "base_url"): "http://localhost:8080/prefix"})
+ def test_should_respond_400(self, test_client, params):
+ response = test_client.get("/auth/refresh", follow_redirects=False, params=params)
+
+ assert response.status_code == 400
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py
index 95734bdaa37..8fe41ede186 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py
@@ -22,6 +22,7 @@ from airflow.api_fastapi.core_api.routes.public import authenticated_router, pub
NO_AUTH_PATHS = {
"/api/v2/auth/login",
"/api/v2/auth/logout",
+ "/api/v2/auth/refresh",
"/api/v2/version",
"/api/v2/monitor/health",
}
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 5d42c8bb160..8fdaa0b03e9 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
@@ -19,8 +19,6 @@ from __future__ import annotations
import argparse
import json
import logging
-import time
-from base64 import urlsafe_b64decode
from typing import TYPE_CHECKING, Any
from urllib.parse import urljoin
@@ -110,15 +108,9 @@ class KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
base_url = conf.get("api", "base_url", fallback="/")
return urljoin(base_url, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login")
- def refresh_user(self, *, user: KeycloakAuthManagerUser) -> KeycloakAuthManagerUser | None:
- if self._token_expired(user.access_token):
- client = self.get_keycloak_client()
- tokens = client.refresh_token(user.refresh_token)
- user.refresh_token = tokens["refresh_token"]
- user.access_token = tokens["access_token"]
- return user
-
- return None
+ def get_url_refresh(self) -> str | None:
+ base_url = conf.get("api", "base_url", fallback="/")
+ return urljoin(base_url, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/refresh")
def is_authorized_configuration(
self,
@@ -374,17 +366,3 @@ class KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/x-www-form-urlencoded",
}
-
- @staticmethod
- def _token_expired(token: str) -> bool:
- """
- Check whether a JWT token is expired.
-
- :meta private:
-
- :param token: the token
- """
- payload_b64 = token.split(".")[1] + "=="
- payload_bytes = urlsafe_b64decode(payload_b64)
- payload = json.loads(payload_bytes)
- return payload["exp"] < int(time.time())
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 2604da76581..9cfc1984e71 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
@@ -16,7 +16,9 @@
# under the License.
from __future__ import annotations
-from unittest.mock import ANY, Mock, patch
+from unittest.mock import ANY, AsyncMock, Mock, patch
+
+import pytest
from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
@@ -74,3 +76,76 @@ class TestLoginRouter:
def test_login_callback_without_code(self, client):
response = client.get(AUTH_MANAGER_FASTAPI_APP_PREFIX + "/login_callback")
assert response.status_code == 400
+
+ @patch("airflow.api_fastapi.core_api.security.get_user", new_callable=AsyncMock)
+ @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+ @patch("airflow.providers.keycloak.auth_manager.routes.login.KeycloakAuthManager.get_keycloak_client")
+ @patch("airflow.providers.keycloak.auth_manager.routes.login.get_auth_manager")
+ @pytest.mark.asyncio
+ async def test_refresh(
+ self,
+ mock_get_auth_manager,
+ mock_get_keycloak_client,
+ mock_sec_get_auth_manager,
+ mock_get_user,
+ client,
+ ):
+ mock_user = Mock()
+ mock_get_user.return_value = mock_user
+ mock_auth_manager_sec = Mock()
+ mock_sec_get_auth_manager.return_value = mock_auth_manager_sec
+ mock_auth_manager_sec.get_user_from_token = AsyncMock(return_value=mock_user)
+ mock_get_keycloak_client.refresh_token.return_value = {
+ "access_token": "new_access_token",
+ "refresh_token": "new_refresh_token",
+ }
+
+ mock_auth_manager = Mock()
+ mock_get_auth_manager.return_value = mock_auth_manager
+ mock_auth_manager.generate_jwt.return_value = "new_token"
+
+ next_url = "http://localhost:8080"
+ response = client.get(
+ AUTH_MANAGER_FASTAPI_APP_PREFIX + "/refresh",
+ headers={"Authorization": "Bearer refresh_token"},
+ follow_redirects=False,
+ params={"next": next_url},
+ )
+
+ assert response.status_code == 303
+ assert "_token" in response.cookies
+
+ assert "location" in response.headers
+ assert response.headers["location"] == next_url
+
+ # Test when user is None or refresh_token is not set
+ @patch("airflow.api_fastapi.core_api.security.get_user", new_callable=AsyncMock)
+ @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+ @patch("airflow.providers.keycloak.auth_manager.routes.login.KeycloakAuthManager.get_keycloak_client")
+ @patch("airflow.providers.keycloak.auth_manager.routes.login.get_auth_manager")
+ @pytest.mark.asyncio
+ async def test_refresh_user_none(
+ self,
+ mock_get_auth_manager,
+ mock_get_keycloak_client,
+ mock_sec_get_auth_manager,
+ mock_get_user,
+ client,
+ ):
+ mock_user = None
+ mock_get_user.return_value = mock_user
+ mock_auth_manager_sec = Mock()
+ mock_sec_get_auth_manager.return_value = mock_auth_manager_sec
+ mock_auth_manager_sec.get_user_from_token = AsyncMock(return_value=mock_user)
+
+ next_url = "http://localhost:8080"
+ response = client.get(
+ AUTH_MANAGER_FASTAPI_APP_PREFIX + "/refresh",
+ headers={"Authorization": "Bearer refresh_token"},
+ follow_redirects=False,
+ params={"next": next_url},
+ )
+
+ assert response.status_code == 400
+ assert "_token" not in response.cookies
+ assert "location" not in response.headers
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 7c00c74f1ab..2e7478b2991 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
@@ -67,7 +67,6 @@ def auth_manager():
def user():
user = Mock()
user.access_token = "access_token"
- user.refresh_token = "refresh_token"
return user
@@ -103,32 +102,6 @@ class TestKeycloakAuthManager:
result = auth_manager.get_url_login()
assert result == f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login"
- @patch.object(KeycloakAuthManager, "_token_expired")
- def test_refresh_user_not_expired(self, mock_token_expired, auth_manager):
- mock_token_expired.return_value = False
-
- result = auth_manager.refresh_user(user=Mock())
-
- assert result is None
-
- @patch.object(KeycloakAuthManager, "get_keycloak_client")
- @patch.object(KeycloakAuthManager, "_token_expired")
- def test_refresh_user_expired(self, mock_token_expired, mock_get_keycloak_client, auth_manager, user):
- mock_token_expired.return_value = True
- keycloak_client = Mock()
- keycloak_client.refresh_token.return_value = {
- "access_token": "new_access_token",
- "refresh_token": "new_refresh_token",
- }
-
- mock_get_keycloak_client.return_value = keycloak_client
-
- result = auth_manager.refresh_user(user=user)
-
- keycloak_client.refresh_token.assert_called_with("refresh_token")
- assert result.access_token == "new_access_token"
- assert result.refresh_token == "new_refresh_token"
-
@pytest.mark.parametrize(
"function, method, details, permission, attributes",
[
@@ -453,15 +426,3 @@ class TestKeycloakAuthManager:
def test_get_cli_commands_return_cli_commands(self, auth_manager):
assert len(auth_manager.get_cli_commands()) == 1
-
- @pytest.mark.parametrize(
- "expiration, expected",
- [
- (-30, True),
- (30, False),
- ],
- )
- def test_token_expired(self, auth_manager, expiration, expected):
- token = auth_manager._get_token_signer(expiration_time_in_seconds=expiration).generate({})
-
- assert KeycloakAuthManager._token_expired(token) is expected