Search code examples
flaskcookiesoauthopenid-connectauth0

Auth0 Python quickstart - validation of signature and expiry time in ID token


I'm building a Python Flask app with Auth0 as my OpenID Connect provider. The Auth0 website provides some skeleton code, which I'm using as the starting point for my app. The skeleton code works and is easily extensible; however, there are some ambiguities as to what the code is doing and why the behaviour adheres to modern security standards. I can't afford to be less than 100% confident when it comes to security, so I would like to run these ambiguities past the experts here at StackOverflow.

My understanding of the skeleton code

The following is the sequence of events that occur when a user interacts with this skeleton app. (See below for the code.)

  1. The user opens http://localhost:3000/ in the browser. This invokes the home endpoint in the Flask app. At this point in time, the user does not have a session cookie, so the response is some HTML containing the words "Hello guest" and a login button.
  2. The user clicks on the login button, which is a link to http://localhost:3000/login. This invokes the login endpoint in the Flask app, which redirects the user to Auth0's login box.
  3. The user enters their email and username into Auth0's login box. The user is redirected to http://localhost:3000/callback; the authorisation code generated by the successful login is passed in the query string in this URL.
  4. The callback endpoint in the Flask app is invoked. The authorisation code is sent to Auth0 in exchange for an ID token and an access token. A session cookie is set, containing this ID token and access token. The user is redirected to http://localhost:3000/.
  5. The home endpoint is invoked again. This time, the user has a session cookie. The endpoint returns some HTML containing the text Welcome {username}, plus further info about the user contained inside the ID token in the session cookie.

And how exactly is the session cookie constructed? My understanding is that the session cookie contains the ID token and access token, plus a signature. The signature is created using the Flask app's secret key (see line 8 in the code sample below).


Question 1: JWT signature verification

The ID token is a JWT. Being a JWT, the ID token contains a signature. This signature is signed using Auth0's private key, and can be verified using Auth0's public key (also known as the JWK).

Since Auth0 goes to the trouble of signing the ID token, I would expect that any endpoint in our Flask app that uses the ID token as a proof of the user's identity ought to verify the signature on the ID token using Auth0's public key. Otherwise, what's the point in Auth0 signing the ID token?

But the home endpoint uses the ID token as proof of the user's identity, and it does not verify the signature on the ID token! (At least, that's the impression I get by reading the code and ctrl-clicking through the library methods. That said, I'm not too confident in this assertion since the authlib.integrations.flask_client library is not very friendly for ctrl-clicking.)

My questions are:

  • Am I correct that the signature inside the ID token does not get verified by the home endpoint?
  • Assuming I'm correct, then is this a problem? Should I fix it?

Warning: There are two signatures in this setup:

  • The signature inside the ID token, which is signed by Auth0's private key and can be verified using Auth0's public key.
  • The signature on the session cookie, which is signed using the Flask app's secret key.

It is impossible for an attacker to forge a session cookie, because the attacker doesn't have the Flask app's secret key, and so is unable to create a valid signature for the session cookie. So to my naive mind, the app seems secure. Sure, the app fails to verify the signature in the ID token, but it makes up for this by verifying the signature on the session cookie.

Still, I suspect that the signature in the ID token has got to be there for a good reason, presumably to defend against some kind of attack that my non-expert brain hasn't thought of. It's likely that I'm missing something.

Question 2: JWT expiry time verification

The ID token contains an expiry time. As far as I can tell, the home endpoint doesn't check that the ID tokens hasn't expired.

Again, I'm not 100% sure if I'm right about this.

My questions are:

  • Is it really the case that the home endpoint doesn't check for expiry of the ID token?
  • If so, then is this a mistake?

Finally, feel free to correct any misconceptions I have and/or suggest better choice of terminology. I'm not an expert and I'm here to learn.


Code samples:

server.py

import json
from os import environ as env

from authlib.integrations.flask_client import OAuth
from flask import Flask, redirect, render_template, session, url_for
    
app = Flask(__name__)
app.secret_key = env.get("APP_SECRET_KEY")

oauth = OAuth(app)

