6

This is driving me absolutely crazy and preventing me from being able to do local dev/test.

I have a flask app that uses authlib (client capabilities only). When a user hits my home page, my flask backend redirects them to /login which in turn redirects to Google Auth. Google Auth then posts them back to my app's /auth endpoint.

For months, I have been experiencing ad-hoc issues with authlib.integrations.base_client.errors.MismatchingStateError: mismatching_state: CSRF Warning! State not equal in request and response. It feels like a cookie problem and most of the time, I just open a new browser window or incognito or try to clear cache and eventually, it sort of works.

However, I am now running the exact same application inside of a docker container and at one stage this was working. I have no idea what I have changed but whenever I browse to localhost/ or 127.0.0.1/ and go through the auth process (clearing cookies each time to ensure i'm not auto-logged in), I am constantly redirected back to localhost/auth?state=blah blah blah and I experience this issue: authlib.integrations.base_client.errors.MismatchingStateError: mismatching_state: CSRF Warning! State not equal in request and response.

I think the relevant part of my code is:

@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def catch_all(path: str) -> Union[flask.Response, werkzeug.Response]:
    if flask.session.get("user"):
        return app.send_static_file("index.html")
    return flask.redirect("/login")


@app.route("/auth")
def auth() -> Union[Tuple[str, int], werkzeug.Response]:
    token = oauth.google.authorize_access_token()
    user = oauth.google.parse_id_token(token)
    flask.session["user"] = user
    return flask.redirect("/")


@app.route("/login")
def login() -> werkzeug.Response:
    return oauth.google.authorize_redirect(flask.url_for("auth", _external=True))

I would hugely appreciate any help.

When I run locally, I start with:

export FLASK_APP=foo && flask run

When I run inside docker container, i start with:

.venv/bin/gunicorn -b :8080 --workers 16 foo
adamcunnington
  • 292
  • 1
  • 3
  • 16

3 Answers3

11

Issue was that SECRET_KEY was being populated using os.random which yielded different values for different workers and thus, couldn't access the session cookie.

adamcunnington
  • 292
  • 1
  • 3
  • 16
3

@adamcunnington here is how you can debug it:

@app.route("/auth")
def auth() -> Union[Tuple[str, int], werkzeug.Response]:
    # Check these two values
    print(flask.request.args.get('state'), flask.session.get('_google_authlib_state_'))

    token = oauth.google.authorize_access_token()
    user = oauth.google.parse_id_token(token)
    flask.session["user"] = user
    return flask.redirect("/")

Check the values in request.args and session to see what's going on.

Maybe it is because Flask session not persistent across requests in Flask app with Gunicorn on Heroku

lepture
  • 2,307
  • 16
  • 18
  • thanks. Here are the results: - Docker + gunicorn (16 workers) TrhHO856SVe7EA9INtHKZ85rnieDKS None authlib.integrations.base_client.errors.MismatchingStateError: mismatching_state: CSRF Warning! State not equal in request and response. - Docker + gunicorn (1 worker) enq7AxTUI5lfhTvDwvMKRe6pn5Hah8 enq7AxTUI5lfhTvDwvMKRe6pn5Hah8 - Flask webserver eqahv9lpZOHpPmTabsqBNSDtEN4TEI eqahv9lpZOHpPmTabsqBNSDtEN4TEI Why does the 16 worker scenario cause the issue?! I still only make 1 request. – adamcunnington May 21 '20 at 09:02
  • @adamcunnington I'm trying to reproduce the issue with https://github.com/authlib/demo-oauth-client/tree/master/flask-google-login `gunicorn app:app -w 16 --bind=0.0.0.0:5000` But I can't reproduce it. When does it happen? I tried many times, there is still no error. – lepture May 21 '20 at 13:40
  • It happens immediately - as soon as I boot the webserver with 16 workers, if I try to visit the homepage - which redirects me to login, i always get the mismatched CSRF error. – adamcunnington May 21 '20 at 16:07
  • @adamcunnington follow authlib twitter account, we can arrange a zoom meeting to debug this problem. I'll DM you. – lepture May 22 '20 at 09:41
  • @adamcunnington can you create a sample github repo to reproduce the issue? Maybe it is because this https://stackoverflow.com/questions/30984622/flask-session-not-persistent-across-requests-in-flask-app-with-gunicorn-on-herok – lepture May 22 '20 at 11:19
  • thanks so much for your help and for offering a zoom to fix. As it happens, your link was incredibly useful - I feel stupid, it was because i was using os.random to generate the SECRET_KEY and so this was inconsistent across workers and thus, they couldn't access the session state and hence the CSRF. Changing this to a fixed string fixed the problem - thank you again! – adamcunnington May 23 '20 at 00:12
2

How I Fix My Issue

install old version of authlib it work fine with fastapi and flask

Authlib==0.14.3

For Fastapi

uvicorn==0.11.8
starlette==0.13.6
Authlib==0.14.3
fastapi==0.61.1

Imporantt if using local host for Google auth make sure get https certifcate

install chocolatey and setup https check this tutorial

https://dev.to/rajshirolkar/fastapi-over-https-for-development-on-windows-2p7d

ssl_keyfile="./localhost+2-key.pem" ,
 ssl_certfile= "./localhost+2.pem"

--- My Code ---

from typing import Optional
from fastapi import FastAPI, Depends, HTTPException
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi

from starlette.config import Config
from starlette.requests import Request
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse

from authlib.integrations.starlette_client import OAuth

# Initialize FastAPI
app = FastAPI(docs_url=None, redoc_url=None)
app.add_middleware(SessionMiddleware, secret_key='!secret')




@app.get('/')
async def home(request: Request):
    # Try to get the user
    user = request.session.get('user')
    if user is not None:
        email = user['email']
        html = (
            f'<pre>Email: {email}</pre><br>'
            '<a href="/docs">documentation</a><br>'
            '<a href="/logout">logout</a>'
        )
        return HTMLResponse(html)

    # Show the login link
    return HTMLResponse('<a href="/login">login</a>')


# --- Google OAuth ---


# Initialize our OAuth instance from the client ID and client secret specified in our .env file
config = Config('.env')
oauth = OAuth(config)

CONF_URL = 'https://accounts.google.com/.well-known/openid-configuration'
oauth.register(
    name='google',
    server_metadata_url=CONF_URL,
    client_kwargs={
        'scope': 'openid email profile'
    }
)


@app.get('/login', tags=['authentication'])  # Tag it as "authentication" for our docs
async def login(request: Request):
    # Redirect Google OAuth back to our application
    redirect_uri = request.url_for('auth')
    print(redirect_uri)

    return await oauth.google.authorize_redirect(request, redirect_uri)


@app.route('/auth/google')
async def auth(request: Request):
    # Perform Google OAuth
    token = await oauth.google.authorize_access_token(request)
    user = await oauth.google.parse_id_token(request, token)

    # Save the user
    request.session['user'] = dict(user)

    return RedirectResponse(url='/')


@app.get('/logout', tags=['authentication'])  # Tag it as "authentication" for our docs
async def logout(request: Request):
    # Remove the user
    request.session.pop('user', None)

    return RedirectResponse(url='/')


# --- Dependencies ---


# Try to get the logged in user
async def get_user(request: Request) -> Optional[dict]:
    user = request.session.get('user')
    if user is not None:
        return user
    else:
        raise HTTPException(status_code=403, detail='Could not validate credentials.')

    return None


# --- Documentation ---


@app.route('/openapi.json')
async def get_open_api_endpoint(request: Request, user: Optional[dict] = Depends(get_user)):  # This dependency protects our endpoint!
    response = JSONResponse(get_openapi(title='FastAPI', version=1, routes=app.routes))
    return response


@app.get('/docs', tags=['documentation'])  # Tag it as "documentation" for our docs
async def get_documentation(request: Request, user: Optional[dict] = Depends(get_user)):  # This dependency protects our endpoint!
    response = get_swagger_ui_html(openapi_url='/openapi.json', title='Documentation')
    return response


if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app,    port=8000,
                log_level='debug',
                ssl_keyfile="./localhost+2-key.pem" ,
                ssl_certfile= "./localhost+2.pem"
                )

.env file

GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""

Google Console Setup

enter image description here enter image description here

Haseeb
  • 2,039
  • 3
  • 11
  • 26