Intility / fastapi-azure-auth

Easy and secure implementation of Azure Entra ID (previously AD) for your FastAPI APIs 🔒 B2C, single- and multi-tenant support.
https://intility.github.io/fastapi-azure-auth
MIT License
416 stars 64 forks source link

[BUG/Question] Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type. #47

Closed Vido closed 2 years ago

Vido commented 2 years ago

Describe the bug

Auth error
Error: Bad Request,
error: invalid_request,
description: AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type.

To Reproduce

This is the minimal FastAPI app:

from pydantic import AnyHttpUrl, BaseSettings, Field
from fastapi.middleware.cors import CORSMiddleware
from typing import Union

class Settings(BaseSettings):
    SECRET_KEY: str = Field('my super secret key', env='SECRET_KEY')
    BACKEND_CORS_ORIGINS: list[Union[str, AnyHttpUrl]] = ['http://localhost:8000']
    OPENAPI_CLIENT_ID: str = Field(default='', env='OPENAPI_CLIENT_ID')
    APP_CLIENT_ID: str = Field(default='', env='APP_CLIENT_ID')
    TENANT_ID: str = Field(default='', env='TENANT_ID')

    class Config:
        env_file = '.env'
        env_file_encoding = 'utf-8'
        case_sensitive = True

from fastapi import FastAPI

settings = Settings()
app = FastAPI()

settings = Settings()
if settings.BACKEND_CORS_ORIGINS:
    app.add_middleware(
        CORSMiddleware,
        allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
        allow_credentials=True,
        allow_methods=['*'],
        allow_headers=['*'],
    )

app = FastAPI(
    swagger_ui_oauth2_redirect_url='/oauth2-redirect',
    swagger_ui_init_oauth={
        'usePkceWithAuthorizationCodeGrant': True,
        'clientId': settings.OPENAPI_CLIENT_ID,
    })

from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer

azure_scheme = SingleTenantAzureAuthorizationCodeBearer(
    app_client_id=settings.APP_CLIENT_ID,
    tenant_id=settings.TENANT_ID,
    scopes={
        #"User.ReadBasic.All": 'read'
        'https://graph.microsoft.com/.default': 'default'
        #AADSTS70011
        #f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation',
    })

@app.on_event('startup')
async def load_config() -> None:
    """    Load OpenID config on startup.    """
    await azure_scheme.openid_config.load_config()

from fastapi import Security, responses

@app.get("/", dependencies=[Security(azure_scheme, scopes=["default"])])
def read_root():
    """
    Redirects to /docs
    """
    return "It works."

Please, set the following envars:

export TENANT_ID=<your-tenant_id>
export OPENAPI_CLIENT_ID=<your-client_id>
export APP_CLIENT_ID="https://login.microsoftonline.com/$TENANT_ID"
export SECRET_KEY=<your-secret>

Steps to reproduce the behavior:

  1. Go to http://localhost:8000/docs
  2. Click in 'Autorize'
  3. Leave client_secret blank, and select scopes
  4. Click in 'Autorize', the page will return the error

Configuration

I believe this bug is related to my Azure AD set up, so may provide the Manifest from AD. Sensitive information is hidden and the <CENSORED> is put in place.

