Package: oatk
OAuthToolKitA collection of useful functions for dealing with OAuthInstallationMinimal survival command...% pip install oatkRationale and OverviewFederated login using OAuth/OpenIDConnect/... is a de facto standard for authenticating and authorizing users nowadays. You simply don't want to deal with them in your application at all. The underlying principle is simple: have someone you trust tell you that you can trust a user and get your own personal token that proves you're operating for that user when accessing other resources, who can validate this token with the trusted authenticating party.This OAuthTooKit brings together very basic implementations of several aspects of this flow, aiming to have this up and running quickly in a development context, without the hassle of setting up a full-fletched OAuth server or having to go through many hoops in configuring an existing service to accept your application and users.In a nutshell, the toolkit contains:a Python module to create and validate tokensa Flask route decorator to validate tokensa Javascript client-library to handle an OAuth flow using PKCEa "fake" OAuth servera command line tool to work with all the functionalityEssentially, the toolkit allows... running a fake OAuth server ...%oatkserverrun... creating a protected API ...fromflaskimportFlaskfromflask_restfulimportResource,ApifromoatkimportOAuthToolkitapp=Flask(__name__)api=Api(app)oatk=OAuthToolkit()oatk.with_jwks("certs.json")classHelloWorld(Resource):@oatk.authenticated_with_claims(username="xtof")defget(self):return{"hello":"world"}api.add_resource(HelloWorld,"/api/hello")... and access it with an authenticated user ...<scriptsrc="oatk.js"></script><script>oatk.with_authenticated(function(user,http){// at this point, the user is authenticated and we have an object with his// information...console.log(oatk.user)// ... and a way to get and post with his authenticating token:http.get("http://localhost:5001/api/hello",function(response){console.log("authenticated response",response);});});</script>And now you can focus on your actual application functionality ;-)Okay ... why not use a fancy-pants, big and bloathed "de facto standard" library or framework? Because initially I want to understand what I'm doing, to be able to make it work. Later, given this working and understood setup, through refactoring such 3rd party solution can be incorporated - if needed, since most of my works ends at the proof-of-concept-phase anyway ;-)The toolkit therefore aims to be completely transparant, implementing maybe only a small "happy" path, taking you straigth from point A to B. You can read all code and easily understand it and implement the basics.Before we start...You need a private key pair:%opensslgenrsa-outprivate_key.pem2048Generating RSA private key, 2048 bit long modulus.................................................................+++..+++e is 65537 (0x10001)%opensslrsa-inprivate_key.pem-outformPEM-pubout-outpublic_key.pemwriting RSA keyYou know the drill: keep your private key safe!Using the CLILet's see what the toolkit has to offer from the command line.With our public key, we can generate a JSON Web Key Set (JWKS), which is the typical format to publish our public key:%oatkwith_publicpublic_key.pemjwks|teecerts.json{"keys": [{"n": "43CteJFzpZAa_h2-HFKCgTGTzdguocBE4FgxlqDgQdxIzD4-hTAQ3JXuu2OLt1AcDgvT5MNEJtbQzsXtbpC_HajcC5o0S-PKtZaqA-KZwNEPlL_Fi1WIb6paoJsk7k_C4q1ZBvqrMF2pLz7pgUOeXdZ6_6FiXacIrhmmSvjWOIqjcIC8SAApsxI4gjNHKp2TubTYbw6gxicFVvDk_kA1pSUdOotRD1v0mweLMkZMHORLgcajne0GulQlDuFADAFILS4tAz5BWV6zIJnS2W1Tv8zy_g_Y2N9NoN3tbn4ii7PSZgZzVbRy3bgry6EiuMC4RcPdM5AlPL29s354vPUttQ","e": "AQAB","kty": "RSA","alg": "RS256","kid": "5171a100-e4c8-49ed-94af-6bc8fa635368"}]}With this information out in the open, we can now create tokens that can be validated:%oatkwith_privateprivate_key.pemwith_jwkscerts.jsonclaims'{ "hello" : "world" }'token|teetoken.txteyJhbGciOiJSUzI1NiIsImtpZCI6IjUxNzFhMTAwLWU0YzgtNDllZC05NGFmLTZiYzhmYTYzNTM2OCIsInR5cCI6IkpXVCJ9.eyJoZWxsbyI6IndvcmxkIn0.03XJ4MrNhhoB9ouQ6oPBehR9iSZxYUhfgZ2j0YV-_vqyBDwDV26GR0wpxhxrPu7XfnbeIRyqO8qFL_WKk6tn_32F7aMYdfK6lKtqRHIkTLDux4GtzXkpqHAuQPBZdD5W4xfrBbDQ6ItHwptMP_wVwuN0OQaR3X8Lz2UNpy3FWuMpJBbfPaaV8_E62plMIKOd92CHuafSgFOZOQMsvmxWCz-ylG2Kh4Kc77_CoDJABYsI2VbDK9bNG3C3x3oNH1KylDvmvtSgapQjhewFiYgvjVJWAMtU22TX_27BUGwbnMwni42jsJoHsI9GpQmd-IVtOnMA0wigd1A4xATfGBQF3Q%cattoken.txt|pbcopy%oatkwith_jwkscerts.jsonfrom_clipboardvalidate%oatkwith_jwkscerts.jsonfrom_filetoken.txtvalidate%oatkfrom_filetoken.txtdecodehello: worldModuleThe command line tool is in fact afired-upversion of the OAuthToolkit class. So everything we just did looks exactly the same...>>>fromoatkimportOAuthToolkit>>>oauth=OAuthToolkit()>>>oauth.with_private("private_key.pem")<oatk.OAuthToolkit object at 0x1014c3760>>>>oauth.with_jwks("certs.json")<oatk.OAuthToolkit object at 0x1014c3760>>>>oauth.claims({"hello":"world"})<oatk.OAuthToolkit object at 0x1014c3760>>>>token=oauth.token>>>token'eyJhbGciOiJSUzI1NiIsImtpZCI6IjUxNzFhMTAwLWU0YzgtNDllZC05NGFmLTZiYzhmYTYzNTM2OCIsInR5cCI6IkpXVCJ9.eyJoZWxsbyI6IndvcmxkIn0.03XJ4MrNhhoB9ouQ6oPBehR9iSZxYUhfgZ2j0YV-_vqyBDwDV26GR0wpxhxrPu7XfnbeIRyqO8qFL_WKk6tn_32F7aMYdfK6lKtqRHIkTLDux4GtzXkpqHAuQPBZdD5W4xfrBbDQ6ItHwptMP_wVwuN0OQaR3X8Lz2UNpy3FWuMpJBbfPaaV8_E62plMIKOd92CHuafSgFOZOQMsvmxWCz-ylG2Kh4Kc77_CoDJABYsI2VbDK9bNG3C3x3oNH1KylDvmvtSgapQjhewFiYgvjVJWAMtU22TX_27BUGwbnMwni42jsJoHsI9GpQmd-IVtOnMA0wigd1A4xATfGBQF3Q'>>>oauth.validate(token)>>>oauth.decode(token){'hello': 'world'}All methods of the OAuthToolkit class return the object itself. This enables chaining method calls, which is mostly for nicer command line support ;-)Theexamples/create-and-validate.pyexample presents this functionality in a ready to run format:PYTHONPATH=. python examples/create-and-validate.pyAnd no output means good output in this case ;-)A validating decorator for Flask routesThe OAuthToolkit class also provides a convenient way to decorate Flask routes. Take a look atexamples/web.py:fromflaskimportFlask,request,Responsefromflask_restfulimportResource,ApifromoatkimportOAuthToolkitapp=Flask(__name__)api=Api(app)oatk=OAuthToolkit()oatk.with_private("private_key.pem")oatk.with_jwks("certs.json")@app.route("/")@oatk.authenticateddefhello_world():return"<p>Hello, World!</p>"classHelloWorld(Resource):@oatk.authenticated_with_claims(username="xtof")defget(self):return{"hello":"world"}api.add_resource(HelloWorld,"/api/hello")classToken(Resource):defget(self):claims={}forclaim,valueinrequest.args.items():if","invalue:value=value.split(",")claims[claim]=valuetoken=oatk.claims(**claims).tokenreturnResponse(response=token,status=200,mimetype="plain/text")api.add_resource(Token,"/api/token")It's a more elaborate version from the one presented above in the rationale. It adds a plain Flask route and also a Token resource. We can now request a token and use it. First launch the web application...%gunicornexamples.web:app[2022-10-15 13:32:29 +0200] [84085] [INFO] Starting gunicorn 20.1.0[2022-10-15 13:32:29 +0200] [84085] [INFO] Listening at: http://127.0.0.1:8000 (84085)[2022-10-15 13:32:29 +0200] [84085] [INFO] Using worker: sync[2022-10-15 13:32:29 +0200] [84099] [INFO] Booting worker with pid: 84099... and then call it:first to get a token with a claimusername=xtofthen call the Flask route, which requires any authenticated userthen call the Flask-RESTful route, which requires the username=xtof claim%curl-s"http://localhost:8000/api/token?username=xtof"|pbcopy%curlhttp://localhost:8000/-H"Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjUxNzFhMTAwLWU0YzgtNDllZC05NGFmLTZiYzhmYTYzNTM2OCIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Inh0b2YifQ.Yh6EiNFzOcldoqXLi7TyFannS4LGt05v_XhKDbW8MrKR3VgbH-ZpueSNO3A_Esrupz1cIpEBfNWCnw8JaUjQhOOIt47teQI5RejQtRm8bply93DovLlPsE5Fu5gjiATYBj6KA6Hlg1MDRYOsifN4LTQoSXwcWiKo-OIl-iapFDAwkrgv9SQLexslKKuhTqLSI8PLh0jL32GLZCVULsPNs7Eqrm_-HozwMIPKyN6uX0MNt2eQiBy3BtWmL5ElX6MtbP-mz2B186VKFwpmncdEOKmy72cup77WbVOhJt8ml-XO5kDh2UPQqri6HLIS4mPuQi0cCKbLbpyoHmhGHEVNcA"<p>Hello, World!</p>%curlhttp://localhost:8000/api/hello-H"Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjUxNzFhMTAwLWU0YzgtNDllZC05NGFmLTZiYzhmYTYzNTM2OCIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Inh0b2YifQ.Yh6EiNFzOcldoqXLi7TyFannS4LGt05v_XhKDbW8MrKR3VgbH-ZpueSNO3A_Esrupz1cIpEBfNWCnw8JaUjQhOOIt47teQI5RejQtRm8bply93DovLlPsE5Fu5gjiATYBj6KA6Hlg1MDRYOsifN4LTQoSXwcWiKo-OIl-iapFDAwkrgv9SQLexslKKuhTqLSI8PLh0jL32GLZCVULsPNs7Eqrm_-HozwMIPKyN6uX0MNt2eQiBy3BtWmL5ElX6MtbP-mz2B186VKFwpmncdEOKmy72cup77WbVOhJt8ml-XO5kDh2UPQqri6HLIS4mPuQi0cCKbLbpyoHmhGHEVNcA"{"hello": "world"}With any other token, or no token, the calls fail...%curl-s"http://localhost:8000/api/token?username=notxtof"|pbcopy%curlhttp://localhost:8000/api/hello-H"Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjUxNzFhMTAwLWU0YzgtNDllZC05NGFmLTZiYzhmYTYzNTM2OCIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im5vdHh0b2YifQ.mjkspQOyUm1yMimd2CMkRsMO-O5Yrrm4B7v32_dJYhYOFnsqcwLD3fD0LBa3kxLsUaSRutdj8Q7PtRyKXmpLxBcW6N7LoIaCqHHVuy8fAiP74bDfSZ5UbXUFE1xmesipCEf_1XyNUiIuHWmariv5Auzfs4iV_VpQLG2qdz09zEDKMZ2_mpjv2PCX6hPRlv7iwmDyp8SZ2gin8QnN3ZW4D5Ki7i3jeHK4eUcVxdy1OZPkUWisbbFbrkjqBxLmio2l2jkImhhHSq8dHB6O9rk7Etel9FTCXhhxE-RRR-l8ygoMXLjMmYrKqJfLPMWulv7w7Mat_TPWcJ5b4IF_meII3Q"ValueError("claim username hasn't required value")%%curlhttp://localhost:8000Missing Authorization%Fake ServerThe toolkit includes a fake OAuth server that allows testing applications you build without the need for a full-fletched setup. The fake server implements a minimum flow with little real blocking aspects, and allows you to focus on your application, not the OAuth flow by itself ;-)The server runs by default ashttp://localhost:5000and essentially provides the following service routes:/oauth/well-knownreturning a well-known configuration set, with all other URLs of service routes it offers, as described by the following - so you only need to configure this one./oauth/certsproviding the (public) key(s) that can be used to validate signatures in JSON Web Key Set (JWKS) format./oauth/authorizeused to obtain an authorization code from the authenticated end user/oauth/tokenused to exchange an authorization code for an access token/oauth/userinfoprovides a user information record, given an access tokenThese service routes enable the following OAuth workflow between your application and an OAuth server:get thewell-knownconfiguration(optionally) get thejwks_uri, with public key(s)visit theauthorization_endpoint, providing aclient_id,scope,response_type, aredirect_uriandnonce.after succesfull authorization, you're redirected to yourredirect_uri(if it was previously registered as an acceptable one), along with acodequery parameter, containing your authorization code.post to thetoken_endpointwith JSON fragment containinggrant_type("authorization_code") and acode(containing the obtained code)the response is a JSON fragment containing anaccess_token.get theuserinfo_endpointwith theaccess_tokenas aBearerAuthorizationheaderVisually:Bringing everything together...Let's fire up our fake OAuth server, our minimal web API and create a web application to interact with the user.The root-level Makefile contains targets that automate the require command line instructions. Make will also output these, so you can inspect what is done for you on the first line of the output.%makeserverpython -m oatk with_private private_key.pem with_jwks certs.json server run* Serving Flask app 'oatk.fake'* Debug mode: off[2022-10-15 16:23:31 +0200] [werkzeug] [INFO] WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.* Running on http://127.0.0.1:5000[2022-10-15 16:23:31 +0200] [werkzeug] [INFO] Press CTRL+C to quit%makeappgunicorn -b 0.0.0.0:5001 -k eventlet -w 1 examples.client.app:server[2022-10-15 16:15:26 +0200] [98606] [INFO] Starting gunicorn 20.1.0[2022-10-15 16:15:26 +0200] [98606] [INFO] Listening at: http://0.0.0.0:5001 (98606)[2022-10-15 16:15:26 +0200] [98606] [INFO] Using worker: eventlet[2022-10-15 16:15:26 +0200] [98620] [INFO] Booting worker with pid: 98620%makeapigunicorn -b 0.0.0.0:5002 -k eventlet -w 1 examples.web:app[2022-10-15 16:26:13 +0200] [99984] [INFO] Starting gunicorn 20.1.0[2022-10-15 16:26:13 +0200] [99984] [INFO] Listening at: http://0.0.0.0:5002 (99984)[2022-10-15 16:26:13 +0200] [99984] [INFO] Using worker: eventlet[2022-10-15 16:26:13 +0200] [99998] [INFO] Booting worker with pid: 99998Three servers are up and running now:the fake OAuth server onhttp://localhost:5000the web application onhttp://localhost:5001the api application onhttp://localhost:5002We first visit the fake OAuth server to setup a user and a client registration:Let's login/signup asxtof:Now, create a client registration...The defaults are all set for this demo, so just submit them...So we now have an OAuth server, with a userxtofand a client registration for an application known by the clientIdtest.If you hit the "log out" link now, you get back to the first screenshot. If you actually do this, in a few moments you'll have to log in again, else, since you're still logged in, you will see nothing really happen at first... ;-)Our web application is very simple: it requires and authenticated user and once it has it, it calls our protected web API to say hello...<divid="app"style="display:none;"><astyle="float:right"id="logout"href="#">logout</a><h1>My App</h1><divid="output"></div></div><scriptsrc="static/jquery-3.6.1.min.js"></script><scriptsrc="http://localhost:5000/static/oatk.js"></script><script>oatk.with_authenticated_user(function(user,http,logout){console.log("👩 user is authenticated...",user);$("#app").show();// time to show the application$("#logout").click(logout);// wire the logout action// call our API to say hellohttp.getJSON("http://localhost:5002/api/hello",function(result){$("#output").text(JSON.stringify(result));});});</script>Let's visit it...Did we go to the wrong server? No, our application redirected us to the (fake) OAuth server, based on its known configuration. It could get the URLs, but didn't have an authorization code, so it redirected to the authorization endpoint. And since we're not logged in, because we logged out earlier, we now have to log in again, asxtof.We explicitly have to give consent for the application to use our information and act on our behalf. After that, we are again redirected to our application that now continues the OAuth flow...... and calls our API that requires authentication and authorization using our OAuth token.I've checked the console log preservation flag, to see the entire flow more clearly as it happens behind the scenes. You can see that upon returning to our app we now do have an authorization code, start another call to the server to exchange the code for a token, which results in a full set of URLs, code and token, so we can call our API and display the result.GoogleTheexamples/folder contains another example, which is targetting Google as an authentication provider. The implicit flow is a little different, providing an access token (calledid_token) immediately after logon and consent. Theid_tokenkan be used as a JWT access token, yet it cannot be refreshed from the javascript client side.So for now, I use it to authenticate the user once and set up an application-level session.Theexamples/google/templates/home.htmlfile shows the minimal client-side HTML/javascript implementation:<divid="landing"><aid="login"href="#">login with google</a></div><divid="app"style="display:none;"><astyle="float:right"id="logout"href="#">logout</a><h1>My App</h1><divid="output"></div></div><scriptsrc="/static/jquery-3.6.1.min.js"></script><scriptsrc="/oatk.js"></script><script>oatk.using_provider("{{ OAUTH_PROVIDER }}");oatk.using_client_id("{{ OAUTH_CLIENT_ID }}");oatk.apply_flow("implicit");functionshow_landing(){$("#app").hide();$("#landing").show();}functionshow_app(){$("#app").show();$("#landing").hide();}functionlogin(){oatk.with_authenticated_user(function(user,http,logout){console.log("👩 user is authenticated...",user);$("#logout").click(function(){logout(show_landing);});show_app();// call our API to say hellohttp.getJSON("http://localhost:8000/api/hello",function(result){$("#output").text(JSON.stringify(result));},function(result){if(result.status==403){console.log(result);$("#output").text("You were authenticated by Google, yet you don't have the correct claims.");}});});}$("#login").click(login);if(oatk.have_authenticated_user()){login();}</script>At the server-side of our application we need to:serve oatk.js from the package (e.g. using Flask)protect our endpoints with authorisation logic (e.g. using Flask_restful)fromflaskimportFlask,render_template,Responsefromflask_restfulimportResource,Apiimportoatk.jsfromoatkimportOAuthToolkitserver=Flask(__name__)# route to load web app@server.route("/",methods=["GET"])defhome():returnrender_template("home.html",**os.environ)# route for oatk.js from the oatk package@server.route("/oatk.js",methods=["GET"])defoatk_script():returnResponse(oatk.js.as_src(),mimetype="application/javascript")# API set upapi=Api(server)# setup oatkauth=OAuthToolkit()auth.using_provider(os.environ["OAUTH_PROVIDER"]);auth.with_client_id(os.environ["OAUTH_CLIENT_ID"])defvalidate_name(name):returnname=="Christophe VG"classHelloWorld(Resource):@auth.authenticated_with_claims(name=validate_name)defget(self):return{"hello":"world"}api.add_resource(HelloWorld,"/api/hello")If youregister an application with Googleand addlocalhost:8000as a authorized Javascript source and redirect URL for the credentials, you can run the example usinggunicorn -k eventlet -w 1 examples.google.app:server, which triggers the following flow: