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

turaga 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 653b883dee1 Add self-service JWT token generation to the Airflow UI 
(#63195)
653b883dee1 is described below

commit 653b883dee14eae25dd64feb81a544336dcc6a1e
Author: Dheeraj Turaga <[email protected]>
AuthorDate: Wed Mar 11 00:33:49 2026 -0500

    Add self-service JWT token generation to the Airflow UI (#63195)
    
    * Add self-service JWT token generation to the Airflow UI
    
      Implement a full-stack feature enabling authenticated users to generate
      JWT tokens (API and CLI) directly from the Airflow web interface. This
      eliminates the need to re-authenticate or use credential-based endpoints,
      streamlining API access and airflowctl usage for all Airflow deployments.
    
    * Xiaodong's feedback
    
    * Fix static checks
    
    * fix more static checks
    
    * Jasons suggestions
---
 .../api_fastapi/core_api/datamodels/ui/auth.py     |  23 ++++
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  61 +++++++++
 .../airflow/api_fastapi/core_api/routes/ui/auth.py |  38 ++++++
 .../src/airflow/ui/openapi-gen/queries/common.ts   |   1 +
 .../src/airflow/ui/openapi-gen/queries/queries.ts  |  15 ++-
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  39 ++++++
 .../ui/openapi-gen/requests/services.gen.ts        |  22 +++-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  42 +++++++
 .../airflow/ui/public/i18n/locales/en/common.json  |  13 ++
 .../ui/src/layouts/Nav/TokenGenerationModal.tsx    | 137 +++++++++++++++++++++
 .../ui/src/layouts/Nav/UserSettingsButton.tsx      |   8 ++
 .../api_fastapi/core_api/routes/ui/test_auth.py    |  38 ++++++
 12 files changed, 435 insertions(+), 2 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
index 8cc6c4648a2..f5563fbd6c7 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/auth.py
@@ -17,6 +17,8 @@
 
 from __future__ import annotations
 
+from enum import Enum
+
 from airflow.api_fastapi.common.types import ExtraMenuItem, MenuItem
 from airflow.api_fastapi.core_api.base import BaseModel
 
@@ -33,3 +35,24 @@ class AuthenticatedMeResponse(BaseModel):
 
     id: str
     username: str
+
+
+class TokenType(str, Enum):
+    """Type of token to generate."""
+
+    API = "api"
+    CLI = "cli"
+
+
+class GenerateTokenBody(BaseModel):
+    """Request body for generating a token."""
+
+    token_type: TokenType = TokenType.API
+
+
+class GenerateTokenResponse(BaseModel):
+    """Response for a generated token."""
+
+    access_token: str
+    token_type: TokenType
+    expires_in_seconds: int
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 96550601a0d..6c38b7ce2d6 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -40,6 +40,35 @@ paths:
       security:
       - OAuth2PasswordBearer: []
       - HTTPBearer: []
+  /ui/auth/token:
+    post:
+      tags:
+      - Auth Links
+      summary: Generate Token
+      description: Generate a JWT token for the authenticated user.
+      operationId: generate_token
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/GenerateTokenBody'
+        required: true
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenerateTokenResponse'
+        '422':
+          description: Validation Error
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPValidationError'
+      security:
+      - OAuth2PasswordBearer: []
+      - HTTPBearer: []
   /ui/next_run_assets/{dag_id}:
     get:
       tags:
@@ -2238,6 +2267,31 @@ components:
       - end_date
       title: GanttTaskInstance
       description: Task instance data for Gantt chart.
+    GenerateTokenBody:
+      properties:
+        token_type:
+          $ref: '#/components/schemas/TokenType'
+          default: api
+      type: object
+      title: GenerateTokenBody
+      description: Request body for generating a token.
+    GenerateTokenResponse:
+      properties:
+        access_token:
+          type: string
+          title: Access Token
+        token_type:
+          $ref: '#/components/schemas/TokenType'
+        expires_in_seconds:
+          type: integer
+          title: Expires In Seconds
+      type: object
+      required:
+      - access_token
+      - token_type
+      - expires_in_seconds
+      title: GenerateTokenResponse
+      description: Response for a generated token.
     GridNodeResponse:
       properties:
         id:
@@ -3257,6 +3311,13 @@ components:
       - tokens
       title: Theme
       description: JSON to modify Chakra's theme.
+    TokenType:
+      type: string
+      enum:
+      - api
+      - cli
+      title: TokenType
+      description: Type of token to generate.
     TriggerResponse:
       properties:
         id:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
index 7927f810d49..c7d889fca52 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/auth.py
@@ -17,13 +17,24 @@
 
 from __future__ import annotations
 
+import logging
+
+from fastapi import Depends
+
 from airflow.api_fastapi.app import get_auth_manager
 from airflow.api_fastapi.common.router import AirflowRouter
 from airflow.api_fastapi.core_api.datamodels.ui.auth import (
     AuthenticatedMeResponse,
+    GenerateTokenBody,
+    GenerateTokenResponse,
     MenuItemCollectionResponse,
+    TokenType,
 )
 from airflow.api_fastapi.core_api.security import GetUserDep
+from airflow.api_fastapi.logging.decorators import action_logging
+from airflow.configuration import conf
+
+log = logging.getLogger(__name__)
 
 auth_router = AirflowRouter(tags=["Auth Links"])
 
@@ -50,3 +61,30 @@ def get_current_user_info(
         id=user.get_id(),
         username=user.get_name(),
     )
+
+
+@auth_router.post("/auth/token", dependencies=[Depends(action_logging())])
+def generate_token(
+    body: GenerateTokenBody,
+    user: GetUserDep,
+) -> GenerateTokenResponse:
+    """Generate a JWT token for the authenticated user."""
+    if body.token_type == TokenType.CLI:
+        expiration_seconds = conf.getint("api_auth", "jwt_cli_expiration_time")
+    else:
+        expiration_seconds = conf.getint("api_auth", "jwt_expiration_time")
+
+    access_token = get_auth_manager().generate_jwt(user, 
expiration_time_in_seconds=expiration_seconds)
+
+    log.info(
+        "User %s generated a %s token (expires in %d seconds)",
+        user.get_name(),
+        body.token_type.value,
+        expiration_seconds,
+    )
+
+    return GenerateTokenResponse(
+        access_token=access_token,
+        token_type=body.token_type,
+        expires_in_seconds=expiration_seconds,
+    )
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 4f32c3773cf..3e09d977b29 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -929,6 +929,7 @@ export type 
TaskInstanceServicePostClearTaskInstancesMutationResult = Awaited<Re
 export type PoolServicePostPoolMutationResult = Awaited<ReturnType<typeof 
PoolService.postPool>>;
 export type XcomServiceCreateXcomEntryMutationResult = 
Awaited<ReturnType<typeof XcomService.createXcomEntry>>;
 export type VariableServicePostVariableMutationResult = 
Awaited<ReturnType<typeof VariableService.postVariable>>;
+export type AuthLinksServiceGenerateTokenMutationResult = 
Awaited<ReturnType<typeof AuthLinksService.generateToken>>;
 export type BackfillServicePauseBackfillMutationResult = 
Awaited<ReturnType<typeof BackfillService.pauseBackfill>>;
 export type BackfillServiceUnpauseBackfillMutationResult = 
Awaited<ReturnType<typeof BackfillService.unpauseBackfill>>;
 export type BackfillServiceCancelBackfillMutationResult = 
Awaited<ReturnType<typeof BackfillService.cancelBackfill>>;
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 fd4924351c4..99a22c6f323 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2,7 +2,7 @@
 
 import { UseMutationOptions, UseQueryOptions, useMutation, useQuery } from 
"@tanstack/react-query";
 import { AssetService, AuthLinksService, BackfillService, CalendarService, 
ConfigService, ConnectionService, DagParsingService, DagRunService, DagService, 
DagSourceService, DagStatsService, DagVersionService, DagWarningService, 
DashboardService, DeadlinesService, DependenciesService, EventLogService, 
ExperimentalService, ExtraLinksService, GanttService, GridService, 
ImportErrorService, JobService, LoginService, MonitorService, 
PartitionedDagRunService, PluginService, PoolService, Provide [...]
-import { BackfillPostBody, BulkBody_BulkTaskInstanceBody_, 
BulkBody_ConnectionBody_, BulkBody_PoolBody_, BulkBody_VariableBody_, 
ClearTaskInstancesBody, ConnectionBody, CreateAssetEventsBody, DAGPatchBody, 
DAGRunClearBody, DAGRunPatchBody, DAGRunsBatchBody, DagRunState, 
DagWarningType, PatchTaskInstanceBody, PoolBody, PoolPatchBody, 
TaskInstancesBatchBody, TriggerDAGRunPostBody, UpdateHITLDetailPayload, 
VariableBody, XComCreateBody, XComUpdateBody } from "../requests/types.gen";
+import { BackfillPostBody, BulkBody_BulkTaskInstanceBody_, 
BulkBody_ConnectionBody_, BulkBody_PoolBody_, BulkBody_VariableBody_, 
ClearTaskInstancesBody, ConnectionBody, CreateAssetEventsBody, DAGPatchBody, 
DAGRunClearBody, DAGRunPatchBody, DAGRunsBatchBody, DagRunState, 
DagWarningType, GenerateTokenBody, PatchTaskInstanceBody, PoolBody, 
PoolPatchBody, TaskInstancesBatchBody, TriggerDAGRunPostBody, 
UpdateHITLDetailPayload, VariableBody, XComCreateBody, XComUpdateBody } from 
"../requests/t [...]
 import * as Common from "./common";
 /**
 * Get Assets
@@ -1988,6 +1988,19 @@ export const useVariableServicePostVariable = <TData = 
Common.VariableServicePos
   requestBody: VariableBody;
 }, TContext>({ mutationFn: ({ requestBody }) => VariableService.postVariable({ 
requestBody }) as unknown as Promise<TData>, ...options });
 /**
+* Generate Token
+* Generate a JWT token for the authenticated user.
+* @param data The data for the request.
+* @param data.requestBody
+* @returns GenerateTokenResponse Successful Response
+* @throws ApiError
+*/
+export const useAuthLinksServiceGenerateToken = <TData = 
Common.AuthLinksServiceGenerateTokenMutationResult, TError = unknown, TContext 
= unknown>(options?: Omit<UseMutationOptions<TData, TError, {
+  requestBody: GenerateTokenBody;
+}, TContext>, "mutationFn">) => useMutation<TData, TError, {
+  requestBody: GenerateTokenBody;
+}, TContext>({ mutationFn: ({ requestBody }) => 
AuthLinksService.generateToken({ requestBody }) as unknown as Promise<TData>, 
...options });
+/**
 * Pause Backfill
 * @param data The data for the request.
 * @param data.backfillId
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 61dfb3c3406..6c540964655 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -8087,6 +8087,38 @@ export const $GanttTaskInstance = {
     description: 'Task instance data for Gantt chart.'
 } as const;
 
+export const $GenerateTokenBody = {
+    properties: {
+        token_type: {
+            '$ref': '#/components/schemas/TokenType',
+            default: 'api'
+        }
+    },
+    type: 'object',
+    title: 'GenerateTokenBody',
+    description: 'Request body for generating a token.'
+} as const;
+
+export const $GenerateTokenResponse = {
+    properties: {
+        access_token: {
+            type: 'string',
+            title: 'Access Token'
+        },
+        token_type: {
+            '$ref': '#/components/schemas/TokenType'
+        },
+        expires_in_seconds: {
+            type: 'integer',
+            title: 'Expires In Seconds'
+        }
+    },
+    type: 'object',
+    required: ['access_token', 'token_type', 'expires_in_seconds'],
+    title: 'GenerateTokenResponse',
+    description: 'Response for a generated token.'
+} as const;
+
 export const $GridNodeResponse = {
     properties: {
         id: {
@@ -8951,6 +8983,13 @@ export const $Theme = {
     description: "JSON to modify Chakra's theme."
 } as const;
 
+export const $TokenType = {
+    type: 'string',
+    enum: ['api', 'cli'],
+    title: 'TokenType',
+    description: 'Type of token to generate.'
+} as const;
+
 export const $UIAlert = {
     properties: {
         text: {
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 6318b4678f3..976a14533cd 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 {
     /**
@@ -3847,6 +3847,26 @@ export class AuthLinksService {
         });
     }
     
+    /**
+     * Generate Token
+     * Generate a JWT token for the authenticated user.
+     * @param data The data for the request.
+     * @param data.requestBody
+     * @returns GenerateTokenResponse Successful Response
+     * @throws ApiError
+     */
+    public static generateToken(data: GenerateTokenData): 
CancelablePromise<GenerateTokenResponse2> {
+        return __request(OpenAPI, {
+            method: 'POST',
+            url: '/ui/auth/token',
+            body: data.requestBody,
+            mediaType: 'application/json',
+            errors: {
+                422: 'Validation Error'
+            }
+        });
+    }
+    
 }
 
 export class PartitionedDagRunService {
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 aaa5674dca1..6794c8ff4fa 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
@@ -1989,6 +1989,22 @@ export type GanttTaskInstance = {
     is_mapped?: boolean;
 };
 
+/**
+ * Request body for generating a token.
+ */
+export type GenerateTokenBody = {
+    token_type?: TokenType;
+};
+
+/**
+ * Response for a generated token.
+ */
+export type GenerateTokenResponse = {
+    access_token: string;
+    token_type: TokenType;
+    expires_in_seconds: number;
+};
+
 /**
  * Base Node serializer for responses.
  */
@@ -2211,6 +2227,11 @@ export type Theme = {
     icon_dark_mode?: string | null;
 };
 
+/**
+ * Type of token to generate.
+ */
+export type TokenType = 'api' | 'cli';
+
 /**
  * Optional alert to be shown at the top of the page.
  */
@@ -3532,6 +3553,12 @@ export type GetAuthMenusResponse = 
MenuItemCollectionResponse;
 
 export type GetCurrentUserInfoResponse = AuthenticatedMeResponse;
 
+export type GenerateTokenData = {
+    requestBody: GenerateTokenBody;
+};
+
+export type GenerateTokenResponse2 = GenerateTokenResponse;
+
 export type GetPartitionedDagRunsData = {
     dagId?: string | null;
     hasCreatedDagRunId?: boolean | null;
@@ -6737,6 +6764,21 @@ export type $OpenApiTs = {
             };
         };
     };
+    '/ui/auth/token': {
+        post: {
+            req: GenerateTokenData;
+            res: {
+                /**
+                 * Successful Response
+                 */
+                200: GenerateTokenResponse;
+                /**
+                 * Validation Error
+                 */
+                422: HTTPValidationError;
+            };
+        };
+    };
     '/ui/partitioned_dag_runs': {
         get: {
             req: GetPartitionedDagRunsData;
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index ba586990e60..745193fd071 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -128,6 +128,7 @@
     "selectDateRange": "Select Date Range",
     "startTime": "Start Time"
   },
+  "generateToken": "Generate Token",
   "logicalDate": "Logical Date",
   "logout": "Logout",
   "logoutConfirmation": "You are about to logout from the application.",
@@ -327,6 +328,18 @@
       }
     }
   },
