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

vincbeck 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 d5ca289f414 Add CLI login discovery to auth managers (#75672)
d5ca289f414 is described below

commit d5ca289f41493b1cb5e64c7e0e0b742bb9719a79
Author: Baptiste Gouhoury <[email protected]>
AuthorDate: Wed Oct 28 21:58:41 2026 +0100

    Add CLI login discovery to auth managers (#75672)
    
    airflowctl cannot tell which login flows a server supports before it
    tries one, so an unsupported flow surfaces as a 404 or, for browser
    logins, a silent timeout. Auth managers can now advertise the flows
    they offer to command-line clients, using RFC 8414 field names, so
    clients can choose a supported flow or fail with a clear message.
    
    The flows must not depend on a specific auth manager, so the contract
    each advertised grant follows is documented in core. Any auth manager
    can then offer CLI login without changes to airflowctl.
    
    Signed-off-by: Baptiste Gouhoury <[email protected]>
---
 .../docs/core-concepts/auth-manager/index.rst      | 25 ++++++++++
 airflow-core/docs/security/api_permissions_ref.rst |  4 ++
 .../api_fastapi/auth/managers/base_auth_manager.py | 11 ++++-
 .../src/airflow/api_fastapi/common/types.py        | 21 +++++++-
 .../api_fastapi/core_api/datamodels/auth.py        | 29 +++++++++++
 .../core_api/openapi/v2-rest-api-generated.yaml    | 52 ++++++++++++++++++++
 .../api_fastapi/core_api/routes/public/auth.py     | 14 ++++++
 .../src/airflow/ui/openapi-gen/queries/common.ts   |  4 ++
 .../ui/openapi-gen/queries/ensureQueryData.ts      |  7 +++
 .../src/airflow/ui/openapi-gen/queries/prefetch.ts |  7 +++
 .../src/airflow/ui/openapi-gen/queries/queries.ts  |  7 +++
 .../src/airflow/ui/openapi-gen/queries/suspense.ts |  7 +++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 56 ++++++++++++++++++++++
 .../ui/openapi-gen/requests/services.gen.ts        | 15 +++++-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   | 23 +++++++++
 .../auth/managers/test_base_auth_manager.py        |  5 +-
 .../core_api/routes/public/test_auth.py            | 47 +++++++++++++++++-
 .../api_fastapi/core_api/routes/test_routes.py     |  1 +
 .../src/airflowctl/api/datamodels/generated.py     | 12 +++++
 providers/fab/docs/auth-manager/access-control.rst |  4 ++
 20 files changed, 346 insertions(+), 5 deletions(-)

diff --git a/airflow-core/docs/core-concepts/auth-manager/index.rst 
b/airflow-core/docs/core-concepts/auth-manager/index.rst
index 7431d84c796..f853cd38fa0 100644
--- a/airflow-core/docs/core-concepts/auth-manager/index.rst
+++ b/airflow-core/docs/core-concepts/auth-manager/index.rst
@@ -106,11 +106,36 @@ Authentication related methods
 * ``get_url_login``: Return the URL the user is redirected to for signing in.
 * ``get_url_logout``: Return the URL the user is redirected to when logging 
out. This is an optional method,
   this redirection is usually needed to invalidate resources when logging out, 
such as a session.
+* ``get_cli_login_methods``: Return the login methods offered to command-line 
clients such as ``airflowctl``.
+  This is an optional method; by default, no method is advertised. See 
:ref:`auth-manager-cli-login`.
 * ``serialize_user``: Serialize a user instance to a dict. This dict is the 
actual content of the JWT token.
   It should contain all the information needed to identify the user and make 
an authorization request.
 * ``deserialize_user``: Create a user instance from a dict. The dict is the 
payload of the JWT token.
   This is the same dict returned by ``serialize_user``.
 
+.. _auth-manager-cli-login:
+
+Login from command-line clients
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Before logging in, command-line clients such as ``airflowctl`` read ``GET 
/api/v2/auth/cli/metadata``, an
+unauthenticated route that returns ``get_cli_login_methods``. It uses RFC 8414 
field names, except that endpoints
+are absolute paths, resolved against the API server URL. They include the path 
of ``[api] base_url``, if any.
+An empty ``grant_types_supported`` means no CLI login is offered.
+
+Each advertised grant must follow the contract below, so that clients work 
with any auth manager. A successful call
+to ``token_endpoint`` returns ``201`` with ``{"access_token": ...}``, valid 
for ``[api_auth] jwt_cli_expiration_time``.
+
+* ``password``: the client sends ``username`` and ``password``. This is the 
default when ``grant_type`` is omitted.
+* ``authorization_code``: the browser opens ``authorization_endpoint`` with 
``redirect_uri`` (``http`` loopback
+  only), ``state`` and a required ``S256`` ``code_challenge``. The client then 
sends ``code``, ``code_verifier`` and
+  ``redirect_uri``.
+* ``urn:ietf:params:oauth:grant-type:device_code``: 
``device_authorization_endpoint`` returns the RFC 8628 fields.
+  The client polls with ``device_code``, and gets ``400`` with 
``authorization_pending`` or ``slow_down`` until the
+  user approves.
+* ``refresh_token``: the client sends the current CLI token as ``token``. 
Expired or revoked tokens are refused,
+  and the new token never expires later than the old one.
+
 Authorization related methods
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/airflow-core/docs/security/api_permissions_ref.rst 
b/airflow-core/docs/security/api_permissions_ref.rst
index c4997864f34..1723b8ffd72 100644
--- a/airflow-core/docs/security/api_permissions_ref.rst
+++ b/airflow-core/docs/security/api_permissions_ref.rst
@@ -110,6 +110,10 @@ source code so it stays up to date as endpoints are added 
or changed.
      - ``/api/v2/assets/{asset_id}/state-store/{key}``
      - ``Asset``
      - ``PUT``
+   * - ``GET``
+     - ``/api/v2/auth/cli/metadata``
+     - ``Public``
+     - ``No Airflow permission required``
    * - ``GET``
      - ``/api/v2/auth/login``
      - ``Public``
diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py 
b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
index 8b2302393a0..05743a58688 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
@@ -46,7 +46,7 @@ from airflow.api_fastapi.auth.tokens import (
     get_sig_validation_args,
     get_signing_args,
 )
-from airflow.api_fastapi.common.types import ExtraMenuItem, MenuItem
+from airflow.api_fastapi.common.types import CliLoginMethods, ExtraMenuItem, 
MenuItem
 from airflow.configuration import conf
 from airflow.exceptions import RemovedInAirflow4Warning
 from airflow.models import Connection, DagModel, Pool, Variable
@@ -237,6 +237,15 @@ class BaseAuthManager(Generic[T], LoggingMixin, 
metaclass=ABCMeta):
         """
         return None
 
+    def get_cli_login_methods(self) -> CliLoginMethods:
+        """
+        Return the login methods offered to command-line clients such as 
airflowctl.
+
+        Clients read them before logging in, so they can tell which flows the 
server supports.
+        By default, no method is advertised.
+        """
+        return CliLoginMethods()
+
     def refresh_user(self, *, user: T) -> T | None:
         """
         Refresh the user if needed.
diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py 
b/airflow-core/src/airflow/api_fastapi/common/types.py
index 251a15071b4..38d0c68f90a 100644
--- a/airflow-core/src/airflow/api_fastapi/common/types.py
+++ b/airflow-core/src/airflow/api_fastapi/common/types.py
@@ -17,7 +17,7 @@
 from __future__ import annotations
 
 import re
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from datetime import timedelta
 from enum import Enum
 from typing import Annotated, Literal
@@ -111,6 +111,25 @@ class ExtraMenuItem:
     href: str
 
 
+@dataclass
+class CliLoginMethods:
+    """
+    Login methods an auth manager offers to command-line clients such as 
airflowctl.
+
+    Field names follow the OAuth 2.0 Authorization Server Metadata (RFC 8414). 
An empty
+    ``grant_types_supported`` means the auth manager offers no login method to 
these clients.
+    Unlike RFC 8414, endpoints are absolute paths, such as 
``/auth/token/cli``, that clients
+    resolve against the API server URL, because the auth manager may not know 
the public URL
+    clients use to reach the API server. They include the path of ``[api] 
base_url``, if any.
+    """
+
+    grant_types_supported: list[str] = field(default_factory=list)
+    code_challenge_methods_supported: list[str] = field(default_factory=list)
+    authorization_endpoint: str | None = None
+    device_authorization_endpoint: str | None = None
+    token_endpoint: str | None = None
+
+
 class MenuItem(Enum):
     """Define all menu items defined in the menu."""
 
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/auth.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/auth.py
new file mode 100644
index 00000000000..f1f6276ed1b
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/auth.py
@@ -0,0 +1,29 @@
+# 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 airflow.api_fastapi.core_api.base import BaseModel
+
+
+class CliLoginMetadataResponse(BaseModel):
+    """Login methods the auth manager offers to command-line clients, named as 
in RFC 8414."""
+
+    grant_types_supported: list[str]
+    code_challenge_methods_supported: list[str]
+    authorization_endpoint: str | None
+    device_authorization_endpoint: str | None
+    token_endpoint: str | None
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 7e99a405b68..ac426aa2ab2 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -12426,6 +12426,21 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
+  /api/v2/auth/cli/metadata:
+    get:
+      tags:
+      - Login
+      summary: Get Cli Login Metadata
+      description: Return the login methods the AuthManager configured offers 
to command-line
+        clients.
+      operationId: get_cli_login_metadata
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/CliLoginMetadataResponse'
   /api/v2/auth/logout:
     get:
       tags:
@@ -14261,6 +14276,43 @@ components:
       type: object
       title: ClearTaskInstancesBody
       description: Request body for Clear Task Instances endpoint.
+    CliLoginMetadataResponse:
+      properties:
+        grant_types_supported:
+          items:
+            type: string
+          type: array
+          title: Grant Types Supported
+        code_challenge_methods_supported:
+          items:
+            type: string
+          type: array
+          title: Code Challenge Methods Supported
+        authorization_endpoint:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Authorization Endpoint
+        device_authorization_endpoint:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Device Authorization Endpoint
+        token_endpoint:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Token Endpoint
+      type: object
+      required:
+      - grant_types_supported
+      - code_challenge_methods_supported
+      - authorization_endpoint
+      - device_authorization_endpoint
+      - token_endpoint
+      title: CliLoginMetadataResponse
+      description: Login methods the auth manager offers to command-line 
clients,
+        named as in RFC 8414.
     Config:
       properties:
         sections:
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
index 486ccb7c451..42b8ba10301 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/auth.py
@@ -26,6 +26,7 @@ from fastapi.security import HTTPAuthorizationCredentials
 from airflow.api_fastapi.app import get_cookie_path
 from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
 from airflow.api_fastapi.common.router import AirflowRouter
+from airflow.api_fastapi.core_api.datamodels.auth import 
CliLoginMetadataResponse
 from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
 from airflow.api_fastapi.core_api.security import (
     AuthManagerDep,
@@ -60,6 +61,19 @@ def login(request: Request, auth_manager: AuthManagerDep, 
next: None | str = Non
     return RedirectResponse(login_url)
 
 
+@auth_router.get("/cli/metadata")
+def get_cli_login_metadata(auth_manager: AuthManagerDep) -> 
CliLoginMetadataResponse:
+    """Return the login methods the AuthManager configured offers to 
command-line clients."""
+    methods = auth_manager.get_cli_login_methods()
+    return CliLoginMetadataResponse(
+        grant_types_supported=methods.grant_types_supported,
+        
code_challenge_methods_supported=methods.code_challenge_methods_supported,
+        authorization_endpoint=methods.authorization_endpoint,
+        device_authorization_endpoint=methods.device_authorization_endpoint,
+        token_endpoint=methods.token_endpoint,
+    )
+
+
 @auth_router.get(
     "/logout",
     
responses=create_openapi_http_exception_doc([status.HTTP_307_TEMPORARY_REDIRECT]),
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 5170bea5453..6af9fa923c4 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1089,6 +1089,10 @@ export const useLoginServiceLoginKey = 
"LoginServiceLogin";
 export const UseLoginServiceLoginKeyFn = ({ next }: {
   next?: string;
 } = {}, queryKey?: Array<unknown>) => [useLoginServiceLoginKey, ...(queryKey 
?? [{ next }])];
+export type LoginServiceGetCliLoginMetadataDefaultResponse = 
Awaited<ReturnType<typeof LoginService.getCliLoginMetadata>>;
+export type LoginServiceGetCliLoginMetadataQueryResult<TData = 
LoginServiceGetCliLoginMetadataDefaultResponse, TError = unknown> = 
UseQueryResult<TData, TError>;
+export const useLoginServiceGetCliLoginMetadataKey = 
"LoginServiceGetCliLoginMetadata";
+export const UseLoginServiceGetCliLoginMetadataKeyFn = (queryKey?: 
Array<unknown>) => [useLoginServiceGetCliLoginMetadataKey, ...(queryKey ?? [])];
 export type LoginServiceLogoutDefaultResponse = Awaited<ReturnType<typeof 
LoginService.logout>>;
 export type LoginServiceLogoutQueryResult<TData = 
LoginServiceLogoutDefaultResponse, TError = unknown> = UseQueryResult<TData, 
TError>;
 export const useLoginServiceLogoutKey = "LoginServiceLogout";
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index 1179186d336..15003eedc51 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -2163,6 +2163,13 @@ export const ensureUseLoginServiceLoginData = 
(queryClient: QueryClient, { next
   next?: string;
 } = {}) => queryClient.ensureQueryData({ queryKey: 
Common.UseLoginServiceLoginKeyFn({ next }), queryFn: () => LoginService.login({ 
next }) });
 /**
+* Get Cli Login Metadata
+* Return the login methods the AuthManager configured offers to command-line 
clients.
+* @returns CliLoginMetadataResponse Successful Response
+* @throws ApiError
+*/
+export const ensureUseLoginServiceGetCliLoginMetadataData = (queryClient: 
QueryClient) => queryClient.ensureQueryData({ queryKey: 
Common.UseLoginServiceGetCliLoginMetadataKeyFn(), queryFn: () => 
LoginService.getCliLoginMetadata() });
+/**
 * Logout
 * Logout the user.
 * @returns unknown Successful Response
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index 2f7eac20576..489ea7df9b0 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -2163,6 +2163,13 @@ export const prefetchUseLoginServiceLogin = 
(queryClient: QueryClient, { next }:
   next?: string;
 } = {}) => queryClient.prefetchQuery({ queryKey: 
Common.UseLoginServiceLoginKeyFn({ next }), queryFn: () => LoginService.login({ 
next }) });
 /**
+* Get Cli Login Metadata
+* Return the login methods the AuthManager configured offers to command-line 
clients.
+* @returns CliLoginMetadataResponse Successful Response
+* @throws ApiError
+*/
+export const prefetchUseLoginServiceGetCliLoginMetadata = (queryClient: 
QueryClient) => queryClient.prefetchQuery({ queryKey: 
Common.UseLoginServiceGetCliLoginMetadataKeyFn(), queryFn: () => 
LoginService.getCliLoginMetadata() });
+/**
 * Logout
 * Logout the user.
 * @returns unknown Successful Response
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 048d540f134..e3a0c653bd0 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2163,6 +2163,13 @@ export const useLoginServiceLogin = <TData = 
Common.LoginServiceLoginDefaultResp
   next?: string;
 } = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey: 
Common.UseLoginServiceLoginKeyFn({ next }, queryKey), queryFn: () => 
LoginService.login({ next }) as TData, ...options });
 /**
+* Get Cli Login Metadata
+* Return the login methods the AuthManager configured offers to command-line 
clients.
+* @returns CliLoginMetadataResponse Successful Response
+* @throws ApiError
+*/
+export const useLoginServiceGetCliLoginMetadata = <TData = 
Common.LoginServiceGetCliLoginMetadataDefaultResponse, TError = unknown, 
TQueryKey extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?: 
Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => 
useQuery<TData, TError>({ queryKey: 
Common.UseLoginServiceGetCliLoginMetadataKeyFn(queryKey), queryFn: () => 
LoginService.getCliLoginMetadata() as TData, ...options });
+/**
 * Logout
 * Logout the user.
 * @returns unknown Successful Response
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts 
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 71efee38492..368cf359d2a 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -2163,6 +2163,13 @@ export const useLoginServiceLoginSuspense = <TData = 
Common.LoginServiceLoginDef
   next?: string;
 } = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>, 
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseLoginServiceLoginKeyFn({ next }, queryKey), queryFn: () => 
LoginService.login({ next }) as TData, ...options });
 /**
+* Get Cli Login Metadata
+* Return the login methods the AuthManager configured offers to command-line 
clients.
+* @returns CliLoginMetadataResponse Successful Response
+* @throws ApiError
+*/
+export const useLoginServiceGetCliLoginMetadataSuspense = <TData = 
Common.LoginServiceGetCliLoginMetadataDefaultResponse, TError = unknown, 
TQueryKey extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?: 
Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) => 
useSuspenseQuery<TData, TError>({ queryKey: 
Common.UseLoginServiceGetCliLoginMetadataKeyFn(queryKey), queryFn: () => 
LoginService.getCliLoginMetadata() as TData, ...options });
+/**
 * Logout
 * Logout the user.
 * @returns unknown Successful Response
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 47cb8ea9bdf..60ac0c48430 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
@@ -2577,6 +2577,62 @@ export const $ClearTaskInstancesBody = {
     description: 'Request body for Clear Task Instances endpoint.'
 } as const;
 
+export const $CliLoginMetadataResponse = {
+    properties: {
+        grant_types_supported: {
+            items: {
+                type: 'string'
+            },
+            type: 'array',
+            title: 'Grant Types Supported'
+        },
+        code_challenge_methods_supported: {
+            items: {
+                type: 'string'
+            },
+            type: 'array',
+            title: 'Code Challenge Methods Supported'
+        },
+        authorization_endpoint: {
+            anyOf: [
+                {
+                    type: 'string'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Authorization Endpoint'
+        },
+        device_authorization_endpoint: {
+            anyOf: [
+                {
+                    type: 'string'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Device Authorization Endpoint'
+        },
+        token_endpoint: {
+            anyOf: [
+                {
+                    type: 'string'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Token Endpoint'
+        }
+    },
+    type: 'object',
+    required: ['grant_types_supported', 'code_challenge_methods_supported', 
'authorization_endpoint', 'device_authorization_endpoint', 'token_endpoint'],
+    title: 'CliLoginMetadataResponse',
+    description: 'Login methods the auth manager offers to command-line 
clients, named as in RFC 8414.'
+} as const;
+
 export const $Config = {
     properties: {
         sections: {
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 ee4130f8e15..79964c5104c 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 {
     /**
@@ -5305,6 +5305,19 @@ export class LoginService {
         });
     }
     
+    /**
+     * Get Cli Login Metadata
+     * Return the login methods the AuthManager configured offers to 
command-line clients.
+     * @returns CliLoginMetadataResponse Successful Response
+     * @throws ApiError
+     */
+    public static getCliLoginMetadata(): 
CancelablePromise<GetCliLoginMetadataResponse> {
+        return __request(OpenAPI, {
+            method: 'GET',
+            url: '/api/v2/auth/cli/metadata'
+        });
+    }
+    
     /**
      * Logout
      * Logout the user.
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 fe19ce55c37..eea8bebd212 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
@@ -800,6 +800,17 @@ export type ClearTaskInstancesBody = {
     note?: string | null;
 };
 
+/**
+ * Login methods the auth manager offers to command-line clients, named as in 
RFC 8414.
+ */
+export type CliLoginMetadataResponse = {
+    grant_types_supported: Array<(string)>;
+    code_challenge_methods_supported: Array<(string)>;
+    authorization_endpoint: string | null;
+    device_authorization_endpoint: string | null;
+    token_endpoint: string | null;
+};
+
 /**
  * List of config sections with their options.
  */
@@ -5637,6 +5648,8 @@ export type LoginData = {
 
 export type LoginResponse = unknown;
 
+export type GetCliLoginMetadataResponse = CliLoginMetadataResponse;
+
 export type LogoutResponse = unknown;
 
 export type GetAuthMenusResponse = MenuItemCollectionResponse;
@@ -9949,6 +9962,16 @@ export type $OpenApiTs = {
             };
         };
     };
+    '/api/v2/auth/cli/metadata': {
+        get: {
+            res: {
+                /**
+                 * Successful Response
+                 */
+                200: CliLoginMetadataResponse;
+            };
+        };
+    };
     '/api/v2/auth/logout': {
         get: {
             res: {
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py 
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
index e433c1223da..f0de3a0b4e5 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
@@ -38,7 +38,7 @@ from 
airflow.api_fastapi.auth.managers.models.resource_details import (
 )
 from airflow.api_fastapi.auth.middlewares.refresh_token import 
JWTRefreshMiddleware
 from airflow.api_fastapi.auth.tokens import JWTGenerator, JWTValidator
-from airflow.api_fastapi.common.types import MenuItem
+from airflow.api_fastapi.common.types import CliLoginMethods, MenuItem
 from airflow.api_fastapi.core_api.app import _STATIC_URL_PATH
 from airflow.exceptions import RemovedInAirflow4Warning
 from airflow.models.pool import Pool
@@ -274,6 +274,9 @@ class TestBaseAuthManager:
     def test_get_url_logout_return_none(self, auth_manager):
         assert auth_manager.get_url_logout() is None
 
+    def test_get_cli_login_methods_return_empty(self, auth_manager):
+        assert auth_manager.get_cli_login_methods() == CliLoginMethods()
+
     def test_get_extra_menu_items_return_empty_list(self, auth_manager):
         assert 
auth_manager.get_extra_menu_items(user=BaseAuthManagerUserTest(name="test")) == 
[]
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
index 6fd0143388f..3b9732ff476 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_auth.py
@@ -24,7 +24,8 @@ import jwt
 import pytest
 from fastapi.testclient import TestClient
 
-from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
+from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN, BaseAuthManager
+from airflow.api_fastapi.common.types import CliLoginMethods
 from airflow.models.revoked_token import RevokedToken
 
 from tests_common.test_utils.config import conf_vars
@@ -98,6 +99,50 @@ class TestGetLogin(TestAuthEndpoint):
         assert response.status_code == 400
 
 
+class TestGetCliLoginMetadata:
+    @pytest.mark.parametrize(
+        ("methods", "expected"),
+        [
+            pytest.param(
+                CliLoginMethods(),
+                {
+                    "grant_types_supported": [],
+                    "code_challenge_methods_supported": [],
+                    "authorization_endpoint": None,
+                    "device_authorization_endpoint": None,
+                    "token_endpoint": None,
+                },
+                id="no-method",
+            ),
+            pytest.param(
+                CliLoginMethods(
+                    grant_types_supported=["password", "authorization_code"],
+                    code_challenge_methods_supported=["S256"],
+                    
authorization_endpoint="http://localhost:8080/auth/login/cli";,
+                    token_endpoint="http://localhost:8080/auth/token/cli";,
+                ),
+                {
+                    "grant_types_supported": ["password", 
"authorization_code"],
+                    "code_challenge_methods_supported": ["S256"],
+                    "authorization_endpoint": 
"http://localhost:8080/auth/login/cli";,
+                    "device_authorization_endpoint": None,
+                    "token_endpoint": "http://localhost:8080/auth/token/cli";,
+                },
+                id="authorization-code",
+            ),
+        ],
+    )
+    def test_should_respond_200_without_authentication(self, 
unauthenticated_test_client, methods, expected):
+        auth_manager_mock = MagicMock(spec=BaseAuthManager)
+        auth_manager_mock.get_cli_login_methods.return_value = methods
+        unauthenticated_test_client.app.state.auth_manager = auth_manager_mock
+
+        response = unauthenticated_test_client.get("/auth/cli/metadata")
+
+        assert response.status_code == 200
+        assert response.json() == expected
+
+
 class TestLogout(TestAuthEndpoint):
     @pytest.fixture(autouse=True)
     def cleanup_revoked_tokens(self):
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py
index 89d4ed29bae..f8767fff0b3 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/test_routes.py
@@ -27,6 +27,7 @@ from tests_common.test_utils.api_fastapi import 
iter_api_routes
 NO_AUTH_PATHS = {
     "/api/v2/auth/login",
     "/api/v2/auth/logout",
+    "/api/v2/auth/cli/metadata",
     "/api/v2/version",
     "/api/v2/monitor/health",
 }
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index fb299db1a10..77590a0ceb3 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -346,6 +346,18 @@ class ClearTaskInstancesBody(BaseModel):
     note: Annotated[Note | None, Field(title="Note")] = None
 
 
+class CliLoginMetadataResponse(BaseModel):
+    """
+    Login methods the auth manager offers to command-line clients, named as in 
RFC 8414.
+    """
+
+    grant_types_supported: Annotated[list[str], Field(title="Grant Types 
Supported")]
+    code_challenge_methods_supported: Annotated[list[str], Field(title="Code 
Challenge Methods Supported")]
+    authorization_endpoint: Annotated[str | None, Field(title="Authorization 
Endpoint")]
+    device_authorization_endpoint: Annotated[str | None, Field(title="Device 
Authorization Endpoint")]
+    token_endpoint: Annotated[str | None, Field(title="Token Endpoint")]
+
+
 class Value(RootModel[tuple[str, str]]):
     root: Annotated[tuple[str, str], Field(max_length=2, min_length=2, 
title="Value")]
 
diff --git a/providers/fab/docs/auth-manager/access-control.rst 
b/providers/fab/docs/auth-manager/access-control.rst
index 58554ae78af..0d203cdaffe 100644
--- a/providers/fab/docs/auth-manager/access-control.rst
+++ b/providers/fab/docs/auth-manager/access-control.rst
@@ -284,6 +284,10 @@ Stable API Permissions
      - PUT
      - Assets.can_edit
      - Op
+   * - ``/api/v2/auth/cli/metadata``
+     - GET
+     - None
+     - Public
    * - ``/api/v2/auth/login``
      - GET
      - None

Reply via email to