Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

get_swagger_ui_html does not pass X-API-Key to openapi_url for authorized access #2678

Closed
7 tasks done
dazza-codes opened this issue Jan 20, 2021 · 3 comments
Closed
7 tasks done

Comments

@dazza-codes
Copy link

dazza-codes commented Jan 20, 2021

First check

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar issue and didn't find it.
  • I searched the FastAPI documentation, with the integrated search.
  • I already searched in Google "How to X in FastAPI" and didn't find any information.
  • I already read and followed all the tutorial in the docs and didn't find an answer.
  • I already checked if it is not related to FastAPI but to Pydantic.
  • [-] I already checked if it is not related to FastAPI but to Swagger UI.
  • I already checked if it is not related to FastAPI but to ReDoc.
  • [-] After submitting this, I commit to one of:
    • Read open issues with questions until I find 2 issues where I can help someone and add a comment to help there.
    • I already hit the "watch" button in this repository to receive notifications and I commit to help at least 2 people that ask questions in the future.
    • Implement a Pull Request for a confirmed bug.

Example

See https://medium.com/data-rebels/fastapi-authentication-revisited-enabling-api-key-authentication-122dc5975680 but the use of cookies to hold an API Key circumvents the behavior of the swagger-UI so that all the authorized endpoints are working without using the swagger authenticate entry. If the cookie is not set (as below) and the api-key auth only allows an X-API-Key header, then the swagger-UI fails to get the openapi.json document; an error appears like "Failed to load API definition. Fetch Error. Unauthorized /openapi.json".

It seems like there should be a way to call get_swagger_ui_html so that any swagger JS can use an X-API-Key to authenticate when it tries to fetch the /openapi.json document. Or, is there a way to define /docs so that the swagger-ui is provided the app.openapi() content without needing to call a /openapi.json endpoint that is protected by an X-API-Key? (Additional context - a deployment uses an AWS API-Gateway with X-API-Key auth enabled on the API-Gateway with a use policy attached to the API keys - the API-Gateway seems to be tricky to configure to allow unrestricted access to /openapi.json when everything else requires an API key or some other auth, even if the fastapi itself might allow unrestricted access to /openapi.json.). Is it considered safe/secure to allow unrestricted access to /openapi.json?

See the medium post link above but, below, the cookie for an api key is disabled.

    
    # assume that get_api_key() checks X-API-Key header and returns key (str) when valid
    # see medium post linked above for example

    @app.get("/openapi.json", include_in_schema=False, tags=["documentation"])
    async def get_open_api_endpoint(api_key: str = Depends(get_api_key)):
        # note how this endpoint requires authentication with an X-API-Key
        response = JSONResponse(app.openapi())
        return response

    @app.get("/docs", include_in_schema=False, tags=["documentation"])
    async def get_documentation(api_key: str = Depends(get_api_key)):
        response = get_swagger_ui_html(openapi_url="/openapi.json", title="docs")

        # Note that when cookie is set, it works, but it also circumvents the swagger auth,
        # but when the cookie is not set, the swagger-ui cannot get the openapi.json doc;
        # how can `get_swagger_ui_html` pass an X-API-Key to the /openapi.json endpoint?

        # Can the `response` here be modified so this works with API keys?  The following
        # addition to the response headers is _not_ the way to go for this, it exposes the API
        # in the response to /docs and the swagger-js does not pass it along anyways.
        response.headers["X-API-Key"] = api_key

        # While this cookie approach works, it circumvents the swagger-ui control over auth;
        # any 'Try it out' will use this cookie and work, without setting any swagger-ui auth.
        # response.set_cookie(
        #     API_KEY_NAME,
        #     value=api_key,
        #     # domain=COOKIE_DOMAIN,
        #     httponly=True,
        #     max_age=1800,
        #     expires=1800,
        # )
        return response

Description

  • Open the browser and call the endpoint /docs.
  • It returns an error with failure to fetch /openapi.json
  • But I expected it to return the swagger-ui with the app openapi.json etc

Request

Would it be possible to use

# tell swagger about using the api_key for authenticated access to `/openapi.json`
response = get_swagger_ui_html(openapi_url="/openapi.json", title="docs", api_key=api_key)

The function signature does not yet include such an option, i.e.