+  "tokenGeneration": {
+    "apiToken": "API Token",
+    "cliToken": "CLI Token",
+    "errorDescription": "An error occurred while generating the token. Please 
try again.",
+    "errorTitle": "Token Generation Failed",
+    "generate": "Generate",
+    "selectType": "Select the type of token to generate.",
+    "title": "Generate Token",
+    "tokenExpiresIn": "This token expires in {{duration}}.",
+    "tokenGenerated": "Your token has been generated.",
+    "tokenShownOnce": "This token will only be shown once. Copy it now."
+  },
   "total": "Total {{state}}",
   "triggered": "Triggered",
   "tryNumber": "Try Number",
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Nav/TokenGenerationModal.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/TokenGenerationModal.tsx
new file mode 100644
index 00000000000..796c6e2d0e2
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/TokenGenerationModal.tsx
@@ -0,0 +1,137 @@
+/*!
+ * 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.
+ */
+import { Box, Button, Flex, HStack, Text } from "@chakra-ui/react";
+import React, { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiAlertTriangle } from "react-icons/fi";
+
+import { useAuthLinksServiceGenerateToken } from "openapi/queries";
+import type { GenerateTokenResponse } from "openapi/requests/types.gen";
+import { Dialog, toaster } from "src/components/ui";
+import { ClipboardIconButton, ClipboardInput, ClipboardRoot } from 
"src/components/ui/Clipboard";
+
+type TokenGenerationModalProps = {
+  readonly isOpen: boolean;
+  readonly onClose: () => void;
+};
+
+type TokenType = "api" | "cli";
+
+const formatExpiration = (seconds: number): string => {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+
+  if (hours > 0 && minutes > 0) {
+    return `${String(hours)}h ${String(minutes)}m`;
+  } else if (hours > 0) {
+    return `${String(hours)}h`;
+  }
+
+  return `${String(minutes)}m`;
+};
+
+const TokenGenerationModal: React.FC<TokenGenerationModalProps> = ({ isOpen, 
onClose }) => {
+  const { t: translate } = useTranslation();
+  const [tokenType, setTokenType] = useState<TokenType>("api");
+  const [generatedToken, setGeneratedToken] = useState<string | null>(null);
+  const [expiresIn, setExpiresIn] = useState<number | null>(null);
+
+  const { isPending, mutate: generateToken } = 
useAuthLinksServiceGenerateToken({
+    onError: (error: unknown) => {
+      toaster.create({
+        description: error instanceof Error ? error.message : 
translate("tokenGeneration.errorDescription"),
+        title: translate("tokenGeneration.errorTitle"),
+        type: "error",
+      });
+    },
+    onSuccess: (data: GenerateTokenResponse) => {
+      setGeneratedToken(data.access_token);
+      setExpiresIn(data.expires_in_seconds);
+    },
+  });
+
+  const handleClose = useCallback(() => {
+    setGeneratedToken(null);
+    setExpiresIn(null);
+    setTokenType("api");
+    onClose();
+  }, [onClose]);
+
+  const handleGenerate = useCallback(() => {
+    generateToken({ requestBody: { token_type: tokenType } });
+  }, [generateToken, tokenType]);
+
+  return (
+    <Dialog.Root lazyMount onOpenChange={handleClose} open={isOpen} size="xl">
+      <Dialog.Content backdrop>
+        <Dialog.Header>{translate("tokenGeneration.title")}</Dialog.Header>
+        <Dialog.CloseTrigger />
+        <Dialog.Body>
+          {generatedToken !== null && generatedToken !== "" ? (
+            <Box>
+              <Text fontWeight="semibold" mb={2}>
+                {translate("tokenGeneration.tokenGenerated")}
+              </Text>
+              <ClipboardRoot value={generatedToken}>
+                <Flex alignItems="center" gap={2}>
+                  <ClipboardInput readOnly />
+                  <ClipboardIconButton />
+                </Flex>
+              </ClipboardRoot>
+              <HStack color="orange.500" gap={2} mt={3}>
+                <FiAlertTriangle />
+                <Text 
fontSize="sm">{translate("tokenGeneration.tokenShownOnce")}</Text>
+              </HStack>
+              {expiresIn !== null && expiresIn > 0 ? (
+                <Text color="fg.muted" fontSize="sm" mt={2}>
+                  {translate("tokenGeneration.tokenExpiresIn", {
+                    duration: formatExpiration(expiresIn),
+                  })}
+                </Text>
+              ) : undefined}
+            </Box>
+          ) : (
+            <Box>
+              <Text mb={3}>{translate("tokenGeneration.selectType")}</Text>
+              <HStack gap={3} mb={4}>
+                <Button
+                  onClick={() => setTokenType("api")}
+                  variant={tokenType === "api" ? "solid" : "outline"}
+                >
+                  {translate("tokenGeneration.apiToken")}
+                </Button>
+                <Button
+                  onClick={() => setTokenType("cli")}
+                  variant={tokenType === "cli" ? "solid" : "outline"}
+                >
+                  {translate("tokenGeneration.cliToken")}
+                </Button>
+              </HStack>
+              <Button loading={isPending} onClick={handleGenerate} 
width="full">
+                {translate("tokenGeneration.generate")}
+              </Button>
+            </Box>
+          )}
+        </Dialog.Body>
+      </Dialog.Content>
+    </Dialog.Root>
+  );
+};
+
+export default TokenGenerationModal;
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
index 2ad032457e7..ace6165ff45 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
@@ -20,6 +20,7 @@ import { Box, Icon, useDisclosure } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import {
   FiGrid,
+  FiKey,
   FiLogOut,
   FiMoon,
   FiSun,
@@ -43,6 +44,7 @@ import LanguageModal from "./LanguageModal";
 import LogoutModal from "./LogoutModal";
 import { NavButton } from "./NavButton";
 import { PluginMenuItem } from "./PluginMenuItem";
+import TokenGenerationModal from "./TokenGenerationModal";
 
 const COLOR_MODES = {
   DARK: "dark",
@@ -77,6 +79,7 @@ export const UserSettingsButton = ({ externalViews }: { 
readonly externalViews:
 
   const { onClose: onCloseLogout, onOpen: onOpenLogout, open: isOpenLogout } = 
useDisclosure();
   const { onClose: onCloseLanguage, onOpen: onOpenLanguage, open: 
isOpenLanguage } = useDisclosure();
+  const { onClose: onCloseToken, onOpen: onOpenToken, open: isOpenToken } = 
useDisclosure();
 
   const [dagView, setDagView] = useLocalStorage<"graph" | 
"grid">(DEFAULT_DAG_VIEW_KEY, "grid");
 
@@ -138,6 +141,10 @@ export const UserSettingsButton = ({ externalViews }: { 
readonly externalViews:
               {dagView === "grid" ? translate("defaultToGraphView") : 
translate("defaultToGridView")}
             </Box>
           </Menu.Item>
+          <Menu.Item onClick={onOpenToken} value="generateToken">
+            <Icon as={FiKey} boxSize={4} />
+            <Box flex="1">{translate("generateToken")}</Box>
+          </Menu.Item>
           {externalViews.map((view) => (
             <PluginMenuItem {...view} key={view.name} />
           ))}
@@ -150,6 +157,7 @@ export const UserSettingsButton = ({ externalViews }: { 
readonly externalViews:
       </Menu.Root>
       <LanguageModal isOpen={isOpenLanguage} onClose={onCloseLanguage} />
       <LogoutModal isOpen={isOpenLogout} onClose={onCloseLogout} />
+      <TokenGenerationModal isOpen={isOpenToken} onClose={onCloseToken} />
     </>
   );
 };
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
index 98a46ea08a6..287f2f9318b 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_auth.py
@@ -76,3 +76,41 @@ class TestGetMeResponse:
         response = unauthenticated_test_client.get("/auth/me")
         assert response.status_code == 401
         assert response.json() == {"detail": "Not authenticated"}
+
+
+class TestGenerateToken:
+    def test_generate_api_token(self, test_client):
+        """Test generating an API token returns correct response shape."""
+        response = test_client.post("/auth/token", json={"token_type": "api"})
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "access_token" in data
+        assert data["token_type"] == "api"
+        assert data["expires_in_seconds"] == 86400  # default 
jwt_expiration_time
+
+    def test_generate_cli_token(self, test_client):
+        """Test generating a CLI token uses jwt_cli_expiration_time config."""
+        response = test_client.post("/auth/token", json={"token_type": "cli"})
+
+        assert response.status_code == 200
+        data = response.json()
+        assert "access_token" in data
+        assert data["token_type"] == "cli"
+        # cli expiration comes from jwt_cli_expiration_time config
+        assert isinstance(data["expires_in_seconds"], int)
+        assert data["expires_in_seconds"] > 0
+
+    def test_default_token_type_is_api(self, test_client):
+        """Test that the default token type is API when not specified."""
+        response = test_client.post("/auth/token", json={})
+
+        assert response.status_code == 200
+        data = response.json()
+        assert data["token_type"] == "api"
+
+    def test_unauthenticated_request(self, unauthenticated_test_client):
+        """Test that unauthenticated requests are rejected."""
+        response = unauthenticated_test_client.post("/auth/token", 
json={"token_type": "api"})
+        assert response.status_code == 401
+        assert response.json() == {"detail": "Not authenticated"}

Reply via email to