This is an automated email from the ASF dual-hosted git repository.

gopidesu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 4ffb0a6fd38 Set JWT token to localStorage from cookies (#47432)
4ffb0a6fd38 is described below

commit 4ffb0a6fd38ae97bd02e1eb4e40d3781318ef9ef
Author: GPK <[email protected]>
AuthorDate: Fri Mar 14 14:59:49 2025 +0000

    Set JWT token to localStorage from cookies (#47432)
    
    * Set JWT token to localStorage onsuccess login
    
    * Set jwt to localstorage in fab
    
    * Set jwt token to cookies from redirect
    
    * Set jwt token to cookies from redirect
    
    * Fix tests for tokenHandler
    
    * Update auth manager documentation
    
    * Update cookie to use secure property
    
    * Use react-cookie
    
    * Use react-cookie
    
    * Rename cookie constant to COOKIE_NAME_JWT_TOKEN and fix k8s and docker 
tests
    
    * Remove samesite param and update docs
    
    * Fix auth manager docs error
    
    * Remove samesite param in auth manager
    
    * Update docs/apache-airflow/core-concepts/auth-manager/index.rst
    
    Co-authored-by: Pierre Jeambrun <[email protected]>
    
    * Fix static checks
    
    ---------
    
    Co-authored-by: Pierre Jeambrun <[email protected]>
---
 .../api_fastapi/auth/managers/base_auth_manager.py |  3 +
 .../auth/managers/simple/routes/login.py           | 13 +++--
 .../auth/managers/simple/ui/package.json           |  3 +-
 .../auth/managers/simple/ui/pnpm-lock.yaml         | 65 ++++++++++++++++++++--
 .../auth/managers/simple/ui/src/login/Login.tsx    |  7 ++-
 airflow/ui/src/utils/tokenHandler.test.ts          | 36 ++++++------
 airflow/ui/src/utils/tokenHandler.ts               | 32 ++++++-----
 docker_tests/test_docker_compose_quick_start.py    | 29 ++--------
 .../core-concepts/auth-manager/index.rst           | 19 ++++++-
 kubernetes_tests/test_base.py                      | 35 +++---------
 .../amazon/aws/auth_manager/router/login.py        |  9 ++-
 .../amazon/aws/auth_manager/router/test_login.py   |  3 +-
 .../fab/src/airflow/providers/fab/www/views.py     |  7 ++-
 .../auth/managers/simple/routes/test_login.py      |  2 +-
 14 files changed, 161 insertions(+), 102 deletions(-)

diff --git a/airflow/api_fastapi/auth/managers/base_auth_manager.py 
b/airflow/api_fastapi/auth/managers/base_auth_manager.py
index e1ac86e090b..c8714e41034 100644
--- a/airflow/api_fastapi/auth/managers/base_auth_manager.py
+++ b/airflow/api_fastapi/auth/managers/base_auth_manager.py
@@ -72,6 +72,9 @@ log = logging.getLogger(__name__)
 T = TypeVar("T", bound=BaseUser)
 
 
+COOKIE_NAME_JWT_TOKEN = "_token"
+
+
 class BaseAuthManager(Generic[T], LoggingMixin, metaclass=ABCMeta):
     """
     Class to derive in order to implement concrete auth managers.
diff --git a/airflow/api_fastapi/auth/managers/simple/routes/login.py 
b/airflow/api_fastapi/auth/managers/simple/routes/login.py
index da53e1f82b8..b637a12373b 100644
--- a/airflow/api_fastapi/auth/managers/simple/routes/login.py
+++ b/airflow/api_fastapi/auth/managers/simple/routes/login.py
@@ -17,12 +17,11 @@
 
 from __future__ import annotations
 
-from urllib.parse import urljoin
-
 from fastapi import HTTPException, status
 from starlette.responses import 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.auth.managers.simple.datamodels.login import 
LoginBody, LoginResponse
 from airflow.api_fastapi.auth.managers.simple.services.login import 
SimpleAuthManagerLogin
 from airflow.api_fastapi.auth.managers.simple.user import SimpleAuthManagerUser
@@ -62,8 +61,14 @@ def create_token_all_admins() -> RedirectResponse:
         username="Anonymous",
         role="ADMIN",
     )
-    url = urljoin(conf.get("api", "base_url"), 
f"?token={get_auth_manager().generate_jwt(user)}")
-    return RedirectResponse(url=url)
+
+    response = RedirectResponse(url=conf.get("api", "base_url"))
+    response.set_cookie(
+        COOKIE_NAME_JWT_TOKEN,
+        get_auth_manager().generate_jwt(user),
+        secure=True,
+    )
+    return response
 
 
 @login_router.post(
diff --git a/airflow/api_fastapi/auth/managers/simple/ui/package.json 
b/airflow/api_fastapi/auth/managers/simple/ui/package.json
index f4d766d9f08..a26b9aad6b2 100644
--- a/airflow/api_fastapi/auth/managers/simple/ui/package.json
+++ b/airflow/api_fastapi/auth/managers/simple/ui/package.json
@@ -18,7 +18,8 @@
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
     "react-hook-form": "^7.20.0",
-    "react-router-dom": "^6.26.2"
+    "react-router-dom": "^6.26.2",
+    "react-cookie": "^7.0.0"
   },
   "devDependencies": {
     "@7nohe/openapi-react-query-codegen": "^1.6.0",
diff --git a/airflow/api_fastapi/auth/managers/simple/ui/pnpm-lock.yaml 
b/airflow/api_fastapi/auth/managers/simple/ui/pnpm-lock.yaml
index bb2dd4fd45f..d0c2f468571 100644
--- a/airflow/api_fastapi/auth/managers/simple/ui/pnpm-lock.yaml
+++ b/airflow/api_fastapi/auth/managers/simple/ui/pnpm-lock.yaml
@@ -10,7 +10,7 @@ importers:
     dependencies:
       '@chakra-ui/react':
         specifier: ^3.1.1
-        version: 
3.3.3(@emotion/[email protected]([email protected]))([email protected]([email protected]))([email protected])
+        version: 
3.3.3(@emotion/[email protected](@types/[email protected])([email protected]))([email protected]([email protected]))([email protected])
       '@tanstack/react-query':
         specifier: ^5.52.1
         version: 5.64.1([email protected])
@@ -20,6 +20,9 @@ importers:
       react:
         specifier: ^18.3.1
         version: 18.3.1
+      react-cookie:
+        specifier: ^7.0.0
+        version: 7.2.2([email protected])
       react-dom:
         specifier: ^18.3.1
         version: 18.3.1([email protected])
@@ -38,7 +41,7 @@ importers:
         version: 6.6.3
       '@testing-library/react':
         specifier: ^16.0.0
-        version: 
16.2.0(@testing-library/[email protected])([email protected]([email protected]))([email protected])
+        version: 
16.2.0(@testing-library/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
       '@vitejs/plugin-react-swc':
         specifier: ^3.7.0
         version: 3.7.2(@swc/[email protected])([email protected]([email protected]))
@@ -663,15 +666,24 @@ packages:
   '@types/[email protected]':
     resolution: {integrity: 
sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
 
+  '@types/[email protected]':
+    resolution: {integrity: 
sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
+
   '@types/[email protected]':
     resolution: {integrity: 
sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
 
+  '@types/[email protected]':
+    resolution: {integrity: 
sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==}
+
   '@types/[email protected]':
     resolution: {integrity: 
sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
   '@types/[email protected]':
     resolution: {integrity: 
sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
 
+  '@types/[email protected]':
+    resolution: {integrity: 
sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==}
+
   '@vitejs/[email protected]':
     resolution: {integrity: 
sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==}
     peerDependencies:
@@ -1070,6 +1082,10 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+    engines: {node: '>= 0.6'}
+
   [email protected]:
     resolution: {integrity: 
sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
     engines: {node: '>=10'}
@@ -1702,6 +1718,11 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-e+hi6axHcw9VODoeVu8WyMWyoosa1pzpyjfvrLdF7CexfU+WSGZdDuRfHa4RJgTpfv3ZjdIpHE14HpYBieHFhg==}
+    peerDependencies:
+      react: '>= 16.3.0'
+
   [email protected]:
     resolution: {integrity: 
sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
     peerDependencies:
@@ -1892,6 +1913,9 @@ packages:
     engines: {node: '>=0.8.0'}
     hasBin: true
 
+  [email protected]:
+    resolution: {integrity: 
sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ==}
+
   [email protected]:
     resolution: {integrity: 
sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==}
 
@@ -2157,11 +2181,11 @@ snapshots:
       '@babel/helper-string-parser': 7.25.9
       '@babel/helper-validator-identifier': 7.25.9
 
-  
'@chakra-ui/[email protected](@emotion/[email protected]([email protected]))([email protected]([email protected]))([email protected])':
+  
'@chakra-ui/[email protected](@emotion/[email protected](@types/[email protected])([email protected]))([email protected]([email protected]))([email protected])':
     dependencies:
       '@ark-ui/react': 4.8.0([email protected]([email protected]))([email protected])
       '@emotion/is-prop-valid': 1.3.1
-      '@emotion/react': 11.14.0([email protected])
+      '@emotion/react': 11.14.0(@types/[email protected])([email protected])
       '@emotion/serialize': 1.3.3
       '@emotion/use-insertion-effect-with-fallbacks': 1.2.0([email protected])
       '@emotion/utils': 1.4.2
@@ -2202,7 +2226,7 @@ snapshots:
 
   '@emotion/[email protected]': {}
 
-  '@emotion/[email protected]([email protected])':
+  '@emotion/[email protected](@types/[email protected])([email protected])':
     dependencies:
       '@babel/runtime': 7.26.9
       '@emotion/babel-plugin': 11.13.5
@@ -2213,6 +2237,8 @@ snapshots:
       '@emotion/weak-memoize': 0.4.0
       hoist-non-react-statics: 3.3.2
       react: 18.3.1
+    optionalDependencies:
+      '@types/react': 19.0.10
     transitivePeerDependencies:
       - supports-color
 
@@ -2585,12 +2611,14 @@ snapshots:
       lodash: 4.17.21
       redent: 3.0.0
 
-  
'@testing-library/[email protected](@testing-library/[email protected])([email protected]([email protected]))([email protected])':
+  
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])':
     dependencies:
       '@babel/runtime': 7.26.0
       '@testing-library/dom': 10.4.0
       react: 18.3.1
       react-dom: 18.3.1([email protected])
+    optionalDependencies:
+      '@types/react': 19.0.10
 
   '@ts-morph/[email protected]':
     dependencies:
@@ -2601,12 +2629,23 @@ snapshots:
 
   '@types/[email protected]': {}
 
+  '@types/[email protected]': {}
+
   '@types/[email protected]': {}
 
+  '@types/[email protected]':
+    dependencies:
+      '@types/react': 19.0.10
+      hoist-non-react-statics: 3.3.2
+
   '@types/[email protected]': {}
 
   '@types/[email protected]': {}
 
+  '@types/[email protected]':
+    dependencies:
+      csstype: 3.1.3
+
   
'@vitejs/[email protected](@swc/[email protected])([email protected]([email protected]))':
     dependencies:
       '@swc/core': 1.10.7(@swc/[email protected])
@@ -3286,6 +3325,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       '@types/parse-json': 4.0.2
@@ -3915,6 +3956,13 @@ snapshots:
       defu: 6.1.4
       destr: 2.0.3
 
+  [email protected]([email protected]):
+    dependencies:
+      '@types/hoist-non-react-statics': 3.3.6
+      hoist-non-react-statics: 3.3.2
+      react: 18.3.1
+      universal-cookie: 7.2.2
+
   [email protected]([email protected]):
     dependencies:
       loose-envify: 1.4.0
@@ -4096,6 +4144,11 @@ snapshots:
   [email protected]:
     optional: true
 
+  [email protected]:
+    dependencies:
+      '@types/cookie': 0.6.0
+      cookie: 0.7.2
+
   [email protected]: {}
 
   [email protected]:
diff --git a/airflow/api_fastapi/auth/managers/simple/ui/src/login/Login.tsx 
b/airflow/api_fastapi/auth/managers/simple/ui/src/login/Login.tsx
index 915e70b55c5..e0321ad08e9 100644
--- a/airflow/api_fastapi/auth/managers/simple/ui/src/login/Login.tsx
+++ b/airflow/api_fastapi/auth/managers/simple/ui/src/login/Login.tsx
@@ -25,6 +25,7 @@ import {LoginForm} from "src/login/LoginForm";
 import type {ApiError} from "openapi-gen/requests/core/ApiError";
 import type {LoginResponse, HTTPExceptionResponse, HTTPValidationError} from 
"openapi-gen/requests/types.gen";
 import { useSearchParams } from "react-router-dom";
+import { useCookies } from 'react-cookie';
 
 export type LoginBody = {
     username: string; password: string;
@@ -36,11 +37,15 @@ type ExpandedApiError = {
 
 export const Login = () => {
     const [searchParams, setSearchParams] = useSearchParams();
+    const [cookies, setCookie] = useCookies(['_token']);
 
     const onSuccess = (data: LoginResponse) => {
         // Redirect to appropriate page with the token
         const next = searchParams.get("next")
-        globalThis.location.replace(`${next ?? ""}?token=${data.jwt_token}`);
+
+        setCookie('_token', data.jwt_token, {path: "/", secure: true});
+
+        globalThis.location.replace(`${next ?? ""}`);
     }
     const {createToken, error: err, isPending, setError} = 
useCreateToken({onSuccess});
     const error = err as ExpandedApiError;
diff --git a/airflow/ui/src/utils/tokenHandler.test.ts 
b/airflow/ui/src/utils/tokenHandler.test.ts
index b0073f9560d..8359bc193a1 100644
--- a/airflow/ui/src/utils/tokenHandler.test.ts
+++ b/airflow/ui/src/utils/tokenHandler.test.ts
@@ -17,21 +17,23 @@
  * under the License.
  */
 import type { InternalAxiosRequestConfig } from "axios";
-import { afterEach, describe, it, vi, expect } from "vitest";
+import { afterEach, describe, it, vi, expect, beforeAll } from "vitest";
 
-import { TOKEN_QUERY_PARAM_NAME, TOKEN_STORAGE_KEY, tokenHandler } from 
"./tokenHandler";
+import { TOKEN_STORAGE_KEY, tokenHandler } from "./tokenHandler";
 
-describe.each([
-  { searchParams: new URLSearchParams({ token: "something" }) },
-  { searchParams: new URLSearchParams({ param2: "someParam2", token: "else" }) 
},
-  { searchParams: new URLSearchParams({}) },
-])("TokenFlow Interceptor", ({ searchParams }) => {
-  it("Should read from the SearchParams, persist to the localStorage and 
remove from the SearchParams", () => {
-    const token = searchParams.get(TOKEN_QUERY_PARAM_NAME);
+describe("TokenFlow Interceptor", () => {
+  beforeAll(() => {
+    Object.defineProperty(document, "cookie", {
+      writable: true,
+    });
+  });
 
-    const setItemMock = vi.spyOn(localStorage, "setItem");
+  it("Should read from the cookie, persist to the localStorage and remove from 
the cookie", () => {
+    const token = "test-token";
 
-    vi.stubGlobal("location", { search: searchParams.toString() });
+    document.cookie = `_token=${token};`;
+
+    const setItemMock = vi.spyOn(localStorage, "setItem");
 
     const headers = {};
 
@@ -39,14 +41,10 @@ describe.each([
 
     const { headers: updatedHeaders } = tokenHandler(config as 
InternalAxiosRequestConfig);
 
-    if (token === null) {
-      expect(setItemMock).toHaveBeenCalledTimes(0);
-    } else {
-      expect(setItemMock).toHaveBeenCalledOnce();
-      expect(setItemMock).toHaveBeenCalledWith(TOKEN_STORAGE_KEY, token);
-      expect(searchParams).not.to.contains.keys(TOKEN_QUERY_PARAM_NAME);
-      expect(updatedHeaders).toEqual({ Authorization: `Bearer ${token}` });
-    }
+    expect(setItemMock).toHaveBeenCalledOnce();
+    expect(setItemMock).toHaveBeenCalledWith(TOKEN_STORAGE_KEY, token);
+    expect(updatedHeaders).toEqual({ Authorization: `Bearer ${token}` });
+    expect(document.cookie).toContain("_token=; expires=");
   });
 });
 
diff --git a/airflow/ui/src/utils/tokenHandler.ts 
b/airflow/ui/src/utils/tokenHandler.ts
index ab267cf96ec..b257dd06c08 100644
--- a/airflow/ui/src/utils/tokenHandler.ts
+++ b/airflow/ui/src/utils/tokenHandler.ts
@@ -19,25 +19,29 @@
 import type { InternalAxiosRequestConfig } from "axios";
 
 export const TOKEN_STORAGE_KEY = "token";
-export const TOKEN_QUERY_PARAM_NAME = "token";
+const getTokenFromCookies = (): string | undefined => {
+  const cookies = document.cookie.split(";");
 
-export const tokenHandler = (config: InternalAxiosRequestConfig) => {
-  const searchParams = new URLSearchParams(globalThis.location.search);
-
-  const tokenUrl = searchParams.get(TOKEN_QUERY_PARAM_NAME);
+  for (const cookie of cookies) {
+    if (cookie.startsWith("_token=")) {
+      const [, token] = cookie.split("=");
 
-  let token: string | null;
+      if (token !== undefined) {
+        localStorage.setItem(TOKEN_STORAGE_KEY, token);
+        document.cookie = "_token=; expires=Thu, 01 Jan 2000 00:00:00 UTC; 
path=/;";
 
-  if (tokenUrl === null) {
-    token = localStorage.getItem(TOKEN_STORAGE_KEY);
-  } else {
-    localStorage.setItem(TOKEN_QUERY_PARAM_NAME, tokenUrl);
-    searchParams.delete(TOKEN_QUERY_PARAM_NAME);
-    globalThis.location.search = searchParams.toString();
-    token = tokenUrl;
+        return token;
+      }
+    }
   }
 
-  if (token !== null) {
+  return undefined;
+};
+
+export const tokenHandler = (config: InternalAxiosRequestConfig) => {
+  const token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? 
getTokenFromCookies();
+
+  if (token !== undefined) {
     config.headers.Authorization = `Bearer ${token}`;
   }
 
diff --git a/docker_tests/test_docker_compose_quick_start.py 
b/docker_tests/test_docker_compose_quick_start.py
index ccf4ef18fcd..452ad41aa8e 100644
--- a/docker_tests/test_docker_compose_quick_start.py
+++ b/docker_tests/test_docker_compose_quick_start.py
@@ -18,12 +18,10 @@ from __future__ import annotations
 
 import json
 import os
-import re
 import shlex
 from pprint import pprint
 from shutil import copyfile
 from time import sleep
-from urllib.parse import parse_qs, urlparse
 
 import pytest
 import requests
@@ -61,32 +59,17 @@ def get_jwt_token() -> str:
     """
     # get csrf token from login page
     session = requests.Session()
-    get_login_form_response = 
session.get(f"http://{DOCKER_COMPOSE_HOST_PORT}/auth/login";)
-    csrf_token = re.search(
-        r'<input id="csrf_token" name="csrf_token" type="hidden" 
value="(.+?)">',
-        get_login_form_response.text,
-    )
-    assert csrf_token, "Failed to get csrf token from login page"
-    csrf_token_str = csrf_token.group(1)
-    assert csrf_token_str, "Failed to get csrf token from login page"
-    # login with form data
+    url = f"http://{DOCKER_COMPOSE_HOST_PORT}/auth/token";
     login_response = session.post(
-        f"http://{DOCKER_COMPOSE_HOST_PORT}/auth/login";,
-        data={
+        url,
+        json={
             "username": AIRFLOW_WWW_USER_USERNAME,
             "password": AIRFLOW_WWW_USER_PASSWORD,
-            "csrf_token": csrf_token_str,
         },
     )
-    redirect_url = login_response.url
-    # ensure redirect_url is a string
-    redirect_url_str = str(redirect_url) if redirect_url is not None else ""
-    assert "/?token" in redirect_url_str, f"Login failed with redirect url 
{redirect_url_str}"
-    parsed_url = urlparse(redirect_url_str)
-    query_params = parse_qs(str(parsed_url.query))
-    jwt_token_list = query_params.get("token")
-    jwt_token = jwt_token_list[0] if jwt_token_list else None
-    assert jwt_token, f"Failed to get JWT token from redirect url 
{redirect_url_str}"
+    jwt_token = login_response.json().get("jwt_token")
+
+    assert jwt_token, f"Failed to get JWT token from redirect url {url} with 
status code {login_response}"
     return jwt_token
 
 
diff --git a/docs/apache-airflow/core-concepts/auth-manager/index.rst 
b/docs/apache-airflow/core-concepts/auth-manager/index.rst
index dc973f1d5e7..170a819985f 100644
--- a/docs/apache-airflow/core-concepts/auth-manager/index.rst
+++ b/docs/apache-airflow/core-concepts/auth-manager/index.rst
@@ -92,13 +92,30 @@ Some reasons you may want to write a custom auth manager 
include:
 * You'd like to use an auth manager that leverages an identity provider from 
your preferred cloud provider.
 * You have a private user management tool that is only available to you or 
your organization.
 
-
 Authentication related BaseAuthManager methods
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 * ``get_user``: Return the signed-in user.
 * ``get_url_login``: Return the URL the user is redirected to for signing in.
 
+JWT token management by auth managers
+-------------------------------------
+The auth manager is responsible of creating the JWT token and pass it to 
Airflow UI. The protocol to exchange the JWT
+token between the auth manager and Airflow UI is using cookies. The auth 
manager needs to save the JWT token in a
+cookie named ``_token`` before redirecting to the Airflow UI. The Airflow UI 
will then read the cookie, save it and
+delete the cookie.
+
+.. code-block:: python
+
+    from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
+
+    response = RedirectResponse(url="/")
+    response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=True)
+    return response
+
+.. note::
+    Do not set the cookie parameter ``httponly`` to ``True``. Airflow UI needs 
to access the JWT token from the cookie.
+
 Authorization related BaseAuthManager methods
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/kubernetes_tests/test_base.py b/kubernetes_tests/test_base.py
index 8cad42de296..03e361f0eaa 100644
--- a/kubernetes_tests/test_base.py
+++ b/kubernetes_tests/test_base.py
@@ -25,7 +25,6 @@ import time
 from datetime import datetime, timezone
 from pathlib import Path
 from subprocess import check_call, check_output
-from urllib.parse import parse_qs, urlparse
 
 import pytest
 import requests
@@ -145,13 +144,9 @@ class BaseK8STest:
         Note: API server is still using FAB Auth Manager.
 
         Steps:
-        1. Get the login page to get the csrf token
-            - The csrf token is in the hidden input field with id "csrf_token"
-        2. Login with the username and password
+        1. Login with the username and password
             - Must use the same session to keep the csrf token session
-        3. Extract the JWT token from the redirect url
-            - Expected to have a connection error
-            - The redirect url should have the JWT token as a query parameter
+        2. Extract the JWT token from the auth/token url
 
         :param session: The session to use for the request
         :param username: The username to use for the login
@@ -163,28 +158,14 @@ class BaseK8STest:
         session = requests.Session()
         session.mount("http://";, HTTPAdapter(max_retries=retry))
         session.mount("https://";, HTTPAdapter(max_retries=retry))
-        get_login_form_response = 
session.get(f"http://{KUBERNETES_HOST_PORT}/auth/login";)
-        csrf_token = re.search(
-            r'<input id="csrf_token" name="csrf_token" type="hidden" 
value="(.+?)">',
-            get_login_form_response.text,
-        )
-        assert csrf_token, "Failed to get csrf token from login page"
-        csrf_token_str = csrf_token.group(1)
-        assert csrf_token_str, "Failed to get csrf token from login page"
-        # login with form data
+        url = f"http://{KUBERNETES_HOST_PORT}/auth/token";
         login_response = session.post(
-            f"http://{KUBERNETES_HOST_PORT}/auth/login";,
-            data={"username": username, "password": password, "csrf_token": 
csrf_token_str},
+            url,
+            json={"username": username, "password": password},
         )
-        redirect_url = login_response.url
-        # ensure redirect_url is a string
-        redirect_url_str = str(redirect_url) if redirect_url is not None else 
""
-        assert "/?token" in redirect_url_str, f"Login failed with redirect url 
{redirect_url_str}"
-        parsed_url = urlparse(redirect_url_str)
-        query_params = parse_qs(str(parsed_url.query))
-        jwt_token_list = query_params.get("token")
-        jwt_token = jwt_token_list[0] if jwt_token_list else None
-        assert jwt_token, f"Failed to get JWT token from redirect url 
{redirect_url_str}"
+        jwt_token = login_response.json().get("jwt_token")
+
+        assert jwt_token, f"Failed to get JWT token from redirect url {url} 
with status code {login_response}"
         return jwt_token
 
     def _get_session_with_retries(self):
diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py
 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py
index 293ed79b4a0..d00ef2e7feb 100644
--- 
a/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py
+++ 
b/providers/amazon/src/airflow/providers/amazon/aws/auth_manager/router/login.py
@@ -19,7 +19,6 @@ from __future__ import annotations
 
 import logging
 from typing import Any
-from urllib.parse import urljoin
 
 import anyio
 from fastapi import HTTPException, Request
@@ -27,6 +26,7 @@ from starlette import status
 from starlette.responses import RedirectResponse
 
 from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX, 
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.configuration import conf
 from airflow.providers.amazon.aws.auth_manager.constants import 
CONF_SAML_METADATA_URL_KEY, CONF_SECTION_NAME
@@ -80,8 +80,11 @@ def login_callback(request: Request):
         username=saml_auth.get_nameid(),
         email=attributes["email"][0] if "email" in attributes else None,
     )
-    url = urljoin(conf.get("api", "base_url"), 
f"?token={get_auth_manager().generate_jwt(user)}")
-    return RedirectResponse(url=url, status_code=303)
+    url = conf.get("api", "base_url")
+    token = get_auth_manager().generate_jwt(user)
+    response = RedirectResponse(url=url, status_code=303)
+    response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=True)
+    return response
 
 
 def _init_saml_auth(request: Request) -> OneLogin_Saml2_Auth:
