diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 78f7f7b8e7c28..e425a80379a0f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -82,7 +82,7 @@ airflow-core/src/airflow/ui/public/i18n/locales/fr/ @pierrejeambrun @vincbeck /providers/edge3/ @jscheffl /providers/fab/ @vincbeck /providers/hashicorp/ @hussein-awala -/providers/keycloak/ @vincbeck +/providers/keycloak/ @vincbeck @bugraoz93 /providers/openlineage/ @mobuchowski /providers/slack/ @eladkal /providers/smtp/ @hussein-awala 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 321bcb123aebe..8e7ef8573f2dc 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 @@ -132,6 +132,15 @@ def get_url_logout(self) -> str | None: """ return None + def get_url_refresh(self) -> str | None: + """ + Return the URL to refresh the authentication token. + + 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 + @abstractmethod def is_authorized_configuration( self, 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 1bbb151cc141a..4dc9687d7a0a7 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 @@ -7718,6 +7718,40 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /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 b8f6d204d2ed1..2e3a215f1753b 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 @@ -22,6 +22,7 @@ from airflow.api_fastapi.common.router import AirflowRouter from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc from airflow.api_fastapi.core_api.security import is_safe_url +from airflow.configuration import conf auth_router = AirflowRouter(tags=["Login"], prefix="/auth") @@ -55,3 +56,23 @@ def logout(request: Request, next: None | str = None) -> RedirectResponse: logout_url = request.app.state.auth_manager.get_url_login() return RedirectResponse(logout_url) + + +@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 837eca5e85d7e..716f7885a6eb6 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts @@ -678,6 +678,12 @@ export const useLoginServiceLogoutKey = "LoginServiceLogout"; export const UseLoginServiceLogoutKeyFn = ({ next }: { next?: string; } = {}, queryKey?: Array) => [useLoginServiceLogoutKey, ...(queryKey ?? [{ next }])]; +export type LoginServiceRefreshDefaultResponse = Awaited>; +export type LoginServiceRefreshQueryResult = UseQueryResult; +export const useLoginServiceRefreshKey = "LoginServiceRefresh"; +export const UseLoginServiceRefreshKeyFn = ({ next }: { + next?: string; +} = {}, queryKey?: Array) => [useLoginServiceRefreshKey, ...(queryKey ?? [{ next }])]; export type AuthLinksServiceGetAuthMenusDefaultResponse = Awaited>; export type AuthLinksServiceGetAuthMenusQueryResult = UseQueryResult; 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 210dcacb0acd9..2188a44bdad60 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts @@ -1279,6 +1279,17 @@ export const ensureUseLoginServiceLogoutData = (queryClient: QueryClient, { next next?: string; } = {}) => queryClient.ensureQueryData({ queryKey: Common.UseLoginServiceLogoutKeyFn({ next }), queryFn: () => LoginService.logout({ next }) }); /** +* 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 bf2a3841c2ef4..baf88d9cddf0b 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts @@ -1279,6 +1279,17 @@ export const prefetchUseLoginServiceLogout = (queryClient: QueryClient, { next } next?: string; } = {}) => queryClient.prefetchQuery({ queryKey: Common.UseLoginServiceLogoutKeyFn({ next }), queryFn: () => LoginService.logout({ next }) }); /** +* 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 f598ce9f4237d..61aa7cba3e0d5 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts @@ -1279,6 +1279,17 @@ export const useLoginServiceLogout = , "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseLoginServiceLogoutKeyFn({ next }, queryKey), queryFn: () => LoginService.logout({ next }) 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 = = unknown[]>({ next }: { + next?: string; +} = {}, queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useQuery({ 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 2d4773f6fa87d..e01e04d767921 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts @@ -1279,6 +1279,17 @@ export const useLoginServiceLogoutSuspense = , "queryKey" | "queryFn">) => useSuspenseQuery({ queryKey: Common.UseLoginServiceLogoutKeyFn({ next }, queryKey), queryFn: () => LoginService.logout({ next }) 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 = = unknown[]>({ next }: { + next?: string; +} = {}, queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useSuspenseQuery({ 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 131be8d72a113..15b5ab5b58c7d 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, DeleteDagAssetQueuedEventsData, DeleteDagAssetQueuedEventsResponse, GetDagAssetQueuedEventData, GetDagAssetQueuedEventResponse, DeleteDagAssetQueuedEventData, DeleteDagAssetQueuedEventResponse, NextRunAssetsData, NextRunAssetsResponse, ListBackfillsData, ListBackfillsResponse, CreateBackfillData, CreateBackfillResponse, GetBackfillData, GetBackfillResponse, PauseBackfillData, PauseBackfillResponse, UnpauseBackfillData, UnpauseBackfillResponse, CancelBackfillData, CancelBackfillResponse, CreateBackfillDryRunData, CreateBackfillDryRunResponse, ListBackfillsUiData, ListBackfillsUiResponse, DeleteConnectionData, DeleteConnectionResponse, GetConnectionData, GetConnectionResponse, PatchConnectionData, PatchConnectionResponse, GetConnectionsData, GetConnectionsResponse, PostConnectionData, PostConnectionResponse, BulkConnectionsData, BulkConnectionsResponse, TestConnectionData, TestConnectionResponse, CreateDefaultConnectionsResponse, HookMetaDataResponse, GetDagRunData, GetDagRunResponse, DeleteDagRunData, DeleteDagRunResponse, PatchDagRunData, PatchDagRunResponse, GetUpstreamAssetEventsData, GetUpstreamAssetEventsResponse, ClearDagRunData, ClearDagRunResponse, GetDagRunsData, GetDagRunsResponse, TriggerDagRunData, TriggerDagRunResponse, WaitDagRunUntilFinishedData, WaitDagRunUntilFinishedResponse, GetListDagRunsBatchData, GetListDagRunsBatchResponse, GetDagSourceData, GetDagSourceResponse, GetDagStatsData, GetDagStatsResponse, GetDagReportsData, GetDagReportsResponse, GetConfigData, GetConfigResponse, GetConfigValueData, GetConfigValueResponse, GetConfigsResponse, ListDagWarningsData, ListDagWarningsResponse, GetDagsData, GetDagsResponse, PatchDagsData, PatchDagsResponse, GetDagData, GetDagResponse, PatchDagData, PatchDagResponse, DeleteDagData, DeleteDagResponse, GetDagDetailsData, GetDagDetailsResponse, FavoriteDagData, FavoriteDagResponse, UnfavoriteDagData, UnfavoriteDagResponse, GetDagTagsData, GetDagTagsResponse, GetDagsUiData, GetDagsUiResponse, GetLatestRunInfoData, GetLatestRunInfoResponse, GetEventLogData, GetEventLogResponse, GetEventLogsData, GetEventLogsResponse, GetExtraLinksData, GetExtraLinksResponse, GetTaskInstanceData, GetTaskInstanceResponse, PatchTaskInstanceData, PatchTaskInstanceResponse, DeleteTaskInstanceData, DeleteTaskInstanceResponse, GetMappedTaskInstancesData, GetMappedTaskInstancesResponse, GetTaskInstanceDependenciesByMapIndexData, GetTaskInstanceDependenciesByMapIndexResponse, GetTaskInstanceDependenciesData, GetTaskInstanceDependenciesResponse, GetTaskInstanceTriesData, GetTaskInstanceTriesResponse, GetMappedTaskInstanceTriesData, GetMappedTaskInstanceTriesResponse, GetMappedTaskInstanceData, GetMappedTaskInstanceResponse, PatchTaskInstanceByMapIndexData, PatchTaskInstanceByMapIndexResponse, GetTaskInstancesData, GetTaskInstancesResponse, BulkTaskInstancesData, BulkTaskInstancesResponse, GetTaskInstancesBatchData, GetTaskInstancesBatchResponse, GetTaskInstanceTryDetailsData, GetTaskInstanceTryDetailsResponse, GetMappedTaskInstanceTryDetailsData, GetMappedTaskInstanceTryDetailsResponse, PostClearTaskInstancesData, PostClearTaskInstancesResponse, PatchTaskInstanceDryRunByMapIndexData, PatchTaskInstanceDryRunByMapIndexResponse, PatchTaskInstanceDryRunData, PatchTaskInstanceDryRunResponse, GetLogData, GetLogResponse, GetExternalLogUrlData, GetExternalLogUrlResponse, GetImportErrorData, GetImportErrorResponse, GetImportErrorsData, GetImportErrorsResponse, GetJobsData, GetJobsResponse, GetPluginsData, GetPluginsResponse, ImportErrorsResponse, DeletePoolData, DeletePoolResponse, GetPoolData, GetPoolResponse, PatchPoolData, PatchPoolResponse, GetPoolsData, GetPoolsResponse, PostPoolData, PostPoolResponse, BulkPoolsData, BulkPoolsResponse, GetProvidersData, GetProvidersResponse, GetXcomEntryData, GetXcomEntryResponse, UpdateXcomEntryData, UpdateXcomEntryResponse, GetXcomEntriesData, GetXcomEntriesResponse, CreateXcomEntryData, CreateXcomEntryResponse, GetTasksData, GetTasksResponse, GetTaskData, GetTaskResponse, DeleteVariableData, DeleteVariableResponse, GetVariableData, GetVariableResponse, PatchVariableData, PatchVariableResponse, GetVariablesData, GetVariablesResponse, PostVariableData, PostVariableResponse, BulkVariablesData, BulkVariablesResponse, ReparseDagFileData, ReparseDagFileResponse, GetDagVersionData, GetDagVersionResponse, GetDagVersionsData, GetDagVersionsResponse, UpdateHitlDetailData, UpdateHitlDetailResponse, GetHitlDetailData, GetHitlDetailResponse, UpdateMappedTiHitlDetailData, UpdateMappedTiHitlDetailResponse, GetMappedTiHitlDetailData, GetMappedTiHitlDetailResponse, GetHitlDetailsData, GetHitlDetailsResponse, GetHealthResponse, GetVersionResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, GetAuthMenusResponse, GetDependenciesData, GetDependenciesResponse, HistoricalMetricsData, HistoricalMetricsResponse, DagStatsResponse2, StructureDataData, StructureDataResponse2, GetDagStructureData, GetDagStructureResponse, GetGridRunsData, GetGridRunsResponse, GetGridTiSummariesData, GetGridTiSummariesResponse, GetLatestRunData, GetLatestRunResponse, GetCalendarData, GetCalendarResponse } from './types.gen'; +import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, DeleteDagAssetQueuedEventsData, DeleteDagAssetQueuedEventsResponse, GetDagAssetQueuedEventData, GetDagAssetQueuedEventResponse, DeleteDagAssetQueuedEventData, DeleteDagAssetQueuedEventResponse, NextRunAssetsData, NextRunAssetsResponse, ListBackfillsData, ListBackfillsResponse, CreateBackfillData, CreateBackfillResponse, GetBackfillData, GetBackfillResponse, PauseBackfillData, PauseBackfillResponse, UnpauseBackfillData, UnpauseBackfillResponse, CancelBackfillData, CancelBackfillResponse, CreateBackfillDryRunData, CreateBackfillDryRunResponse, ListBackfillsUiData, ListBackfillsUiResponse, DeleteConnectionData, DeleteConnectionResponse, GetConnectionData, GetConnectionResponse, PatchConnectionData, PatchConnectionResponse, GetConnectionsData, GetConnectionsResponse, PostConnectionData, PostConnectionResponse, BulkConnectionsData, BulkConnectionsResponse, TestConnectionData, TestConnectionResponse, CreateDefaultConnectionsResponse, HookMetaDataResponse, GetDagRunData, GetDagRunResponse, DeleteDagRunData, DeleteDagRunResponse, PatchDagRunData, PatchDagRunResponse, GetUpstreamAssetEventsData, GetUpstreamAssetEventsResponse, ClearDagRunData, ClearDagRunResponse, GetDagRunsData, GetDagRunsResponse, TriggerDagRunData, TriggerDagRunResponse, WaitDagRunUntilFinishedData, WaitDagRunUntilFinishedResponse, GetListDagRunsBatchData, GetListDagRunsBatchResponse, GetDagSourceData, GetDagSourceResponse, GetDagStatsData, GetDagStatsResponse, GetDagReportsData, GetDagReportsResponse, GetConfigData, GetConfigResponse, GetConfigValueData, GetConfigValueResponse, GetConfigsResponse, ListDagWarningsData, ListDagWarningsResponse, GetDagsData, GetDagsResponse, PatchDagsData, PatchDagsResponse, GetDagData, GetDagResponse, PatchDagData, PatchDagResponse, DeleteDagData, DeleteDagResponse, GetDagDetailsData, GetDagDetailsResponse, FavoriteDagData, FavoriteDagResponse, UnfavoriteDagData, UnfavoriteDagResponse, GetDagTagsData, GetDagTagsResponse, GetDagsUiData, GetDagsUiResponse, GetLatestRunInfoData, GetLatestRunInfoResponse, GetEventLogData, GetEventLogResponse, GetEventLogsData, GetEventLogsResponse, GetExtraLinksData, GetExtraLinksResponse, GetTaskInstanceData, GetTaskInstanceResponse, PatchTaskInstanceData, PatchTaskInstanceResponse, DeleteTaskInstanceData, DeleteTaskInstanceResponse, GetMappedTaskInstancesData, GetMappedTaskInstancesResponse, GetTaskInstanceDependenciesByMapIndexData, GetTaskInstanceDependenciesByMapIndexResponse, GetTaskInstanceDependenciesData, GetTaskInstanceDependenciesResponse, GetTaskInstanceTriesData, GetTaskInstanceTriesResponse, GetMappedTaskInstanceTriesData, GetMappedTaskInstanceTriesResponse, GetMappedTaskInstanceData, GetMappedTaskInstanceResponse, PatchTaskInstanceByMapIndexData, PatchTaskInstanceByMapIndexResponse, GetTaskInstancesData, GetTaskInstancesResponse, BulkTaskInstancesData, BulkTaskInstancesResponse, GetTaskInstancesBatchData, GetTaskInstancesBatchResponse, GetTaskInstanceTryDetailsData, GetTaskInstanceTryDetailsResponse, GetMappedTaskInstanceTryDetailsData, GetMappedTaskInstanceTryDetailsResponse, PostClearTaskInstancesData, PostClearTaskInstancesResponse, PatchTaskInstanceDryRunByMapIndexData, PatchTaskInstanceDryRunByMapIndexResponse, PatchTaskInstanceDryRunData, PatchTaskInstanceDryRunResponse, GetLogData, GetLogResponse, GetExternalLogUrlData, GetExternalLogUrlResponse, GetImportErrorData, GetImportErrorResponse, GetImportErrorsData, GetImportErrorsResponse, GetJobsData, GetJobsResponse, GetPluginsData, GetPluginsResponse, ImportErrorsResponse, DeletePoolData, DeletePoolResponse, GetPoolData, GetPoolResponse, PatchPoolData, PatchPoolResponse, GetPoolsData, GetPoolsResponse, PostPoolData, PostPoolResponse, BulkPoolsData, BulkPoolsResponse, GetProvidersData, GetProvidersResponse, GetXcomEntryData, GetXcomEntryResponse, UpdateXcomEntryData, UpdateXcomEntryResponse, GetXcomEntriesData, GetXcomEntriesResponse, CreateXcomEntryData, CreateXcomEntryResponse, GetTasksData, GetTasksResponse, GetTaskData, GetTaskResponse, DeleteVariableData, DeleteVariableResponse, GetVariableData, GetVariableResponse, PatchVariableData, PatchVariableResponse, GetVariablesData, GetVariablesResponse, PostVariableData, PostVariableResponse, BulkVariablesData, BulkVariablesResponse, ReparseDagFileData, ReparseDagFileResponse, GetDagVersionData, GetDagVersionResponse, GetDagVersionsData, GetDagVersionsResponse, UpdateHitlDetailData, UpdateHitlDetailResponse, GetHitlDetailData, GetHitlDetailResponse, UpdateMappedTiHitlDetailData, UpdateMappedTiHitlDetailResponse, GetMappedTiHitlDetailData, GetMappedTiHitlDetailResponse, GetHitlDetailsData, GetHitlDetailsResponse, GetHealthResponse, GetVersionResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, RefreshData, RefreshResponse, GetAuthMenusResponse, GetDependenciesData, GetDependenciesResponse, HistoricalMetricsData, HistoricalMetricsResponse, DagStatsResponse2, StructureDataData, StructureDataResponse2, GetDagStructureData, GetDagStructureResponse, GetGridRunsData, GetGridRunsResponse, GetGridTiSummariesData, GetGridTiSummariesResponse, GetLatestRunData, GetLatestRunResponse, GetCalendarData, GetCalendarResponse } from './types.gen'; export class AssetService { /** @@ -3626,6 +3626,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 { + 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 c5a8664463cb4..ee9394d809ded 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 @@ -2987,6 +2987,12 @@ export type LogoutData = { export type LogoutResponse = unknown; +export type RefreshData = { + next?: string | null; +}; + +export type RefreshResponse = unknown; + export type GetAuthMenusResponse = MenuItemCollectionResponse; export type GetDependenciesData = { @@ -6129,6 +6135,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/core_api/routes/public/test_auth.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py index 121c3b0d48ae2..8f21290142a04 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,6 +16,7 @@ # under the License. from __future__ import annotations +import os from unittest.mock import MagicMock, patch import pytest @@ -23,6 +24,8 @@ 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 @@ -32,6 +35,8 @@ 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 @@ -91,3 +96,56 @@ def test_should_respond_307( assert response.status_code == 307 assert response.headers["location"] == expected_redirection + + +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 95734bdaa37f4..8fe41ede18695 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 @@ 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 945539ccc4235..c269210b5e8bd 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 @@ -96,6 +96,10 @@ def get_url_login(self, **kwargs) -> str: base_url = conf.get("api", "base_url", fallback="/") return urljoin(base_url, f"{AUTH_MANAGER_FASTAPI_APP_PREFIX}/login") + 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, *, diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py index 49e6255a4df0b..99d42dce36ccc 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/routes/login.py @@ -18,13 +18,15 @@ from __future__ import annotations import logging +from typing import Annotated -from fastapi import Request # noqa: TC002 +from fastapi import Depends, HTTPException, Request from starlette.responses import HTMLResponse, RedirectResponse 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.common.router import AirflowRouter +from airflow.api_fastapi.core_api.security import get_user from airflow.configuration import conf from airflow.providers.keycloak.auth_manager.keycloak_auth_manager import KeycloakAuthManager from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser @@ -70,3 +72,26 @@ def login_callback(request: Request): secure = bool(conf.get("api", "ssl_cert", fallback="")) response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure) return response + + +@login_router.get("/refresh") +def refresh( + request: Request, user: Annotated[KeycloakAuthManagerUser, Depends(get_user)] +) -> RedirectResponse: + """Refresh the token.""" + client = KeycloakAuthManager.get_keycloak_client() + + if not user or not user.refresh_token: + raise HTTPException(status_code=400, detail="User is empty or has no refresh token") + + tokens = client.refresh_token(user.refresh_token) + user.refresh_token = tokens["refresh_token"] + user.access_token = tokens["access_token"] + token = get_auth_manager().generate_jwt(user) + + redirect_url = request.query_params.get("next", conf.get("api", "base_url", fallback="/")) + response = RedirectResponse(url=redirect_url, status_code=303) + secure = bool(conf.get("api", "ssl_cert", fallback="")) + + response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=secure) + return response 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 2604da765818d..9cfc1984e71e6 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 @@ def test_login_callback(self, mock_get_keycloak_client, mock_get_auth_manager, c 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