f Notice The examples and use cases described here are intended to show the different ways SURF Research Access Management can be used and connected to application. These examples and use cases are not always validated by SURF.
Introduction
This guide will explain how to integrate SRAM with Python using OpenID Connect.
To achieve this, we create a basic Python web site and add SRAM connectivity to it. There are many libraries that help you with setting up OIDC in Python, but many of them need a bit of tweaking to work with SRAM, because they lack the PKCE protocol and code challenges SRAM uses. This example uses bare Python with Flask to secure a website. It doesn’t mean there are no good libraries, but this gives you hopefully a better understanding of the flow and the required steps.
Prerequisites
- Visual Studio Code (VSCode, or the editor of your preference).
- An account and a collaboration on SRAM.
- Your application registered in SRAM. You can request a application from within the SRAM interface. Once your application is available, connect it to your collaboration.
- Client ID (APP-ID) and Client Secret from SRAM your application registration.
- Callback URL registered in your SRAM application.
- Knowledge of Python and an installation on your machine, including the Package Installer for Python (pip).
Setting up SRAM
Before diving into the integration process, ensure you have properly set up SRAM:
- Register with SRAM: If you haven’t already, login to SRAM. SRAM provides you with the necessary credentials and management tools. You can use eduID to login to SRAM, if you don’t haven an institutional account.
- Register your application: After creating an account, register your application (application/platform) with SRAM. During this process, specify callback URLs, desired scopes, and other configurations.
- Configure callback URL with SRAM: Once you have decided on a callback URL (typically https://yourdomain.com/oidc_callback), you need to submit it to SRAM, often via a form in the SRAM dashboard or portal. This ensures that SRAM knows where to redirect users after they have been authenticated. It's always a good idea to include a localhost URL in there for testing the solution locally, so we have registered http://localhost:8080/oidc_callback for development purposes.
With the application registered, we can use the provided credentials (client ID and client secret) and endpoint details to prepare for your application's authentication flow with SRAM.
Step-by-step Implementation:
Building the basic app.
We start by creating a very minimal web-app using Python.
Important note: the code below follows the happy flow so we can emphasis on the working. You should not use it as is in your code. You have to make your code robust, like responsive to missing variables, servers who do not respond, expiring tokens.
In the editor create a file app.py and add code for a basic website using Flask, a commonly used web framework for Python.
from flask import Flask app = Flask(__name__) @app.route('/') def home(): return "Welcome!" if __name__ == '__main__': app.run(host='127.0.0.1', port=8080, debug=True, ssl_context='adhoc')
When this code starts it fires up a Flask website. The last two lines make sure we run on localhost (127.0.0.1) over SSL on port 8080 and that this code is started debugging properly as an app and not as when used as a package.
Note: we have registered http://localhost:8080 as one of the valid callback urls so we want this app also to run at port 8080. In Visual Studio you can add these settings to a launch.json file or specify them in code as we did above. Read Debugging configurations for Python apps in Visual Studio Code for more information.
Tip: If you run this code on localhost you might encounter SSL issues with modern browsers. Both Chrome and Edge are actively blocking SSL on localhost and need to create and trust certificate. There is however en undocumented feature in both browsers. When you get the ‘Your connection isn't private’ message, click somewhere in the page and type ‘thisisunsafe’. The message will be bypassed and the whole site will be threated as safe. You can rest this ‘hack’ by clicking the ‘Not Secure’ message in the address bar and choose Turn on warnings.
We add a private page that we want to hide behind a login.
from flask import Flask app = Flask(__name__) @app.route('/') def home(): return "Welcome!" @app.route('/private') def private(): return "Very confidential and private page!" if __name__ == '__main__': app.run(host='127.0.0.1', port=8080, debug=True, ssl_context='adhoc')
The private page can now be reached on https://localhost:8080/private or https://127.0.0.1:8080/private
The login flow in steps
To secure the private page we first need to be able to login. For this we need to create login and callback pages. The callback page is where the code returns after a successful login.
To login using SRAM we first need to request an authorisation code with the proper secrets and a code challenge to enforce PKCE protocol. This protocol uses a temporary secret to talk to the Identity Provider (SRAM) so it is a very secure and thus very popular way in public clients.
We first need to follow these steps in certain order:
- We make a requests.get call to SRAM and pass amongst others the client id and a code challenge, to request an authorization flow.
- This call will start a login flow in SRAM and when that it is successfully done, it will return to our code in the callback procedure.
- In the callback procedure we now know we have a properly authenticated SRAM user and we can continue to request the access token for the authenticated user.
This access token is basically a short-term proof that we are authenticated, and it provides an identifier for the user. The use of an access token is very powerful since it will expire after a certain period of time, so even when somebody gets hands on it, it can’t be used consistently. Technically you can extract data from the access token, but it is better practice to use specific calls for it which we will do in the next step. - With the access token we can request again the user info endpoint, to provide details on the user like email, name, roles. See Attributes in SRAM - SURF User Knowledge Base - SURF User Knowledge Base for available attributes and the scope in which they exist.
Adding SRAM settings and basics
First we add the proper variables for SRAM in a config.json file and store it in the same folder as app.py. This file has this structure:
{ "CLIENT_ID": "APP-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "CLIENT_SECRET": "—your-secret—", "DOTWELLKNOWN": "https://proxy.sram.surf.nl/.well-known/openid-configuration", "REDIRECT_URI": "https://localhost:8080/oidc_callback" }
The CLIENT_ID is the is from the SRAM application and the CLIENT_SECRET the corresponding secret. The REDIRECT_URI is the URL you registered via SURF for the SRAM application. Since we are in development now we use a http URL pointing to localhost, in production it is recommended to use a secure HTTPS URL.
The .well-known endpoint (DOTWELLKNOWN) is a special URL on the OIDC Provider (SRAM) which provides all the relevant information your OIDC application needs to talk to the server like authorization_endpoint, token_endpoint and userinfo_endpoint.
Reading the settings from a config file
These settings can be read in two lines in the app.config. We also set the app.secret_key to a random value. This secret key helps securing the session variables we will use later on.
Reading the .well-known information
The contents of the .well-known endpoint is retrieved using a simple requests.get command and parsed as a JSON structure. The contents of the resulting dictionary is then added to the app.config dictionary.
Securing the communication with the server
To secure the communication with SRAM we need to use a code challenge. This code challenge and corresponding code verifier ensure that the communication is always done from the same client. We use a standard piece of code for this and add it as an function to the code.
Putting it together
Directly after the creation of the app we set the secret key, initialize the configuration from the config file and calculate a code challenge and code verifier.
The code looks like now like this (remember to add the proper imports for the different libraries):
import base64 import hashlib import json import os import re from flask import Flask # helper function # the code challenge is an encoded string that is used to verify the identity of the client # the code verifier is a random string that is used to generate the code challenge def get_code_challenge(): code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8') code_verifier = re.sub('[^a-zA-Z0-9]+', '', code_verifier) code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8') code_challenge = code_challenge.replace('=', '') return code_verifier, code_challenge # get config from .well-known endpoint def get_config(url): result = requests.get(url) if result.status_code == 200: return json.loads(result.content) return {} app = Flask(__name__) app.secret_key = os.urandom(24) with open('config.json') as config_file: config = json.load(config_file) config_url = config['DOTWELLKNOWN'] app.config.update(config) app.config.update(get_config(config_url)) code_verifier, code_challenge = get_code_challenge() @app.route('/') def home(): return "Welcome!" @app.route('/private') def private(): return "Very confidential and private page!" if __name__ == '__main__': app.run(host ='127.0.0.1', port=8080,debug=True,)
If you run this, you won’t see any changes since it all was just preparation, now let’s add the authentication steps.
The login page
We need to create a login routine which basically redirects the user to the SRAM login page, such that the user can login through their own organisation. That page will handle the authentication and return the results to our callback page, so we need to create those pages.
So we create a login page and a routine that actually returns the proper SRAM login URL. The login routine will get simply get that URL and redirect to it.
def get_auth_url(): return requests.get( url=app.config['authorization_endpoint'], params={ "response_type": "code", "client_id": app.config['CLIENT_ID'], "scope": "openid profile email", "redirect_uri": app.config['REDIRECT_URI'], "code_challenge": code_challenge, "code_challenge_method": "S256", }, allow_redirects=False ).url
Note: the ‘authorization_endpoint’ is read in the config and is one of the values retrieved from the request to the.well-known information.
The get_auth_url routine calls SRAM to retrieve an authorization request with the proper SRAM variables. Note the scope, which is set to openid, profile and email. These scopes determine the data that can be retrieved later on with the retrieved access token. We inject the code challenge, so only our code is able to extract the actual access token.
We can now simply add this function to a login pag
@app.route('/login') def login(): return redirect(get_auth_url())
Note, this page just redirects to the URL generated by the get_auth_url function. We could have done the request also directly in the login, but this way is a bit nicer. The application has now a login page which can be reached by going to https://localhost:8080/login .
The callback page
The code should now fire up SRAM and you can sign in and even return in your application, but there is no callback routine yet. The purpose of that routine is to use the result from the authorisation request and request an access token. In the request, we inject certain local variables as the code verifier and client identifier, etc. again to make sure that the one requesting the access token is indeed the one that set up the first authorisation request.
def get_access_token(code, code_verifier): token_params = { 'grant_type': 'authorization_code', 'code': code, 'client_id': app.config['CLIENT_ID'], 'client_secret': app.config['CLIENT_SECRET'], 'redirect_uri': app.config['REDIRECT_URI'], 'code_verifier': code_verifier, } result= requests.post(app.config['token_endpoint'], data=token_params) if (result.status_code != 200): return None return result.json()
Note that this routine uses a variable code which was not mentioned before, but it is part of the result of the data send to the callback function, so we will get to that later.
Of course we can get a refusal or error from the server. Only when we get a success status we should accept the result as an access token. Therefore we simply check if the status_code is unsuccessful (!=200) in which we return None.
Now we have the function for the access_token we can use it in the callback who also handles the ‘code' variable.
@app.route('/oidc_callback') def oidc_callback(): code = request.args.get('code') access_token = get_access_token(code, code_verifier) if access_token is None: return "Error while getting access token" session['access_token'] = access_token return redirect('/')
In this routine we see that the code is part of the request that is returned. We use to get the access token and store that in a session variable.
If all is successful, we will return to the home page. We are signed in, but we cannot see anything yet of it. Let’s add a function to determine wether are sign in or not. We can do this by looking at the existence of the access_token in the session variables since we only set it if and only if it was successfully retrieved.
def is_logged_in(): return 'access_token' in session
Note: depending on your app you need to work with the access_token or the id_token (which is also returned). There are many resources on the internet that explain the difference between them, but since we use it to communicate with the SRAM API best use is the access_token.
Now we have all to beautify the login page a bit, so we can see we authenticated properly, and maybe we can add a logout page as well so we can use that as well.
@app.route('/') def home(): if is_logged_in(): return f""" <p>Welcome authenticated user!</p> <p>You may now access the private page <a href='/private'>here</a></p> <p><a href="/logout">Logout</a></p> """ return "<p>Welcome!<br/><a href='/login'>Login here</a></p>"
and for the logout page we simply remove the access_token from the session variables and return to the homepage.
@app.route('/logout') def logout(): session.pop('access_token', None) return redirect('/')
With this we can log in and log out. Now we can use that simple routine to secure our private page.
@app.route('/private') def private(): if not is_logged_in(): return "<p>You are not logged in.<br/><a href='/login'>Login here</a></p>" return "Very confidential and private page!"
You can now go to the private page https://localhost:8080/private when you are logged in only.
Adding user information
To add user information we can do a request to the user info endpoint and query it using the access token.
def get_userinfo(access_token): if(is_logged_in() == False): return None result= requests.post(url=app.config[userinfo_endpoint’],data= access_token) if(result.status_code != 200): return None return result.json()
If successful, this will return a JSON object with the user information belonging to the scopes defined in the first authorization call.
So we change the home page a bit
@app.route('/') def home(): if is_logged_in(): access_token = session['access_token'] userinfo = get_userinfo(access_token) return f""" <p>Welcome, {html.escape(userinfo['name'])}!</p> <p>Your email is {html.escape(userinfo['email'])}</p> <p>You may now access the private page <a href='/private'>here</a></p> <p><a href="/logout">Logout</a></p> """ return "<p>Welcome!<br/><a href='/login'>Login here</a></p>"
Note: we have used several libraries in the code above. Just for conveniences, if you are stuck: here is the list of the libraries
import base64 import hashlib import html import json import os import re from flask import Flask, redirect, request, session import requests
And we are there! You can log in using SRAM, get user information and log out, plus you can secure pages in a simple way.
Conclusion
By following the above steps, you have successfully set up OIDC authentication in a Python web application using Visual Studio Code.
The complete code
#!/usr/bin/env python import base64 import hashlib import html import json import os import re from flask import Flask, redirect, request, session import requests # helper function # the code challenge is an encoded string that is used to verify the identity # of the client the code verifier is a random string that is used to generate # the code challenge def get_code_challenge(): code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8') code_verifier = re.sub('[^a-zA-Z0-9]+', '', code_verifier) code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8') code_challenge = code_challenge.replace('=', '') return code_verifier, code_challenge # request the initial authorization code from the SRAM server def get_auth_url(): return requests.get( url=app.config['authorization_endpoint'], params={ "response_type": "code", "client_id": app.config['CLIENT_ID'], "scope": "openid profile email", "redirect_uri": app.config['REDIRECT_URI'], "code_challenge": code_challenge, "code_challenge_method": "S256", }, allow_redirects=False ).url # request the access token from the SRAM server using the code from the previous step def get_access_token(code, code_verifier): token_params = { 'grant_type': 'authorization_code', 'code': code, 'client_id': app.config['CLIENT_ID'], 'client_secret': app.config['CLIENT_SECRET'], 'redirect_uri': app.config['REDIRECT_URI'], 'code_verifier': code_verifier, } result = requests.post(app.config['token_endpoint'], data=token_params) if (result.status_code != 200): return None return result.json() # determine if the user is logged in def is_logged_in(): return 'access_token' in session # get the userinfo from the SRAM server using the access token from the previously retrieved access token def get_userinfo(access_token): if (is_logged_in() is False): return None result = requests.post(url=app.config['userinfo_endpoint'], data=access_token) if (result.status_code != 200): return None return result.json() # get config from .well-known endpoint def get_config(url): result = requests.get(url) if result.status_code == 200: return json.loads(result.content) return {} app = Flask(__name__) app.secret_key = os.urandom(24) # read the configuration from the config.json file with open('config.json') as config_file: config = json.load(config_file) app.config.update(config) config_url = config['DOTWELLKNOWN'] app.config.update(get_config(config_url)) # call the helper function to get the code verifier and code challenge code_verifier, code_challenge = get_code_challenge() # the login route redirects the user to authorization URL of the SRAM server @app.route('/login') def login(): return redirect(get_auth_url()) # the authorization callback route is called by the SRAM server after the user # has logged in @app.route('/oidc_callback') def oidc_callback(): code = request.args.get('code') access_token = get_access_token(code, code_verifier) if access_token is None: return "Error while getting access token" session['access_token'] = access_token return redirect('/') # the logout route clears the session and redirects the user to the home page @app.route('/logout') def logout(): session.pop('access_token', None) return redirect('/') # the home route displays the user information if the user is logged in # else it shows that the user is not logged in @app.route('/') def home(): if is_logged_in(): access_token = session['access_token'] userinfo = get_userinfo(access_token) return f""" <p>Welcome, {html.escape(userinfo['name'])}!</p> <p>Your email is {html.escape(userinfo['email'])}</p> <p>You may now access the private page <a href='/private'>here</a></p> <p><a href="/logout">Logout</a></p> """ return "<p>Welcome!<br/><a href='/login'>Login here</a></p>" # the private page is only displayed if the user is logged in # else it shows that the user is not logged in @app.route('/private') def private(): if not is_logged_in(): return "<p>You are not logged in.<br/><a href='/login'>Login here</a></p>" return "Very confidential and private page!" # run the app and make sure we are in debug mode if __name__ == '__main__': app.run(host='127.0.0.1', port=8080, debug=True, ssl_context='adhoc')