diff --git a/airflow/api_fastapi/app.py b/airflow/api_fastapi/app.py index 4323fcd5017ca..bc04e15e355b5 100644 --- a/airflow/api_fastapi/app.py +++ b/airflow/api_fastapi/app.py @@ -133,6 +133,7 @@ def init_auth_manager(app: FastAPI | None = None) -> BaseAuthManager: if app and (auth_manager_fastapi_app := am.get_fastapi_app()): app.mount("/auth", auth_manager_fastapi_app) + app.state.auth_manager = am return am diff --git a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml index 486c371682fdb..dd2a6b4be8482 100644 --- a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml +++ b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml @@ -6676,6 +6676,40 @@ paths: application/json: schema: $ref: '#/components/schemas/VersionInfo' + /public/login: + get: + tags: + - Login + summary: Login + description: Redirect to the login URL depending on the AuthManager configured. + operationId: login + 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/api_fastapi/core_api/routes/public/__init__.py b/airflow/api_fastapi/core_api/routes/public/__init__.py index 89465177845d3..527f993d14255 100644 --- a/airflow/api_fastapi/core_api/routes/public/__init__.py +++ b/airflow/api_fastapi/core_api/routes/public/__init__.py @@ -38,6 +38,7 @@ from airflow.api_fastapi.core_api.routes.public.import_error import import_error_router from airflow.api_fastapi.core_api.routes.public.job import job_router from airflow.api_fastapi.core_api.routes.public.log import task_instances_log_router +from airflow.api_fastapi.core_api.routes.public.login import login_router from airflow.api_fastapi.core_api.routes.public.monitor import monitor_router from airflow.api_fastapi.core_api.routes.public.plugins import plugins_router from airflow.api_fastapi.core_api.routes.public.pools import pools_router @@ -87,3 +88,4 @@ # Following routers are not included in common router, for now we don't expect it to have authentication public_router.include_router(monitor_router) public_router.include_router(version_router) +public_router.include_router(login_router) diff --git a/airflow/api_fastapi/core_api/routes/public/login.py b/airflow/api_fastapi/core_api/routes/public/login.py new file mode 100644 index 0000000000000..23c6cd4e19b19 --- /dev/null +++ b/airflow/api_fastapi/core_api/routes/public/login.py @@ -0,0 +1,38 @@ +# 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, status +from fastapi.responses import RedirectResponse + +from airflow.api_fastapi.common.router import AirflowRouter +from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc + +login_router = AirflowRouter(tags=["Login"], prefix="/login") + + +@login_router.get( + "", + responses=create_openapi_http_exception_doc([status.HTTP_307_TEMPORARY_REDIRECT]), +) +def login(request: Request, next: None | str = None) -> RedirectResponse: + """Redirect to the login URL depending on the AuthManager configured.""" + login_url = request.app.state.auth_manager.get_url_login() + + if next: + login_url += f"?next={next}" + return RedirectResponse(login_url) diff --git a/airflow/ui/openapi-gen/queries/common.ts b/airflow/ui/openapi-gen/queries/common.ts index dcb8ec827d072..2e6d7419d096e 100644 --- a/airflow/ui/openapi-gen/queries/common.ts +++ b/airflow/ui/openapi-gen/queries/common.ts @@ -20,6 +20,7 @@ import { GridService, ImportErrorService, JobService, + LoginService, MonitorService, PluginService, PoolService, @@ -1642,6 +1643,20 @@ export const UseVersionServiceGetVersionKeyFn = (queryKey?: Array) => [ useVersionServiceGetVersionKey, ...(queryKey ?? []), ]; +export type LoginServiceLoginDefaultResponse = Awaited>; +export type LoginServiceLoginQueryResult< + TData = LoginServiceLoginDefaultResponse, + TError = unknown, +> = UseQueryResult; +export const useLoginServiceLoginKey = "LoginServiceLogin"; +export const UseLoginServiceLoginKeyFn = ( + { + next, + }: { + next?: string; + } = {}, + queryKey?: Array, +) => [useLoginServiceLoginKey, ...(queryKey ?? [{ next }])]; export type AssetServiceCreateAssetEventMutationResult = Awaited< ReturnType >; diff --git a/airflow/ui/openapi-gen/queries/prefetch.ts b/airflow/ui/openapi-gen/queries/prefetch.ts index 94176c5e52d0a..0aeca60bf2ac9 100644 --- a/airflow/ui/openapi-gen/queries/prefetch.ts +++ b/airflow/ui/openapi-gen/queries/prefetch.ts @@ -19,6 +19,7 @@ import { GridService, ImportErrorService, JobService, + LoginService, MonitorService, PluginService, PoolService, @@ -2298,3 +2299,23 @@ export const prefetchUseVersionServiceGetVersion = (queryClient: QueryClient) => queryKey: Common.UseVersionServiceGetVersionKeyFn(), queryFn: () => VersionService.getVersion(), }); +/** + * Login + * Redirect to the login URL depending on the AuthManager configured. + * @param data The data for the request. + * @param data.next + * @returns unknown Successful Response + * @throws ApiError + */ +export const prefetchUseLoginServiceLogin = ( + queryClient: QueryClient, + { + next, + }: { + next?: string; + } = {}, +) => + queryClient.prefetchQuery({ + queryKey: Common.UseLoginServiceLoginKeyFn({ next }), + queryFn: () => LoginService.login({ next }), + }); diff --git a/airflow/ui/openapi-gen/queries/queries.ts b/airflow/ui/openapi-gen/queries/queries.ts index 5a71695254bfa..449452418dd88 100644 --- a/airflow/ui/openapi-gen/queries/queries.ts +++ b/airflow/ui/openapi-gen/queries/queries.ts @@ -20,6 +20,7 @@ import { GridService, ImportErrorService, JobService, + LoginService, MonitorService, PluginService, PoolService, @@ -2727,6 +2728,32 @@ export const useVersionServiceGetVersion = < queryFn: () => VersionService.getVersion() as TData, ...options, }); +/** + * Login + * Redirect to the login URL depending on the AuthManager configured. + * @param data The data for the request. + * @param data.next + * @returns unknown Successful Response + * @throws ApiError + */ +export const useLoginServiceLogin = < + TData = Common.LoginServiceLoginDefaultResponse, + TError = unknown, + TQueryKey extends Array = unknown[], +>( + { + next, + }: { + next?: string; + } = {}, + queryKey?: TQueryKey, + options?: Omit, "queryKey" | "queryFn">, +) => + useQuery({ + queryKey: Common.UseLoginServiceLoginKeyFn({ next }, queryKey), + queryFn: () => LoginService.login({ next }) as TData, + ...options, + }); /** * Create Asset Event * Create asset events. diff --git a/airflow/ui/openapi-gen/queries/suspense.ts b/airflow/ui/openapi-gen/queries/suspense.ts index 94e852a598cb0..a7ea9ad976ce0 100644 --- a/airflow/ui/openapi-gen/queries/suspense.ts +++ b/airflow/ui/openapi-gen/queries/suspense.ts @@ -19,6 +19,7 @@ import { GridService, ImportErrorService, JobService, + LoginService, MonitorService, PluginService, PoolService, @@ -2704,3 +2705,29 @@ export const useVersionServiceGetVersionSuspense = < queryFn: () => VersionService.getVersion() as TData, ...options, }); +/** + * Login + * Redirect to the login URL depending on the AuthManager configured. + * @param data The data for the request. + * @param data.next + * @returns unknown Successful Response + * @throws ApiError + */ +export const useLoginServiceLoginSuspense = < + TData = Common.LoginServiceLoginDefaultResponse, + TError = unknown, + TQueryKey extends Array = unknown[], +>( + { + next, + }: { + next?: string; + } = {}, + queryKey?: TQueryKey, + options?: Omit, "queryKey" | "queryFn">, +) => + useSuspenseQuery({ + queryKey: Common.UseLoginServiceLoginKeyFn({ next }, queryKey), + queryFn: () => LoginService.login({ next }) as TData, + ...options, + }); diff --git a/airflow/ui/openapi-gen/requests/services.gen.ts b/airflow/ui/openapi-gen/requests/services.gen.ts index 585796aa2b2db..2c2574198453e 100644 --- a/airflow/ui/openapi-gen/requests/services.gen.ts +++ b/airflow/ui/openapi-gen/requests/services.gen.ts @@ -201,6 +201,8 @@ import type { ReparseDagFileResponse, GetHealthResponse, GetVersionResponse, + LoginData, + LoginResponse, } from "./types.gen"; export class AssetService { @@ -3359,3 +3361,27 @@ export class VersionService { }); } } + +export class LoginService { + /** + * Login + * Redirect to the login URL depending on the AuthManager configured. + * @param data The data for the request. + * @param data.next + * @returns unknown Successful Response + * @throws ApiError + */ + public static login(data: LoginData = {}): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/public/login", + query: { + next: data.next, + }, + errors: { + 307: "Temporary Redirect", + 422: "Validation Error", + }, + }); + } +} diff --git a/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow/ui/openapi-gen/requests/types.gen.ts index a63ec17ffadcf..7e40c6a087d40 100644 --- a/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow/ui/openapi-gen/requests/types.gen.ts @@ -2424,6 +2424,12 @@ export type GetHealthResponse = HealthInfoResponse; export type GetVersionResponse = VersionInfo; +export type LoginData = { + next?: string | null; +}; + +export type LoginResponse = unknown; + export type $OpenApiTs = { "/ui/next_run_assets/{dag_id}": { get: { @@ -5088,4 +5094,23 @@ export type $OpenApiTs = { }; }; }; + "/public/login": { + get: { + req: LoginData; + res: { + /** + * Successful Response + */ + 200: unknown; + /** + * Temporary Redirect + */ + 307: HTTPExceptionResponse; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; }; diff --git a/airflow/ui/src/main.tsx b/airflow/ui/src/main.tsx index b9e63269983ce..2315a8666c3d9 100644 --- a/airflow/ui/src/main.tsx +++ b/airflow/ui/src/main.tsx @@ -39,7 +39,7 @@ axios.interceptors.response.use( const params = new URLSearchParams(); params.set("next", globalThis.location.href); - globalThis.location.replace(`${import.meta.env.VITE_LEGACY_API_URL}/login?${params.toString()}`); + globalThis.location.replace(`/public/login?${params.toString()}`); } return Promise.reject(error); diff --git a/tests/api_fastapi/core_api/routes/public/test_login.py b/tests/api_fastapi/core_api/routes/public/test_login.py new file mode 100644 index 0000000000000..0d774a4035066 --- /dev/null +++ b/tests/api_fastapi/core_api/routes/public/test_login.py @@ -0,0 +1,54 @@ +# 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 MagicMock + +import pytest + +AUTH_MANAGER_LOGIN_URL = "http://some_login_url" + +pytestmark = pytest.mark.db_test + + +class TestLoginEndpoint: + @pytest.fixture(autouse=True) + def setup(self, test_client) -> None: + auth_manager_mock = MagicMock() + auth_manager_mock.get_url_login.return_value = AUTH_MANAGER_LOGIN_URL + test_client.app.state.auth_manager = auth_manager_mock + + +class TestGetLogin(TestLoginEndpoint): + @pytest.mark.parametrize( + "params", + [ + {}, + {"next": None}, + {"next": "http://localhost:29091"}, + {"next": "http://localhost:29091", "other_param": "something_else"}, + ], + ) + def test_should_respond_308(self, test_client, params): + response = test_client.get("/public/login", follow_redirects=False, params=params) + + assert response.status_code == 307 + assert ( + response.headers["location"] == f"{AUTH_MANAGER_LOGIN_URL}?next={params.get('next')}" + if params.get("next") + else AUTH_MANAGER_LOGIN_URL + ) diff --git a/tests/api_fastapi/core_api/routes/test_routes.py b/tests/api_fastapi/core_api/routes/test_routes.py index 1930815a0f08b..a67e300031c1c 100644 --- a/tests/api_fastapi/core_api/routes/test_routes.py +++ b/tests/api_fastapi/core_api/routes/test_routes.py @@ -20,6 +20,7 @@ # Set of paths that are allowed to be accessible without authentication NO_AUTH_PATHS = { + "/public/login", "/public/version", "/public/monitor/health", }