diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/openapi/v2-simple-auth-manager-generated.yaml b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/openapi/v2-simple-auth-manager-generated.yaml index 31d3ebdc19d94..d8ffda58d8d52 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/openapi/v2-simple-auth-manager-generated.yaml +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/openapi/v2-simple-auth-manager-generated.yaml @@ -7,38 +7,25 @@ info: version: 0.1.0 paths: /auth/token: - get: - tags: - - SimpleAuthManagerLogin - summary: Create Token All Admins - description: Create a token with no credentials only if ``simple_auth_manager_all_admins`` - is True. - operationId: create_token_all_admins - responses: - '201': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/LoginResponse' - '403': - description: Forbidden - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPExceptionResponse' post: tags: - SimpleAuthManagerLogin summary: Create Token description: Authenticate the user. operationId: create_token - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/LoginBody' - required: true + parameters: + - name: Content-Type + in: header + required: false + schema: + type: string + description: Content-Type of the request body + enum: + - application/json + - application/x-www-form-urlencoded + default: application/json + title: Content-Type + description: Content-Type of the request body responses: '201': description: Successful Response @@ -47,23 +34,58 @@ paths: schema: $ref: '#/components/schemas/LoginResponse' '400': - description: Bad Request content: application/json: schema: $ref: '#/components/schemas/HTTPExceptionResponse' + description: Bad Request '401': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' description: Unauthorized + '415': content: application/json: schema: $ref: '#/components/schemas/HTTPExceptionResponse' + description: Unsupported Media Type '422': description: Validation Error content: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginBody' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LoginBody' + get: + tags: + - SimpleAuthManagerLogin + summary: Create Token All Admins + description: Create a token with no credentials only if ``simple_auth_manager_all_admins`` + is True. + operationId: create_token_all_admins + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + description: Forbidden /auth/token/login: get: tags: diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py index 82875ceb1239c..f46674301ead1 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/routes/login.py @@ -17,13 +17,15 @@ from __future__ import annotations -from fastapi import Request, status +from fastapi import Depends, Request, status from starlette.responses import RedirectResponse 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.utils import parse_login_body from airflow.api_fastapi.common.router import AirflowRouter +from airflow.api_fastapi.common.types import Mimetype from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc from airflow.configuration import conf @@ -33,10 +35,33 @@ @login_router.post( "/token", status_code=status.HTTP_201_CREATED, - responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]), + responses={ + **create_openapi_http_exception_doc( + [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_401_UNAUTHORIZED, + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + ] + ), + 201: { + "description": "Successful Response", + "content": { + Mimetype.JSON: {"schema": {"$ref": "#/components/schemas/LoginResponse"}}, + }, + }, + }, + openapi_extra={ + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/LoginBody"}}, + "application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/LoginBody"}}, + }, + } + }, ) def create_token( - body: LoginBody, + body: LoginBody = Depends(parse_login_body), ) -> LoginResponse: """Authenticate the user.""" return LoginResponse(access_token=SimpleAuthManagerLogin.create_token(body=body)) diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/queries/queries.ts b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/queries/queries.ts index 09d1142c9a1f7..409795e6945c4 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/queries/queries.ts +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/queries/queries.ts @@ -7,10 +7,12 @@ import * as Common from "./common"; export const useSimpleAuthManagerLoginServiceCreateTokenAllAdmins = = unknown[]>(queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseSimpleAuthManagerLoginServiceCreateTokenAllAdminsKeyFn(queryKey), queryFn: () => SimpleAuthManagerLoginService.createTokenAllAdmins() as TData, ...options }); export const useSimpleAuthManagerLoginServiceLoginAllAdmins = = unknown[]>(queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseSimpleAuthManagerLoginServiceLoginAllAdminsKeyFn(queryKey), queryFn: () => SimpleAuthManagerLoginService.loginAllAdmins() as TData, ...options }); export const useSimpleAuthManagerLoginServiceCreateToken = (options?: Omit, "mutationFn">) => useMutation({ mutationFn: ({ requestBody }) => SimpleAuthManagerLoginService.createToken({ requestBody }) as unknown as Promise, ...options }); +}, TContext>({ mutationFn: ({ contentType, requestBody }) => SimpleAuthManagerLoginService.createToken({ contentType, requestBody }) as unknown as Promise, ...options }); export const useSimpleAuthManagerLoginServiceCreateTokenCli = (options?: Omit, "mutationFn">) => useMutation { - return __request(OpenAPI, { - method: "GET", - url: "/auth/token", - errors: { - 403: "Forbidden", - }, - }); - } - /** * Create Token * Authenticate the user. * @param data The data for the request. * @param data.requestBody + * @param data.contentType Content-Type of the request body * @returns LoginResponse Successful Response * @throws ApiError */ @@ -39,16 +24,36 @@ export class SimpleAuthManagerLoginService { return __request(OpenAPI, { method: "POST", url: "/auth/token", + headers: { + "Content-Type": data.contentType, + }, body: data.requestBody, mediaType: "application/json", errors: { 400: "Bad Request", 401: "Unauthorized", + 415: "Unsupported Media Type", 422: "Validation Error", }, }); } + /** + * Create Token All Admins + * Create a token with no credentials only if ``simple_auth_manager_all_admins`` is True. + * @returns LoginResponse Successful Response + * @throws ApiError + */ + public static createTokenAllAdmins(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/auth/token", + errors: { + 403: "Forbidden", + }, + }); + } + /** * Login All Admins * Login the user with no credentials. diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/requests/types.gen.ts index 058c1e08b1626..b0e3d92afe481 100644 --- a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/ui/openapi-gen/requests/types.gen.ts @@ -36,14 +36,18 @@ export type ValidationError = { type: string; }; -export type CreateTokenAllAdminsResponse = LoginResponse; - export type CreateTokenData = { + /** + * Content-Type of the request body + */ + contentType?: "application/json" | "application/x-www-form-urlencoded"; requestBody: LoginBody; }; export type CreateTokenResponse = LoginResponse; +export type CreateTokenAllAdminsResponse = LoginResponse; + export type CreateTokenCliData = { requestBody: LoginBody; }; @@ -52,18 +56,6 @@ export type CreateTokenCliResponse = LoginResponse; export type $OpenApiTs = { "/auth/token": { - get: { - res: { - /** - * Successful Response - */ - 201: LoginResponse; - /** - * Forbidden - */ - 403: HTTPExceptionResponse; - }; - }; post: { req: CreateTokenData; res: { @@ -79,12 +71,28 @@ export type $OpenApiTs = { * Unauthorized */ 401: HTTPExceptionResponse; + /** + * Unsupported Media Type + */ + 415: HTTPExceptionResponse; /** * Validation Error */ 422: HTTPValidationError; }; }; + get: { + res: { + /** + * Successful Response + */ + 201: LoginResponse; + /** + * Forbidden + */ + 403: HTTPExceptionResponse; + }; + }; }; "/auth/token/login": { get: { diff --git a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/utils.py b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/utils.py new file mode 100644 index 0000000000000..bd2767dc46ffe --- /dev/null +++ b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/utils.py @@ -0,0 +1,48 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +from fastapi import HTTPException, Request, status +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError + +from airflow.api_fastapi.auth.managers.simple.datamodels.login import LoginBody +from airflow.api_fastapi.common.headers import HeaderContentTypeJsonOrForm +from airflow.api_fastapi.common.types import Mimetype + + +async def parse_login_body( + request: Request, + content_type: HeaderContentTypeJsonOrForm, +) -> LoginBody: + try: + if content_type == Mimetype.JSON: + body = await request.json() + elif content_type == Mimetype.FORM: + form = await request.form() + body = { + "username": form.get("username"), + "password": form.get("password"), + } + else: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Unsupported Media Type", + ) + return LoginBody(**body) + except ValidationError as e: + raise RequestValidationError(repr(e)) diff --git a/airflow-core/src/airflow/api_fastapi/common/headers.py b/airflow-core/src/airflow/api_fastapi/common/headers.py index 13567e32bdc7d..6bcdfbf67c7dd 100644 --- a/airflow-core/src/airflow/api_fastapi/common/headers.py +++ b/airflow-core/src/airflow/api_fastapi/common/headers.py @@ -74,3 +74,26 @@ def header_accept_json_or_ndjson_depends( HeaderAcceptJsonOrNdjson = Annotated[Mimetype, Depends(header_accept_json_or_ndjson_depends)] + + +def header_content_type_json_or_form_depends( + content_type: Annotated[ + str, + Header( + alias="Content-Type", + description="Content-Type of the request body", + json_schema_extra={"enum": [Mimetype.JSON, Mimetype.FORM]}, + ), + ] = Mimetype.JSON, +) -> Mimetype: + if content_type.startswith(Mimetype.JSON): + return Mimetype.JSON + if content_type.startswith(Mimetype.FORM): + return Mimetype.FORM + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Only application/json or application/x-www-form-urlencoded is supported", + ) + + +HeaderContentTypeJsonOrForm = Annotated[Mimetype, Depends(header_content_type_json_or_form_depends)] diff --git a/airflow-core/src/airflow/api_fastapi/common/types.py b/airflow-core/src/airflow/api_fastapi/common/types.py index ccbb9d9ad98c7..7e965d5f99f21 100644 --- a/airflow-core/src/airflow/api_fastapi/common/types.py +++ b/airflow-core/src/airflow/api_fastapi/common/types.py @@ -72,6 +72,7 @@ class Mimetype(str, Enum): TEXT = "text/plain" JSON = "application/json" + FORM = "application/x-www-form-urlencoded" NDJSON = "application/x-ndjson" ANY = "*/*" diff --git a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/routes/test_login.py b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/routes/test_login.py index 1ca2ea4c65273..8729b483b4a74 100644 --- a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/routes/test_login.py +++ b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/routes/test_login.py @@ -25,7 +25,12 @@ class TestLogin: @patch("airflow.api_fastapi.auth.managers.simple.routes.login.SimpleAuthManagerLogin") - def test_create_token(self, mock_simple_auth_manager_login, test_client, auth_manager): + def test_create_token( + self, + mock_simple_auth_manager_login, + test_client, + auth_manager, + ): mock_simple_auth_manager_login.create_token.return_value = "DUMMY_TOKEN" response = test_client.post( @@ -33,7 +38,29 @@ def test_create_token(self, mock_simple_auth_manager_login, test_client, auth_ma json={"username": "test1", "password": "DUMMY_PASS"}, ) assert response.status_code == 201 - assert response.json()["access_token"] + assert "access_token" in response.json() + + @patch("airflow.api_fastapi.auth.managers.simple.routes.login.SimpleAuthManagerLogin") + def test_create_token_with_form_data( + self, + mock_simple_auth_manager_login, + test_client, + auth_manager, + test_user, + ): + mock_simple_auth_manager_login.create_token.return_value = "DUMMY_TOKEN" + + response = test_client.post( + "/auth/token", + data={ + "username": "test1", + "password": "DUMMY_PASS", + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + assert response.status_code == 201 + assert "access_token" in response.json() def test_create_token_invalid_user_password(self, test_client): response = test_client.post(