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"}