Suggest middlwares for oauth2 against external providers #7888
-
Is your feature request related to a problemIs your feature request related to a problem? Yes It is unclear how to integrate an external oauth provider such as Microsoft, Google, Auth0 with FastAPI. The solution you would likeA section on the documentation describing how to achieve this, or which libraries do we recommend to do so. Two examples include the client from authlib and starlette-oauth2-api. Describe alternatives you've consideredPR an implementation to FastAPI. The reason I avoided this is because OAuth is not a thing of FastAPI only, but of a web app in general. Additional contextI am the author of starlette-oauth2-api, which we have been using to secure an API of ours against access tokens signed by external providers (multi-tenancy). |
Beta Was this translation helpful? Give feedback.
Replies: 34 comments 4 replies
-
|
I personally would welcome a docs PR, and I think the discussion at the end of #797 may be relevant. I think @tiangolo might already be cooking up something along these lines. |
Beta Was this translation helpful? Give feedback.
-
|
Hi All, I would need it too ;-) BR, |
Beta Was this translation helpful? Give feedback.
-
|
How to add google auth is explained in this article: |
Beta Was this translation helpful? Give feedback.
-
|
Did not know if this helps but I think this code should run for auth0 + FastAPI (actually I am not able to test it, but i will do asap). I merged the suggestion by auth0 from this site https://auth0.com/docs/quickstart/backend/python/01-authorization#validate-scopes with my implementation from FastAPI that just checks a list of valid tokens. First I define all authentication stuff in one python script. Please note that I am just enabled query authentication. In your app you have to declare an exception handler and add the well known I hope this can help you. |
Beta Was this translation helpful? Give feedback.
-
|
@meteoDaniel Thanks for your post! It helped me a lot. And I actually iterated through it a little, making a dependency injection version, and gone a little further. Implemented a couple of automated tests on it. Unfortunately I have not found a way of testing it without hitting Auth0 API. If anyone would have some solution for that I would be pleased. Here follows my code: here follows the tests: |
Beta Was this translation helpful? Give feedback.
-
|
@jfmrm Obrigado pelo exemplo! I reached a very similar solution that doesn't require the full "builder" aspect.
import json
from fastapi import Depends
from fastapi.security import OAuth2AuthorizationCodeBearer, SecurityScopes
from jose import jwt
from six.moves.urllib.request import urlopen
AUTH0_DOMAIN = "<auth0-domain>"
API_AUDIENCE = "<auth0-api-audience>"
ALGORITHMS = ["RS256"]
class AuthError(Exception):
def __init__(self, error, status_code):
self.error = error
self.status_code = status_code
# Define a Authorization scheme specific to our Auth0 config
auth0_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl=AUTH0_DOMAIN, tokenUrl=API_AUDIENCE
)
async def get_current_user(security_scopes: SecurityScopes, token: str = Depends(auth0_scheme)):
# This down to `END` comment is from https://auth0.com/docs/quickstart/backend/python/01-authorization#create-the-jwt-validation-decorator
jsonurl = urlopen("https://" + AUTH0_DOMAIN + "/.well-known/jwks.json")
jwks = json.loads(jsonurl.read())
unverified_header = jwt.get_unverified_header(token)
rsa_key = {}
for key in jwks["keys"]:
if key["kid"] == unverified_header["kid"]:
rsa_key = {
"kty": key["kty"],
"kid": key["kid"],
"use": key["use"],
"n": key["n"],
"e": key["e"],
}
if rsa_key:
try:
payload = jwt.decode(
token,
rsa_key,
algorithms=ALGORITHMS,
audience=API_AUDIENCE,
issuer=f"https://{AUTH0_DOMAIN}/",
)
except jwt.ExpiredSignatureError:
raise AuthError(
{"code": "token_expired", "description": "token is expired"}, 401
)
except jwt.JWTClaimsError:
raise AuthError(
{
"code": "invalid_claims",
"description": "incorrect claims,"
"please check the audience and issuer",
},
401,
)
except Exception:
raise AuthError(
{
"code": "invalid_header",
"description": "Unable to parse authentication" " token.",
},
401,
)
# END from Auth0
# token.scope is represented as a string of scopes space seperated
token_scopes = payload.get("scope", "").split()
# Check that we all scopes are present
for scope in security_scopes.scopes:
if scope not in token_scopes:
raise AuthError(
{
"code": "Unauthorized",
"description": f"You don't have access to this resource. `{' '.join(security_scopes.scopes)}` scopes required",
},
403,
)
return payloadUsing this is now as simple as from fastapi import FastAPI, Security
from .auth0 import AuthError, get_current_user
app = FastAPI()
@app.exception_handler(AuthError)
def handle_auth_error(request: Request, ex: AuthError):
return JSONResponse(status_code=ex.status_code, content=ex.error)
@app.get("/private")
def private(user=Security(get_current_user)):
return {"message": "You're an authorized user"}
@app.get("/private-with-scopes")
def privateScopes(user=Security(get_current_user, scopes["read:example"])):
return {"message": "You're authorized with scopes!"} |
Beta Was this translation helpful? Give feedback.
-
|
@Vivalldi do you have this code anywhere on github or somewhere? I am trying to do something similar but I'm hitting issues on this code. Thanks |
Beta Was this translation helpful? Give feedback.
-
|
Actually @Vivalldi I got your example working, my only question now is how do you auth on the |
Beta Was this translation helpful? Give feedback.
-
|
The OpenAPI spec doesn't include JWT bearer tokens yet. There's no defined standard as to where those tokens live and as such there's no OpenAPI config for it yet. I might be able to contrive an example where you set the token in the username field of basic auth (without pass) similar to stripe https://stripe.com/docs/api/authentication |
Beta Was this translation helpful? Give feedback.
-
|
@Vivalldi how are you handling your user relations this way? I'm guessing the sub data from the user object is basically the userid so would I just use this when I'm creating records that would have an ownerId for example or how have you been doing that? |
Beta Was this translation helpful? Give feedback.
-
|
This isn't directly related to this issue but in short, yes, you should use the user identifier. https://auth0.com/docs/users/normalized/auth0/store-user-data |
Beta Was this translation helpful? Give feedback.
-
|
Here is a working Auth0 and FastAPI code snippet.
|
Beta Was this translation helpful? Give feedback.
-
|
@rsitro4 Nice example. How do you use swagger-ui (/docs) with this? |
Beta Was this translation helpful? Give feedback.
-
|
@LindezaGrey did you know how to use it with swagger-ui (/docs)? |
Beta Was this translation helpful? Give feedback.
-
|
@LuisHernandez1611681 well sort of. In auth0 i added another authorization flow and internally used OAuth2PasswordBearer for the docs page. This means that you need to provide also client_secret and client_id. In my use case only admins use the /docs page for data entry and retrieval so it's not a big deal. if the docs page is faces to customers/clients, of course you don't want to provide those credentials... |
Beta Was this translation helpful? Give feedback.
-
|
@dorinclisu you might want publish it to PyPI with a package manager like poetry, flit etc |
Beta Was this translation helpful? Give feedback.
-
|
I'm looking into it, meanwhile it can be just as easily installed with pip from github archive. Locking against a specific release can be done like this |
Beta Was this translation helpful? Give feedback.
-
@Vivalldi do you think there might be a way to have this as dependency injection in the router? @router.post(
"/predict",
callbacks=callback_router.routes,
response_model=models.Margin,
dependencies=[Depends(Security(required_auth, scopes["role:scope"])]
)
def predict(predict_request: models.PredictRequest):
# whatever the function doesalthough it does not work as |
Beta Was this translation helpful? Give feedback.
-
|
@inspiralpatterns |
Beta Was this translation helpful? Give feedback.
-
|
Anyway, here's a minimal example using fastapi_auth0: from fastapi import FastAPI
from fastapi_auth0 import Auth0, Auth0User
app = FastAPI()
auth = Auth0(domain='your-tenant.auth0.com', api_audience='your-api-identifier', scopes={'read:blabla': ''})
@app.get("/secure", dependencies=[Depends(auth.implicit_scheme)])
def get_secure(user: Auth0User = Security(auth.get_user, scopes=['read:blabla'])):
return {"message": f"{user}"}Bear in mind that If you want to secure the path (or whole router) and are not interested in user details, you could do this: @app.get("/secure", dependencies=[
Depends(auth.implicit_scheme),
Security(auth.get_user, scopes=['read:blabla'])
])
def get_secure():
return {"message": "I don't care who you are, but you're authorized"} |
Beta Was this translation helpful? Give feedback.
-
|
Beta Was this translation helpful? Give feedback.
-
|
oauth2 api seems to be neat I have a question @jorgecarleitao, instead of hitting the jwks endpoint for every singe request through middleware is it possible to set a refresh interval with a cache. Also here is another one for AWS. |
Beta Was this translation helpful? Give feedback.
-
It's not needed to use a cache, you only need to read it once at api startup. The jwks never changes except when you rotate the tenant secret, which means it's not big deal to restart the api also. |
Beta Was this translation helpful? Give feedback.
-
|
@JHBalaji, thanks :D
Currently the midlleware fetches then once when they are first required and cached in-memory. You can inject the keys yourself if your API for some reason does not have egress (e.g. corp stuff). There is a valid use-case for IdP that rotate keys; I've added an issue for that use-case. |
Beta Was this translation helpful? Give feedback.
-
|
@jorgecarleitao thanks for talking it. Yes my IdP rotate every 30 min as well as JWKS url can also rotate which is fetched from another URL (/openid-configuration) with key being jwks_uri. |
Beta Was this translation helpful? Give feedback.
-
|
@JHBalaji , the refreshing of the key is now available, on version 0.2.5. On a separate note: I have never heard of a refreshing key URL. Do you know which concern the rotating URL is addressing? Which OAuth2 provider have you seen that behavior on? |
Beta Was this translation helpful? Give feedback.
-
|
@jorgecarleitao I dont use a public Idp provider but I have added another hook to address this in mine. But the flow is retrieve openid-configuration and read jwks_uri and call jwks_uri to get refresh tokens every X mins and visit openid-configuration and get updated jwks_uri. The jwks_uri changes in our dev env. |
Beta Was this translation helpful? Give feedback.
-
|
Hey all! Thanks for the discussion here! ☕ Having more docs about auth and integration with OAuth2 providers, and how to create OAuth2 servers is one of the things I've been wanting to do for a long time. But it's a relatively complex topic that needs a long time of dedication to write the whole tutorial. And yes, the way to go would be quite similar to @Vivalldi's example: #840 (comment) That one is specific to Auth0, with their own An initial way to go is, if you are using some third party provider and are integrating it with FastAPI, you could write a blog post about it, and you could add it to the external links section in the docs. As this is useful to others using your same provider, I'm pretty sure it would be very well received. Now, about the original issue posted, I wouldn't recommend using middlewares for this, but dependencies. Because dependencies are what can be integrated with FastAPI, with OpenAPI, the automatic docs, respect types, etc. So I would try to move away from middlewares. They also have several issues that have been improving in Starlette, but still, it's not the best way to go with FastAPI. But anyway, although I can't make promises about timelines (I'm now trying to at least clean issues first) at some point OAuth2 will be in the docs and in the tutorials for sure. 🤓 📝 Thanks for the discussion and interest everyone! 🍰
|
Beta Was this translation helpful? Give feedback.
-
|
Assuming the original need was handled, this will be automatically closed now. But feel free to add more comments or create new issues or PRs. |
Beta Was this translation helpful? Give feedback.
-
|
For those who may be interested in Zitadel, here a version, please share your thoughts: import time
import json
import requests
from typing import Optional
from fastapi.security import OAuth2AuthorizationCodeBearer, SecurityScopes
from fastapi.exceptions import HTTPException
from starlette.requests import Request
from starlette.status import HTTP_401_UNAUTHORIZED
from authlib.jose import jwt
import base64
def decode_value(value: str):
if value.startswith("base64:"):
value = value.split("base64:")[1]
return base64.b64decode(value)
return value
def match_token_scopes(token, or_scopes):
if or_scopes is None:
return True
scopes = token.get("scope", "").split()
for and_scopes in or_scopes.values():
if all(key in scopes for key in and_scopes.split()):
return True
return False
class ApiPrivateKey:
def __init__(
self,
client_id: str,
key_id: str,
key: str,
aud: str,
exp_in=60 * 60,
alg="RS256",
type="application",
app_id=None
):
self.client_id = client_id
self.key_id = key_id
self.aud = aud
self.app_id = app_id
self.type = type
self.exp_in = exp_in
self.alg = alg
self.key = decode_value(key)
@staticmethod
def from_file(file_path: str, aud: str):
with open(file_path, "r") as f:
data = json.load(f)
ApiPrivateKey(
type=data["type"],
key_id=data["keyId"],
key=data["key"],
app_id=data["appId"],
client_id=data["clientId"],
aud=aud
)
class ZitadelIntrospectToken(OAuth2AuthorizationCodeBearer):
def __init__(
self,
base_url: str,
private_key: ApiPrivateKey,
scopes: dict[str, str] = None,
):
super().__init__(
authorizationUrl=f"#{base_url}/oauth/v2/authorize",
tokenUrl=f"#{base_url}/oauth/v2/token",
refreshUrl=f"#{base_url}/oauth/v2/token",
scopes=scopes,
auto_error=True
)
self.base_url = base_url
self.private_key = private_key
self.scopes = scopes
async def __call__(self, request: Request) -> Optional[str]:
token = await super().__call__(request)
if not token:
return None
now = time.time()
resp = requests.post(
url=f'{self.base_url}/oauth/v2/introspect',
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": jwt.encode(
header={"alg": self.private_key.alg, "kid": self.private_key.key_id},
payload={
"iss": self.private_key.client_id,
"sub": self.private_key.client_id,
"aud": self.private_key.aud,
"exp": int(now) + self.private_key.exp_in,
"iat": int(now),
},
key=self.private_key.key,
),
"token": token,
}
)
return self.process_response(resp)
def process_response(self, resp):
if resp.status_code != 200:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
introspected_token = resp.json()
if not introspected_token:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Token was revoked.",
headers={"WWW-Authenticate": "Bearer"},
)
if not introspected_token.get("active"):
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Token is inactive.",
headers={"WWW-Authenticate": "Bearer"},
)
now = int(time.time())
if introspected_token["exp"] < now:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Token has expired.",
headers={"WWW-Authenticate": "Bearer"},
)
if not match_token_scopes(introspected_token, self.scopes):
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail=f"Token has insufficient scope. Route requires: {self.scopes.values()}",
headers={"WWW-Authenticate": "Bearer"},
)
return introspected_token |
Beta Was this translation helpful? Give feedback.
Hey all! Thanks for the discussion here! ☕
Having more docs about auth and integration with OAuth2 providers, and how to create OAuth2 servers is one of the things I've been wanting to do for a long time. But it's a relatively complex topic that needs a long time of dedication to write the whole tutorial.
And yes, the way to go would be quite similar to @Vivalldi's example: #840 (comment)
That one is specific to Auth0, with their own
AUTH0_DOMAIN. I want to make a tutorial that doesn't specifically depend on/promote one of these external providers that are not involved with FastAPI. I would hope they would make tutorials to integrate their auth with FastAPI, but anyway. 🤷An initial way t…