oauth.register(
    "auth0",
    client_id=env.get("AUTH0_CLIENT_ID"),
    client_secret=env.get("AUTH0_CLIENT_SECRET"),
    client_kwargs={"scope": "openid profile email"},
    server_metadata_url=f'https://{env.get("AUTH0_DOMAIN")}/.well-known/openid-configuration'
)

@app.route("/login")
def login():
    return oauth.auth0.authorize_redirect(
        redirect_uri=url_for("callback", _external=True)
    )

@app.route("/callback", methods=["GET", "POST"])
def callback():
    token = oauth.auth0.authorize_access_token()
    session["user"] = token
    return redirect("/")

@app.route("/")
def home():
    return render_template(
      "home.html",
      session=session.get('user'),
      pretty=json.dumps(session.get('user'), indent=4)
    )

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=env.get("PORT", 3000))

templates/home.html

<html>
<head>
  <meta charset="utf-8" />
  <title>Auth0 Example</title>
</head>
<body>
  {% if session %}
      <h1>Welcome {{session.userinfo.name}}!</h1>
      <p><a href="/logout">Logout</a></p>
      <div><pre>{{pretty}}</pre></div>
  {% else %}
    <h1>Welcome Guest</h1>
    <p><a href="/login">Login</a></p>
  {% endif %}
</body>
</html>

Solution

  • CODE FLOW

    A modern form of the OAuth 2.0 and OpenID Connect authorization code flow starts with parameters of this form, sent in a browser redirect (the front channel):

    GET https://login.example.com/oauth/v2/authorize
        ?client_id=my-client
        &redirect_uri=https://www.example.com/callback
        &response_type=code
        &scope=openid profile
        &code_challenge=WhmRaP18B9z2zkYcIlb4uVcZzjLqcZsaBQJf5akUxsA
        &code_challenge_method=S256
        &state=CfDJ8Nxa-YhPzjpBilDQz2C...
    

    An authorization code is returned to the browser, after which there is a back channel request to get tokens. This is typically routed via a backend where the client secret can be safely provided.

    POST https://login.example.com/oauth/v2/token
    Content-Type: application/x-www-form-urlencoded
    
    client_id=my-client
    &client_secret=U2U9EnSKx31fUnvgGR3coOUszko5MiuCSI2Z_4ogjIiO5-UbBzIBWU6JQQaljEis
    &code=I9xL9DY9jAYHPuHSiW2OpWUaNRW4otei
    &grant_type=authorization_code
    &redirect_uri=https://www.example.com/callback
    &code_verifier=HlfffYlGy7SIX3pYHOMJfhnO5AhUW1eOIKfjR42ue28
    

    In a web app the best practice is then to issue one or more secure HTTP only cookies, which either contain or reference the tokens returned from the second request. How to do so is not covered by standards documents, but there are well understood best practices.

    ID TOKEN VALIDATION

    The ID token is proof of the authentication event, and its expiry is not usually considered. When all tokens are received on the back channel via a trusted HTTPS URL, the ID token signature does not strictly need to be verified, though it makes sense to verify the expected issuer and audience. This only needs to be done once, after which application level cookies are issued.

    SESSION COOKIE CREATION

    Usually a web app uses a library to create encrypted session cookies with a strong encryption algorithm, such as AES256-GCM. The encryption key is only known to the server. One option is to store ID token, refresh token and access token in separate cookies, in order to hold on to them in a way that is easy for the backend to manage, and that best fits inside browser and HTTP server limits. Doing so also ensures that cookie lifetimes are bound to the token lifetimes.

    SESSION EXPIRY

    On every request for data, the session cookies are validated and decrypted. Cookie defenses from OWASP should also be applied. Underlying access tokens are then used to request data, eg from APIs.

    When access tokens expire, refresh tokens are used to renew access tokens, and cookies are rewritten, to contain updated tokens. Usually the session expiry is that of the refresh token. When the access token cannot be renewed, the user is redirected to a session expired page and prompted to login again, which runs a new code flow.

    SUMMARY

    OAuth secured web apps consist of quite a few moving parts, since browser security is not an easy topic. There are both cookies as web credentials, and tokens as data credentials. Using a good Python OpenID Connect library will help with the details.