Understanding Authentication: OpenID Connect

Last time, we made users sign in with yet another password to see their borrowed books in the library.

Improving on this, we'll ask a Trusted third party authenticator (Microsoft in this case) to vouch for them. This way we let offload the authentication so that we can focus on our critical business login of showing overdue books to the user.

A real world analogy will be to hire a security guard to check IDs of people before they enter the library instead of doing it yourself.

In this blog we will learn how to outsource user authentication to Microsoft using OpenID Connect, which rides on the mighty OAuth2. Instead of storing credentials, we delegate the entire process to a trusted third-party (Microsoft in our case).

The source code can be found here: niketansrane/auth101

Since this has a lot of moving parts involved, let's break it down into different sequential flows which will help us understand how OpenID can be used for logging users to your webapp.

In case you do not want the descriptive post, here is a quick diagram that shows how delegated authentication (using OpenID) works.

Remember that, before any of this can take place, the library (client) had to go to Authorization Server (Microsoft in this case) and register itself as a known client. As part of this process, the client has received a unique identifier and a secret which client can use to prove it's identity when it sends requests to authorization token endpoint particularly during the grant to token exchange flow.

Let's start. This is the moment where our user visits the website's home page and clicks "Sign in with Microsoft".

One thing I would like to clarify that it you will see the terms client and web server being used interchangeably. This is because in our case the web server is requesting some information about the user from the authority and hence acting as a client in this authorization flow.

Authorization Grant Flow

Once the user click on "Sign in with Microsoft", the web server hosting receives a request on its `microsoftlogin` endpoint. The web server (acting as client in this case) follows the OAuth specifications and redirects the user to Authorization Server's authorization endpoint. This is allowed since in this case Microsoft(Authority) will be asking for Microsoft credentials from the user.

The request is crafted essentially to say "Hey, this user wants to sign in using Microsoft, can you please verify this is a legit user and once you are done verifying, send me a code at this redirect URI."

Here is how the request will look like.

https://login.microsoftonline.com/common/oauth2/v2.0/authorize?
response_type=code
&client_id=XXX87a4-b7bd-45f2-a53b-b3d537d3XXX
&redirect_uri=https://auth101.azurewebsites.net/callback
&scope=openid

The authorization server then asks for user credentials and upon validation displays a small page to the user that says, "Hey, I have this client (xyz) who wants to access your name/email address. Do you consent to that?"

Once the user consents, the authorization server sends a grant code to the redirect URI(`https://auth101.azurewebsites.net/callback`).

All of this can be written in our web server with few lines of code.

from oauthlib.oauth2 import WebApplicationClient

AUTH_ENDPOINT="https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
REDIRECT_URI="https://auth101.azurewebsites.net/callback"
CLIENT_ID = "XXX87a4-b7bd-45f2-a53b-b3d537d3XXX"
SCOPE="openid"

@app.get("/microsoftlogin")
def read_login():
    web_client = WebApplicationClient(CLIENT_ID)
    request_uri = web_client.prepare_request_uri(
                     AUTH_ENDPOINT,
                     REDIRECT_URI,
                     SCOPE
                  )
    return RedirectResponse(url=request_uri, status_code=302)

Grant To Token Exchange Flow

Once the web application received the code on it's `callback` endpoint, it will reach out to token endpoint of authorization server and with the authorization code and say, "The user has consented to me reading his emai.id and here is the proof of that (code). Also I am this particular client and here is my proof (client ID + client secret). Now please give me access to his email."

The request will look like:

grant_type=authorization_code
&client_id=XXX87a4-b7bd-45f2-a53b-b3d537d3XXX
&client_secret="dsfd*****"
&code=1.ARoAh...
&redirect_uri=https://auth101.azurewebsites.net/callback

Note that the redirect URI in both requests should match. This is a validation on the that the authorization server will perform before sharing the token.

Implementation:

from oauthlib.oauth2 import WebApplicationClient

TOKEN_URL="https://login.microsoftonline.com/common/oauth2/v2.0/authorize/oauth2/v2.0/token"
REDIRECT_URI="https://auth101.azurewebsites.net/callback"
CLIENT_ID = "XXX87a4-b7bd-45f2-a53b-b3d537d3XXX"
CLIENT_SECRET="dsfd******"

@app.get("/callback")
def callback(code: str):
    web_client = WebApplicationClient(CLIENT_ID)
    request_body = web_client.prepare_request_body(code, REDIRECT_URI, CLIENT_SECRET)

    response = requests.post(url=TOKEN_URL, data=request_body)
    web_client.parse_request_body_response(response.text)
    token = web_client.access_token

    # DECODE THE TOKEN AND GET USER INFO
    decoded = jwt.decode(token, options={"verify_signature": False})
    username = decoded.get("unique_name")
    username = decoded.get("sub") if username is None else username

Session Setup Flow

Now that the web application has user information, it can set a cookie so that subsequent requests for the session can use the same token and need not do perform all those flows again for next X minutes.

....

    decoded = jwt.decode(token, options={"verify_signature": False})
    username = decoded.get("unique_name")
    username = decoded.get("sub") if username is None else username

    response = RedirectResponse(url="/books", status_code=302)
    response.set_cookie(key="username", value=username)
    return response

Now that the cookie is set, the browser makes a request to /books:

If the cookie is present, we let Niketan in. He sees the books page personalized with his name and a book list.

@app.get("/books", response_class=HTMLResponse)
def books(request: Request):
    username = request.cookies.get("username")
    if not username:
        return RedirectResponse("/")
    
    books = get_random_books()
    return templates.TemplateResponse(
        "borrowed_books.html",
        {"request": request, "username": username, "books": books}
    )

In the next post, we will check how OAuth (delegated authorization) works using the same example. Happy coding till then.