def get_swagger_ui_html(
    *,
    openapi_url: str,
    title: str,
    swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js",
    swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css",
    swagger_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
    oauth2_redirect_url: Optional[str] = None,
    init_oauth: Optional[Dict[str, Any]] = None,
) -> HTMLResponse:
    pass ... etc etc

The X-API-Key auth option is not covered by any init_oauth options, is it? It should not require any OAuth flows.

I'm not so familiar with swagger JS, is it possible to use it with an X-API-Key?

Are there fatal security flaws in attempting to use swagger JS in this way? e.g. it could embed the API key in the JS payload somewhere, basically exposing it in the HTML/JS response payload.

When /docs returns, it is possible to force it to open a modal dialog that would present the swagger-ui auth dialog that requires an API-key auth? (Assuming that the swagger JS can even get the /openapi.json doc)

Environment

  • FastAPI Version: 0.63.0
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.5 LTS"

$ python --version
Python 3.7.9
@dazza-codes dazza-codes added the question Question or problem label Jan 20, 2021
@dazza-codes
Copy link
Author

dazza-codes commented Jan 20, 2021

Relevant SO item at

If I've read those correctly, the proposed solution is a requestInterceptor, e.g.

// The spec URL
const url = "https://www.example.com/authorised-users-only/spec.json";

SwaggerUI({
  url,  // spec url
  requestInterceptor: (req) => {

    // Only set Authorization header if the request matches the spec URL
    if (req.url === url) {
      req.headers.Authorization = "Basic " + btoa("myUser" + ":" + "myPassword");
    }

    return req;
  }
})

For my question, the header details would be an X-API-Key header vs. an Authorization header. I'm just not sure what the consequences are for putting an API-key value into the swagger-ui JS - assuming it can only get there because a user provides it in the first place, it's only as secure as their browser session or something.

For comparison, the get_swagger_ui_html function in fastAPI returns something like the following:

<body>
	<div id="swagger-ui">
	</div>
	<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
	<!-- `SwaggerUIBundle` is now available on the page -->
	<script>
		const ui = SwaggerUIBundle({
        url: '/openapi.json',
    
        dom_id: '#swagger-ui',
        presets: [
        SwaggerUIBundle.presets.apis,
        SwaggerUIBundle.SwaggerUIStandalonePreset
        ],
        layout: "BaseLayout",
        deepLinking: true,
        showExtensions: true,
        showCommonExtensions: true
    })
	</script>
</body>

@dazza-codes
Copy link
Author

dazza-codes commented Jan 20, 2021

Using a copy-paste and hack-it, the following custom function adds support for a request interceptor that adds an X-API-Key header in order to get the /openapi.json document from an endpoint that is protected by an API key (without further interference with the swagger-ui after that).

The key addition is the api_key parameter and

if api_key:
        req = '    requestInterceptor: (req) => { if (req.url.endsWith("openapi.json")) { req.headers["X-API-Key"] = "%s"; }; return req; },' % api_key
        html += req

The full function:

import json
from typing import Any
from typing import Dict
from typing import Optional

from fastapi.encoders import jsonable_encoder
from starlette.responses import HTMLResponse


def custom_swagger_ui_html(
    *,
    openapi_url: str,
    title: str,
    swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js",
    swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css",
    swagger_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png",
    oauth2_redirect_url: Optional[str] = None,
    init_oauth: Optional[Dict[str, Any]] = None,
    api_key: Optional[str] = None,
) -> HTMLResponse:

    # Adapted from fastapi.openapi.docs.get_swagger_ui_html
    # Related to https://github.com/tiangolo/fastapi/issues/2678
    # Related to https://github.com/swagger-api/swagger-ui/issues/2793

    html = f"""
    <!DOCTYPE html>
    <html>
    <head>
    <link type="text/css" rel="stylesheet" href="{swagger_css_url}">
    <link rel="shortcut icon" href="{swagger_favicon_url}">
    <title>{title}</title>
    </head>
    <body>
    <div id="swagger-ui">
    </div>
    <script src="{swagger_js_url}"></script>
    <!-- `SwaggerUIBundle` is now available on the page -->
    <script>
    const ui = SwaggerUIBundle({{
        url: '{openapi_url}',
    """

    if api_key:
        req = '    requestInterceptor: (req) => { if (req.url.endsWith("openapi.json")) { req.headers["X-API-Key"] = "%s"; }; return req; },' % api_key
        html += req

    if oauth2_redirect_url:
        html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"

    html += """
        dom_id: '#swagger-ui',
        presets: [
        SwaggerUIBundle.presets.apis,
        SwaggerUIBundle.SwaggerUIStandalonePreset
        ],
        layout: "BaseLayout",
        deepLinking: true,
        showExtensions: true,
        showCommonExtensions: true
    })"""

    if init_oauth:
        html += f"""
        ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})
        """

    html += """
    </script>
    </body>
    </html>
    """
    return HTMLResponse(html)