{
    "id": "<CENSORED>",
    "acceptMappedClaims": null,
    "accessTokenAcceptedVersion": 2,
    "addIns": [],
    "allowPublicClient": false,
    "appId": "<CENSORED>",
    "appRoles": [],
    "oauth2AllowUrlPathMatching": false,
    "createdDateTime": "2022-01-11T19:43:15Z",
    "description": null,
    "certification": null,
    "disabledByMicrosoftStatus": null,
    "groupMembershipClaims": null,
    "identifierUris": [],
    "informationalUrls": {
        "termsOfService": null,
        "support": null,
        "privacy": null,
        "marketing": null
    },
    "keyCredentials": [],
    "knownClientApplications": [],
    "logoUrl": null,
    "logoutUrl": "https://localhost:8000/oauth2-redirect",
    "name": "backoffice",
    "notes": null,
    "oauth2AllowIdTokenImplicitFlow": true,
    "oauth2AllowImplicitFlow": true,
    "oauth2Permissions": [],
    "oauth2RequirePostResponse": false,
    "optionalClaims": null,
    "orgRestrictions": [],
    "parentalControlSettings": {
        "countriesBlockedForMinors": [],
        "legalAgeGroupRule": "Allow"
    },
    "passwordCredentials": [
        {
            "customKeyIdentifier": null,
            "endDate": "2022-04-21T17:02:20.006Z",
            "keyId": "<CENSORED>",
            "startDate": "2022-01-21T17:02:20.006Z",
            "value": null,
            "createdOn": "2022-01-21T17:02:31.8956842Z",
            "hint": ".F7",
            "displayName": "API-Test"
        }
    ],
    "preAuthorizedApplications": [],
    "publisherDomain": "<CENSORED>",
    "replyUrlsWithType": [
        {
            "url": "http://localhost:8000/",
            "type": "Web"
        },
        {
            "url": "http://localhost:8000/oauth2-redirect",
            "type": "Web"
        },
    ],
    "requiredResourceAccess": [
        {
            "resourceAppId": "00000003-0000-0000-c000-000000000000",
            "resourceAccess": [
                {
                    "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d",
                    "type": "Scope"
                },
                {
                    "id": "14dad69e-099b-42c9-810b-d002981feec1",
                    "type": "Scope"
                }
            ]
        }
    ],
    "samlMetadataUrl": null,
    "serviceManagementReference": null,
    "signInUrl": null,
    "signInAudience": "AzureADMyOrg",
    "tags": [],
    "tokenEncryptionKeyId": null
}
JonasKs commented 2 years ago

Hi,

The application created for OpenAPI seems to be configured for web and not Spa. See this section:

bilde

The application itself should looke something like this:

"replyUrlsWithType": [
        {
            "url": "http://localhost:8000",
            "type": "Web"
        }
    ],

and the OpenAPI application registration should look something like this:

    "replyUrlsWithType": [
        {
            "url": "http://localhost:8000/oauth2-redirect",
            "type": "Spa"
        }
    ],
Vido commented 2 years ago

@JonasKs Huge Thanks!

JonasKs commented 2 years ago

My pleasure 😊

JonasKs commented 2 years ago

Either request the scope for the token, such as user_impersonation described in the setup, and then require that scope for that API. Scopes are not mandatory and you can provide an empty list in Security(), or even just use Depends() instead of Security()

(Sorry for formatting, written in a rush on my phone)

h3rmanj commented 2 years ago

@Vido I'm deleting and reposting your comment as it included potential sensitive information.

@JonasKs another question:

The /docs interface gives me this curl:

curl -X 'GET' \
  'http://localhost:8000/' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer [removed]'

But I the this 401 response:

{
  "detail": "Required scope missing"
}

I read the code, It seems that you must consider the scope in the resquest (in some way), I'm not sure

https://github.com/Intility/fastapi-azure-auth/blob/51a0afa7851fb3a62e1ed9c718c632eada6d8b5a/fastapi_azure_auth/auth.py#L143

Am I missing something?

Vido commented 2 years ago

Hi guys,

@JonasKs thanks for the swift answer.

I can't get it to work... I guess there are something wrong with AD config.

Consider this code. I'm not using the 'https://graph.microsoft.com/.default': 'default'scope. (It wasn't working) I created the user_impersonation, and it gets the token.

azure_scheme = SingleTenantAzureAuthorizationCodeBearer(
    app_client_id=settings.APP_CLIENT_ID,
    tenant_id=settings.TENANT_ID,
    scopes={
        # 'https://graph.microsoft.com/.default': 'default'
        f'api://{settings.APP_CLIENT_ID}/user_impersonation': 'user_impersonation',
    })