diff --git 
a/providers/amazon/tests/unit/amazon/aws/auth_manager/router/test_login.py 
b/providers/amazon/tests/unit/amazon/aws/auth_manager/router/test_login.py
index af763444cae..07c61e32f67 100644
--- a/providers/amazon/tests/unit/amazon/aws/auth_manager/router/test_login.py
+++ b/providers/amazon/tests/unit/amazon/aws/auth_manager/router/test_login.py
@@ -119,7 +119,8 @@ class TestLoginRouter:
                 )
                 assert response.status_code == 303
                 assert "location" in response.headers
-                assert 
response.headers["location"].startswith("http://localhost:8080/?token=";)
+                assert "_token" in response.cookies
+                assert 
response.headers["location"].startswith("http://localhost:8080/";)
 
     def test_login_callback_unsuccessful(self):
         with conf_vars(
diff --git a/providers/fab/src/airflow/providers/fab/www/views.py 
b/providers/fab/src/airflow/providers/fab/www/views.py
index e2e63c0eec2..ccc9488ed52 100644
--- a/providers/fab/src/airflow/providers/fab/www/views.py
+++ b/providers/fab/src/airflow/providers/fab/www/views.py
@@ -21,6 +21,7 @@ from urllib.parse import unquote, urljoin, urlsplit
 
 from flask import (
     g,
+    make_response,
     redirect,
     render_template,
     request,
@@ -29,6 +30,7 @@ from flask import (
 from flask_appbuilder import IndexView, expose
 
 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.configuration import conf
 
 # Following the release of https://github.com/python/cpython/issues/102153 in 
Python 3.9.17 on
@@ -66,7 +68,10 @@ class FabIndexView(IndexView):
     def index(self):
         if g.user is not None and g.user.is_authenticated:
             token = get_auth_manager().generate_jwt(g.user)
-            return redirect(urljoin(conf.get("api", "base_url"), 
f"?token={token}"), code=302)
+            response = make_response(redirect(f"{conf.get('api', 
'base_url')}", code=302))
+            response.set_cookie(COOKIE_NAME_JWT_TOKEN, token, secure=True)
+
+            return response
         else:
             return redirect(conf.get("api", "base_url"), code=302)
 
diff --git a/tests/api_fastapi/auth/managers/simple/routes/test_login.py 
b/tests/api_fastapi/auth/managers/simple/routes/test_login.py
index fdde46df07e..70a39814f2d 100644
--- a/tests/api_fastapi/auth/managers/simple/routes/test_login.py
+++ b/tests/api_fastapi/auth/managers/simple/routes/test_login.py
@@ -62,7 +62,7 @@ class TestLogin:
             response = test_client.get("/auth/token", follow_redirects=False)
             assert response.status_code == 307
             assert "location" in response.headers
-            assert 
response.headers["location"].startswith("http://localhost:8080/?token=";)
+            assert response.cookies.get("_token") is not None
 
     def test_create_token_all_admins_config_disabled(self, test_client):
         response = test_client.get("/auth/token")

Reply via email to