Search code examples
pythonflaskoauth-2.0single-sign-onopenid-connect

Python - Implement Authentik SSO in web server using requests_oauthlib


I'm trying to implement SSO in a Web Application using OpenID Connect.

What I am using

  • python 3.12
  • Authentik (the Identity Provider aka IdP)
  • flask (to expose the webserver)
  • requests_oauthlib (to handle OAuth2 Session)

What I've done

I'm trying to replicate the example in request-oauthlib for Web Application but without success

  1. Create an application on the IdP Authentik as OAuth2/OpenID Provider
  2. Create the web server using flask and 2 test endpoint: /login that redirects to the IdP and retrieves (authorization_url and state) and /callback that using the client_id, client_secret, state and authorization_response tries to retrieve the access token

Unfortunately when I try to retrieve the access token I receive back the following error: oauthlib.oauth2.rfc6749.errors.InvalidClientError: (invalid_client) Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method)

The code

import json
import os.path
from uuid import uuid4
from requests_oauthlib import OAuth2Session
from waitress import serve
from flask import Flask, jsonify, request, url_for, redirect, session
from pprint import pprint


with open(os.path.join("Config", "client_secrets.json"), "r") as f:
    idp = json.load(f)

os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"


def main():
    app = Flask(__name__)
    # This allows us to use a plain HTTP callback
    os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = "1"
    app.config['SECRET_KEY'] = str(uuid4())

    @app.route('/')
    def index():
        return """
            <a href="/login">Login</a>
        """

    @app.route("/login")
    def login():
        oauth = OAuth2Session(client_id=idp["client_id"],
                              scope=idp["scope"],
                              redirect_uri=idp["callback"]
                              )
        authorization_url, state = oauth.authorization_url(idp["authorize"])
        session['oauth_state'] = state
        return redirect(authorization_url)

    @app.route("/callback")
    def callback():
        pprint(request.__dict__)
        oauth = OAuth2Session(client_id=idp["client_id"],
                              state=session['oauth_state']
                              )
        # When I try to get the token, nothing works
        token = oauth.fetch_token(
            idp["token"],
            client_secret=idp["client_secret"],
            authorization_response=request.url
        )
        # I never reach this line
        session['oauth_token'] = token
        return "I cannot see this :("

    print("Starting webserver")
    serve(app, host='0.0.0.0', port=5000)
    print("Webserver running")


if __name__ == "__main__":
    main()

The callback request:

{'cookies': ImmutableMultiDict([('session', 'REDACTED_SESSION')]),
 'environ': {'HTTP_ACCEPT': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
             'HTTP_ACCEPT_ENCODING': 'gzip, deflate, br',
             'HTTP_ACCEPT_LANGUAGE': 'en-US,en;q=0.5',
             'HTTP_CONNECTION': 'keep-alive',
             'HTTP_COOKIE': 'session=REDACTED_SESSION',
             'HTTP_DNT': '1',
             'HTTP_HOST': 'localhost:5000',
             'HTTP_SEC_FETCH_DEST': 'document',
             'HTTP_SEC_FETCH_MODE': 'navigate',
             'HTTP_SEC_FETCH_SITE': 'cross-site',
             'HTTP_UPGRADE_INSECURE_REQUESTS': '1',
             'HTTP_USER_AGENT': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; '
                                'rv:123.0) Gecko/20100101 Firefox/123.0',
             'PATH_INFO': '/callback',
             'QUERY_STRING': 'code=REDACTED_CODE&state=REDACTED_STATE',
             'REMOTE_ADDR': '127.0.0.1',
             'REMOTE_HOST': '127.0.0.1',
             'REMOTE_PORT': '64951',
             'REQUEST_METHOD': 'GET',
             'REQUEST_URI': '/callback?code=REDACTED_CODE&state=REDACTED_STATE',
             'SCRIPT_NAME': '',
             'SERVER_NAME': 'waitress.invalid',
             'SERVER_PORT': '5000',
             'SERVER_PROTOCOL': 'HTTP/1.1',
             'SERVER_SOFTWARE': 'waitress',
             'waitress.client_disconnected': <bound method HTTPChannel.check_client_disconnected of <waitress.channel.HTTPChannel connected 127.0.0.1:64951 at 0x285f5356ba0>>,
             'werkzeug.request': <Request 'http://localhost:5000/callback?code=REDACTED_CODE&state=REDACTED_STATE' [GET]>,
             'wsgi.errors': <_io.TextIOWrapper name='<stderr>' mode='w' encoding='utf-8'>,
             'wsgi.file_wrapper': <class 'waitress.buffers.ReadOnlyFileBasedBuffer'>,
             'wsgi.input': <_io.BytesIO object at 0x00000285F5391E90>,
             'wsgi.input_terminated': True,
             'wsgi.multiprocess': False,
             'wsgi.multithread': True,
             'wsgi.run_once': False,
             'wsgi.url_scheme': 'http',
             'wsgi.version': (1, 0)},
 'headers': EnvironHeaders([('Host', 'localhost:5000'), ('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0'), ('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'), ('Accept-Language', 'en-US,en;q=0.5'), ('Accept-Encoding', 'gzip, deflate, br'), ('Dnt', '1'), ('Connection', 'keep-alive'), ('Cookie', 'session=REDACTED_SESSION'), ('Upgrade-Insecure-Requests', '1'), ('Sec-Fetch-Dest', 'document'), ('Sec-Fetch-Mode', 'navigate'), ('Sec-Fetch-Site', 'cross-site')]),
 'host': 'localhost:5000',
 'json_module': <flask.json.provider.DefaultJSONProvider object at 0x00000285F5354530>,
 'method': 'GET',
 'path': '/callback',
 'query_string': b'code=REDACTED_CODE&state=REDACTED_'
                 b'_STATE',
 'remote_addr': '127.0.0.1',
 'root_path': '',
 'scheme': 'http',
 'server': ('waitress.invalid', 5000),
 'shallow': False,
 'url': 'http://localhost:5000/callback?code=REDACTED_CODE&state=REDACTED_STATE',
 'url_rule': <Rule '/callback' (GET, OPTIONS, HEAD) -> callback>,
 'view_args': {}}

Solution

  • When I tried to instantiate the OAuth2Session object it wasn't creating the correct object:

    oauth = OAuth2Session(client_id=idp["client_id"],
                          state=session['oauth_state']
                          ### MISSING PARAMETER: redirect_uri
                         )
    

    The solution is to instantiate the OAuth2Session object passing the same parameters both in login and callback:

    oauth = OAuth2Session(client_id=idp["client_id"],
                          scope=idp["scope"],
                          redirect_uri=idp["callback"]
                         )
    

    To be super clear this is the right callback function:

        @app.route("/callback")
        def callback():
            pprint(request.__dict__)
            oauth = OAuth2Session(client_id=idp["client_id"],
                                  state=session['oauth_state'],
                                  redirect_uri=idp["callback"]
                                  )
            # When I try to get the token, nothing works
            token = oauth.fetch_token(
                idp["token"],
                client_secret=idp["client_secret"],
                authorization_response=request.url
            )
            # I never reach this line
            session['oauth_token'] = token
            return "Ok, everithing is working"