Skip to content
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

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

Closed
Vido opened this issue Feb 1, 2022 · 19 comments
Labels
question Further information is requested

Comments

@Vido
Copy link

Vido commented Feb 1, 2022

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
}
@Vido Vido added the question Further information is requested label Feb 1, 2022
@JonasKs
Copy link
Member

JonasKs commented Feb 1, 2022

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 Vido closed this as completed Feb 2, 2022
@Vido
Copy link
Author

Vido commented Feb 2, 2022

@JonasKs Huge Thanks!

@JonasKs
Copy link
Member

JonasKs commented Feb 2, 2022

My pleasure 😊

@JonasKs
Copy link
Member

JonasKs commented Feb 3, 2022

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)

@Intility Intility deleted a comment from Vido Feb 3, 2022
@h3rmanj
Copy link
Member

h3rmanj commented Feb 3, 2022

@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

raise InvalidAuth('Required scope missing')

Am I missing something?

@Vido
Copy link
Author

Vido commented Feb 4, 2022

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
Copy link
Member

JonasKs commented Feb 4, 2022

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
Copy link
Author

Vido commented Feb 5, 2022

@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
Copy link
Member

JonasKs commented Feb 5, 2022

You’re welcome 😊

@Vido
Copy link
Author

Vido commented Mar 10, 2022

@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
Copy link
Member

JonasKs commented Mar 10, 2022

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
Copy link
Author

Vido commented Mar 21, 2022

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
Copy link
Member

JonasKs commented Mar 21, 2022

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
Copy link
Author

Vido commented Mar 21, 2022

@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
Copy link
Member

JonasKs commented Mar 21, 2022

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
Copy link
Author

Vido commented Mar 22, 2022

@JonasKs. Thanks for the clarification.

  • PKCE flow -> for SPA frontend (interactive public client)
  • Client credentials flow -> for secure applications (non-interactive confidential client)

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
Copy link
Member

JonasKs commented Mar 23, 2022

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
Copy link

tares003 commented Oct 9, 2023

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
Copy link
Member

JonasKs commented Oct 9, 2023

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

4 participants