The resulting HTML contains

    const ui = SwaggerUIBundle({
        url: '/openapi.json',
        requestInterceptor: (req) => { if (req.url.endsWith("openapi.json")) { req.headers["X-API-Key"] = "xxx-api_key-xxx"; }; return req; },
        dom_id: '#swagger-ui',
        presets: [
        SwaggerUIBundle.presets.apis,
        SwaggerUIBundle.SwaggerUIStandalonePreset
        ],
        layout: "BaseLayout",
        deepLinking: true,
        showExtensions: true,
        showCommonExtensions: true
    })

It seems to work, so that the openapi.json doc is protected, e.g.

@app.get("/openapi.json", include_in_schema=False, tags=["documentation"])
    async def get_open_api_endpoint(api_key: str = Depends(get_api_key)):
        response = JSONResponse(app.openapi())
        return response

The swagger-ui exposes any API Keys in the browser in the curl command after using the swagger authorize UI, so any concerns about exposing the API key may just belong with the end user. Perhaps the requestInterceptor could also check that the endpoint is an HTTPS connection before adding the API key (maybe it makes localhost dev/test cycles more tedious or difficult while better protecting the API key in the wild).

@dazza-codes
Copy link
Author

dazza-codes commented Jan 20, 2021

The additional tweaks for this to work with AWS API-Gateway require adding an API-Gateway server to the openapi.json doc, e.g. assume that get_aws_uri(request) is able to use the request event data to get the host and stage from the request to dynamically add the server to the openapi doc.

def get_aws_uri(request: Request) -> Optional[str]:
    # https://mangum.io/adapter/#retrieving-the-aws-event-and-context
    # https://github.com/awsdocs/aws-lambda-developer-guide/blob/master/sample-apps/nodejs-apig/event.json
    aws_event = request.scope.get("aws.event", {})
    context = aws_event.get("requestContext", {})
    domain_name = context.get("domainName")
    domain_stage = context.get("stage")
    if domain_name and domain_stage:
        url = f"https://{domain_name}/{domain_stage}"
        return urlparse(url).geturl()

# now within factory method that creates FastAPI app:

    def custom_openapi(request: Request):
        if app.openapi_schema:
            return app.openapi_schema
        openapi_schema = app.openapi()
        aws_uri = get_aws_uri(request)
        if aws_uri:
            # AWS API-Gateway is deployed to a baseURL path for a stage
            openapi_schema["servers"] = [{"url": aws_uri}]
        app.openapi_schema = openapi_schema
        return app.openapi_schema

    @app.get("/openapi.json", include_in_schema=False, tags=["documentation"])
    async def get_open_api_endpoint(
        request: Request, api_key: str = Depends(get_api_key)
    ):
        response = JSONResponse(custom_openapi(request))
        return response

    @app.get("/docs", include_in_schema=False, tags=["documentation"])
    async def get_documentation(request: Request, api_key: str = Depends(get_api_key)):
        aws_uri = get_aws_uri(request)
        if aws_uri:
            openapi_uri = f"{aws_uri}/openapi.json"
        else:
            openapi_uri = "/openapi.json"
        response = custom_swagger_ui_html(
            openapi_url=openapi_uri, title="docs", api_key=api_key
        )
        return response

Maybe all that could be avoided if the FastAPI instance were dynamically instantiated in factory method and the host and base path were set globally. I'm sure I've missed a documentation tip on how that is done (?)

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Projects
None yet
Development

No branches or pull requests

2 participants