# I try this... same results
# @app.get("/", dependencies=[Security(azure_scheme, scopes=[])])

@app.get("/", dependencies=[Security(azure_scheme, scopes=["user_impersonation"])])
def read_root():
    return "It works."

I get 401 { "detail": "Token contains invalid claims" }

This is the underlining exception:

api_1  | Traceback (most recent call last):
api_1  |   File "/usr/local/lib/python3.9/site-packages/uvicorn/protocols/http/httptools_impl.py", line 375, in run_asgi
api_1  |     result = await app(self.scope, self.receive, self.send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/uvicorn/middleware/proxy_headers.py", line 75, in __call__
api_1  |     return await self.app(scope, receive, send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/fastapi/applications.py", line 208, in __call__
api_1  |     await super().__call__(scope, receive, send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/applications.py", line 112, in __call__
api_1  |     await self.middleware_stack(scope, receive, send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/middleware/errors.py", line 181, in __call__
api_1  |     raise exc
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/middleware/errors.py", line 159, in __call__
api_1  |     await self.app(scope, receive, _send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/middleware/cors.py", line 84, in __call__
api_1  |     await self.app(scope, receive, send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/exceptions.py", line 82, in __call__
api_1  |     raise exc
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/exceptions.py", line 71, in __call__
api_1  |     await self.app(scope, receive, sender)
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/routing.py", line 656, in __call__
api_1  |     await route.handle(scope, receive, send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/routing.py", line 259, in handle
api_1  |     await self.app(scope, receive, send)
api_1  |   File "/usr/local/lib/python3.9/site-packages/starlette/routing.py", line 61, in app
api_1  |     response = await func(request)
api_1  |   File "/usr/local/lib/python3.9/site-packages/fastapi/routing.py", line 216, in app
api_1  |     solved_result = await solve_dependencies(
api_1  |   File "/usr/local/lib/python3.9/site-packages/fastapi/dependencies/utils.py", line 525, in solve_dependencies
api_1  |     solved = await call(**sub_values)
api_1  |   File "/code/./api/main.py", line 117, in __call__
api_1  |     token = jwt.decode(
api_1  |   File "/usr/local/lib/python3.9/site-packages/jose/jwt.py", line 157, in decode
api_1  |     _validate_claims(
api_1  |   File "/usr/local/lib/python3.9/site-packages/jose/jwt.py", line 484, in _validate_claims
api_1  |     _validate_aud(claims, audience=audience)
api_1  |   File "/usr/local/lib/python3.9/site-packages/jose/jwt.py", line 350, in _validate_aud
api_1  |     raise JWTClaimsError("Invalid audience")
api_1  | jose.exceptions.JWTClaimsError: Invalid audience

Am I missing something?

JonasKs commented 2 years ago

Can you decode the token at e.g. jwt.io? The audience for the token is wrong.
Seeing your manifest is different multiple places compared to the tutorial, I strongly suggest that you follow the tutorial exactly as it is written once and get everything working, and then alter the setup to your needs later.

Vido commented 2 years ago

@JonasKs Huge Thanks again!

The jwi.io tip was quite helpful. I wasn't aware that the JWT-token carried so much information. I'm dumpAF, now a realized what I messed up.

Thanks for your help.

JonasKs commented 2 years ago

You’re welcome 😊

Vido commented 2 years ago

@JonasKs Hi, just one more question. (I don't think this worth to open a new issue.)

Considering the same exemple of this Issue:

@app.get("/", dependencies=[Security(azure_scheme, scopes=["user_impersonation"])])
def read_root():
    return "It works."

How to I get the authenticated user object (username, email, etc...) ?

JonasKs commented 2 years ago

Hi!

There's two ways, either using the request object as seen here, or adding a dependency in the input of your function, as seen here.


@app.get("/")
def read_root(user: User = Security(azure_scheme, scopes=["user_impersonation"]):
    return user.dict()
Vido commented 2 years ago

Hi @JonasKs , another silly question.

Let's assume that the use case of the API is systems interoperability (ETLs, etc...), and not a SPA. What is the appropriate way to authenticate this process? What would be the curl command that gets the token?

Thanks.

JonasKs commented 2 years ago

Hi,

That's not a silly question! You would use something called Client Credentials flow. Basically just create a secret for your app reg and do something like this:

from aiohttp import ClientSession

payload = {
    'grant_type': 'client_credentials',
    'client_id': client_id,
    'client_secret': client_secret,
    'scope': scope
}
async with ClientSession() as azure_client:
    async with azure_client.post(f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token', data=payload) as azure_response:
        azure_response = await azure_response.json()
        token = azure_response['access_token']
        print(token)

or if you use httpx:

from httpx import AsyncClient

payload = {
    'grant_type': 'client_credentials',
    'client_id': client_id,
    'client_secret': client_secret,
    'scope': scope
}

async with AsyncClient() as azure_client:
    response = await azure_client.post(url=f'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token', data=body)
        token = response.json()['access_token']
        print(token)

If I don't remember wrong, you have to use the .default scope. So if your backend app reg client ID is abcd then your scope should be api://abcd/.default.

Vido commented 2 years ago

@JonasKs thanks again. This link about authentications protocols was quite helpful. As far as I understood: there are two types of clients: public clients and confidential clients. Basically: confidential means 'intranet' or internal-services. Which is the case of Client Credentials flow.

In my use case, the API must be open to the internet, because business-costumers will get their data from it. I would like the auth to be public and non-interactive. I guess username-password auth-flow is what fits my aplication https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-authentication-flows#usernamepassword

JonasKs commented 2 years ago

Basically: confidential means 'intranet' or internal-services. Which is the case of Client Credentials flow.

Confidential means any service which is not exposed in any way. A place where the client_secret can be safely stored, such as on your local computer or on a server. That does not include any javascript or frontend application. Anyone with access to the frontend could grab the client secret and authenticate as an application in any context. The client_secret must be secret.

If authentication is done through the frontend (such as OpenAPI swagger docs or an SPA), you must use PKCE flow as described in the documentation. Between secure applications (such as you using curl or through your python backend application), you can use client credentials flow to get tokens, and talk to APIs using that.

Vido commented 2 years ago

@JonasKs. Thanks for the clarification.

Let's suppose: I have 100 business-costumers. The want to authenticate with a non-interactive (for easy of use). Do I have to share the client_secret among all of them? (Sounds messy). What are my alternatives for non-interactive clients?

Another concern: I'd like to have automated tests im my application. Let's suppose we are writing tests for a specific endpoint protected by azure_scheme. How to we authenticate this test-client?

JonasKs commented 2 years ago

Every application (confidential, secure client) would have their own application registration(appreg). That application has access to your backend. It's set up just like you would with the OpenAPI (Swagger) app registration.

The users themself can get access to their appreg, and create their own secrets for their own application.

No, you should not share secrets between applications.

tares003 commented 9 months ago

Hi,

The application created for OpenAPI seems to be configured for web and not Spa. See this section:

bilde

The application itself should looke something like this:

"replyUrlsWithType": [
      {
          "url": "http://localhost:8000",
          "type": "Web"
      }
  ],

and the OpenAPI application registration should look something like this:

  "replyUrlsWithType": [
      {
          "url": "http://localhost:8000/oauth2-redirect",
          "type": "Spa"
      }
  ],

how do i overcome this, make it web instead of SPA?

JonasKs commented 9 months ago

Not sure if I understand. You want the OpenAPI docs and other SPA applications (such as a JavaScript frontend) to be configured as SPA.

I tested this flow last week, so if you follow the tutorial everything should work.