Repository: apache/airflow
Pull request: AIP-84 Add login endpoint to the public url
part 2 of: https://github.com/apache/airflow/issues/44884
closes: https://github.com/apache/airflow/issues/44884


After a 401, we call the `/login` endpoint that will retrieve the auth_manager login URL and redirect us to it.

Tested both with `SimpleAuthManager` and `FABAuthManager`

- [x] Need tests.
- [x] Need to handle `next` query param

Problem statement:
AIP-38 JWT Flow
Implement the JWT flow in the front-end:
- [x] retrieve the JWT from the homepage URL after login (or any page actually)
- [x] persist it in the local storage
- [x] provide the Token in all requests
- [x] eventually clear on 401 expiration (no refresh flow implemented yet)

### Committer

- [X] I acknowledge that I am a maintainer/committer of the Apache Airflow project.

Hints from issue discussion:
Related : https://github.com/apache/airflow/pull/45765

Commit message: Add login endpoint to the public url

Patch to review:
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..31abebdcfacba 100644
--- a/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
+++ b/airflow/api_fastapi/core_api/openapi/v1-generated.yaml
@@ -6676,6 +6676,25 @@ 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
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema: {}
+        '308':
+          description: Permanent Redirect
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
 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..8d2f943f20cda
--- /dev/null
+++ b/airflow/api_fastapi/core_api/routes/public/login.py
@@ -0,0 +1,36 @@
+# 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_308_PERMANENT_REDIRECT]),
+)
+def login(
+    request: Request,
+) -> RedirectResponse:
+    """Redirect to the login URL depending on the AuthManager configured."""
+    return RedirectResponse(request.app.state.auth_manager.get_url_login(), status_code=308)
diff --git a/airflow/ui/openapi-gen/queries/common.ts b/airflow/ui/openapi-gen/queries/common.ts
index dcb8ec827d072..5237f2fdc43f5 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,16 @@ export const UseVersionServiceGetVersionKeyFn = (queryKey?: Array<unknown>) => [
   useVersionServiceGetVersionKey,
   ...(queryKey ?? []),
 ];
+export type LoginServiceLoginDefaultResponse = Awaited<ReturnType<typeof LoginService.login>>;
+export type LoginServiceLoginQueryResult<
+  TData = LoginServiceLoginDefaultResponse,
+  TError = unknown,
+> = UseQueryResult<TData, TError>;
+export const useLoginServiceLoginKey = "LoginServiceLogin";
+export const UseLoginServiceLoginKeyFn = (queryKey?: Array<unknown>) => [
+  useLoginServiceLoginKey,
+  ...(queryKey ?? []),
+];
 export type AssetServiceCreateAssetEventMutationResult = Awaited<
   ReturnType<typeof AssetService.createAssetEvent>
 >;
diff --git a/airflow/ui/openapi-gen/queries/prefetch.ts b/airflow/ui/openapi-gen/queries/prefetch.ts
index 94176c5e52d0a..e75ed3d91b151 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,14 @@ export const prefetchUseVersionServiceGetVersion = (queryClient: QueryClient) =>
     queryKey: Common.UseVersionServiceGetVersionKeyFn(),
     queryFn: () => VersionService.getVersion(),
   });
+/**
+ * Login
+ * Redirect to the login URL depending on the AuthManager configured.
+ * @returns unknown Successful Response
+ * @throws ApiError
+ */
+export const prefetchUseLoginServiceLogin = (queryClient: QueryClient) =>
+  queryClient.prefetchQuery({
+    queryKey: Common.UseLoginServiceLoginKeyFn(),
+    queryFn: () => LoginService.login(),
+  });
diff --git a/airflow/ui/openapi-gen/queries/queries.ts b/airflow/ui/openapi-gen/queries/queries.ts
index 5a71695254bfa..a8629bbf0bdf5 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,25 @@ export const useVersionServiceGetVersion = <
     queryFn: () => VersionService.getVersion() as TData,
     ...options,
   });
+/**
+ * Login
+ * Redirect to the login URL depending on the AuthManager configured.
+ * @returns unknown Successful Response
+ * @throws ApiError
+ */
+export const useLoginServiceLogin = <
+  TData = Common.LoginServiceLoginDefaultResponse,
+  TError = unknown,
+  TQueryKey extends Array<unknown> = unknown[],
+>(
+  queryKey?: TQueryKey,
+  options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+  useQuery<TData, TError>({
+    queryKey: Common.UseLoginServiceLoginKeyFn(queryKey),
+    queryFn: () => LoginService.login() 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..5f3a22c3de20c 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,22 @@ export const useVersionServiceGetVersionSuspense = <
     queryFn: () => VersionService.getVersion() as TData,
     ...options,
   });
+/**
+ * Login
+ * Redirect to the login URL depending on the AuthManager configured.
+ * @returns unknown Successful Response
+ * @throws ApiError
+ */
+export const useLoginServiceLoginSuspense = <
+  TData = Common.LoginServiceLoginDefaultResponse,
+  TError = unknown,
+  TQueryKey extends Array<unknown> = unknown[],
+>(
+  queryKey?: TQueryKey,
+  options?: Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">,
+) =>
+  useSuspenseQuery<TData, TError>({
+    queryKey: Common.UseLoginServiceLoginKeyFn(queryKey),
+    queryFn: () => LoginService.login() 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..8feffe9a1f894 100644
--- a/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -201,6 +201,7 @@ import type {
   ReparseDagFileResponse,
   GetHealthResponse,
   GetVersionResponse,
+  LoginResponse,
 } from "./types.gen";
 
 export class AssetService {
@@ -3359,3 +3360,21 @@ export class VersionService {
     });
   }
 }
+
+export class LoginService {
+  /**
+   * Login
+   * Redirect to the login URL depending on the AuthManager configured.
+   * @returns unknown Successful Response
+   * @throws ApiError
+   */
+  public static login(): CancelablePromise<LoginResponse> {
+    return __request(OpenAPI, {
+      method: "GET",
+      url: "/public/login",
+      errors: {
+        308: "Permanent Redirect",
+      },
+    });
+  }
+}
diff --git a/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow/ui/openapi-gen/requests/types.gen.ts
index a63ec17ffadcf..7430f71ab6104 100644
--- a/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2424,6 +2424,8 @@ export type GetHealthResponse = HealthInfoResponse;
 
 export type GetVersionResponse = VersionInfo;
 
+export type LoginResponse = unknown;
+
 export type $OpenApiTs = {
   "/ui/next_run_assets/{dag_id}": {
     get: {
@@ -5088,4 +5090,18 @@ export type $OpenApiTs = {
       };
     };
   };
+  "/public/login": {
+    get: {
+      res: {
+        /**
+         * Successful Response
+         */
+        200: unknown;
+        /**
+         * Permanent Redirect
+         */
+        308: HTTPExceptionResponse;
+      };
+    };
+  };
 };
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);


Code review comments:
diff_hunk: @@ -0,0 +1,36 @@
+# 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_308_PERMANENT_REDIRECT]),
+)
+def login(
+    request: Request,
+) -> RedirectResponse:
+    """Redirect to the login URL depending on the AuthManager configured."""
+    return RedirectResponse(request.app.state.auth_manager.get_url_login(), status_code=308)
path: airflow/api_fastapi/core_api/routes/public/login.py
text: @user1:
Should we be using 307 instead? I feel like we should still have the browser hit us for the redirect fresh each time vs caching it - what if the authmananger/config is changed?

@author:
Right, updated