Token Lifecycle Middleware

Note

The Token Lifecycle system is an extension to the core functionality provided by the original django-auth-adfs project. While building on the foundational authentication capabilities created by Joris Beckers and contributors, this feature adds specialized token management for Microsoft Entra ID and Graph API integration.

Traditionally, django-entra-auth is used exclusively as an authentication solution - it handles user authentication via Microsoft Entra ID and maps claims to Django users. It doesn’t really care about the access tokens after you’ve been authenticated.

The Token Lifecycle system extends django-entra-auth beyond pure authentication to also handle the complete lifecycle of access tokens after the authentication process. This creates a more integrated approach where:

  • The same application registration handles both authentication and resource access

  • Tokens obtained during authentication are stored and refreshed automatically in the session

  • The application can make delegated API calls on behalf of the user

  • The system can optionally log out users when token refresh fails

How it works

The token lifecycle system relies on the interplay between the TokenLifecycleMiddleware and the authentication backend:

  1. Token Storage: During authentication, if the TokenLifecycleMiddleware is enabled, the backend automatically stores and encrypts the access and refresh tokens in the user’s session. If configured (STORE_OBO_TOKEN=True), it also attempts to acquire and store an OBO token.

  2. Token Monitoring: On each request for an authenticated user, the TokenLifecycleMiddleware triggers the backend to check the expiration status of the stored access token (and OBO token, if applicable).

  3. Token Refresh: If the backend determines a token is nearing expiration (based on TOKEN_REFRESH_THRESHOLD), it uses the stored refresh token to attempt to acquire new tokens from Entra ID.

  4. OBO Token Management: The backend handles the acquisition and refresh of OBO tokens alongside the primary access/refresh tokens, if enabled.

  5. Session Update: If tokens are successfully refreshed, the backend updates the encrypted tokens stored in the session.

  6. Security Controls: The backend can be configured (LOGOUT_ON_TOKEN_REFRESH_FAILURE=True) to log out the user if token refresh fails. The middleware itself primarily initiates the checks.

Read more about the OBO flow: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow#protocol-diagram

Warning

The Token Lifecycle system is a new feature in django-entra-auth and is considered experimental. Please be aware:

Currently no community support is guaranteed to be available for this feature

We recommend thoroughly testing this feature in your specific environment before deploying to production.

Consider enabling the LOGOUT_ON_TOKEN_REFRESH_FAILURE setting, which allows you to log out users when token refresh fails.

Configuration

To enable the token lifecycle system, add the middleware to your MIDDLEWARE setting in your Django settings file:

MIDDLEWARE = [
    # ... other middleware
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django_entra_auth.middleware.TokenLifecycleMiddleware',  # Add this line
    # ... other middleware
]

Important

The middleware must be placed after the SessionMiddleware and AuthenticationMiddleware.

You can configure the token lifecycle behavior with these settings in your Django settings file:

ENTRA_AUTH = {
    # other settings

    # Number of seconds before expiration to refresh (default: 300, i.e., 5 minutes)
    "TOKEN_REFRESH_THRESHOLD": 300,

    # Enable or disable OBO token functionality (default: True)
    "STORE_OBO_TOKEN": True,

    # Custom salt for token encryption (optional)
    # If not specified, a default salt is used
    "TOKEN_ENCRYPTION_SALT": "your-custom-salt-string",

    # Automatically log out users when token refresh fails (default: False)
    "LOGOUT_ON_TOKEN_REFRESH_FAILURE": False,
}

Warning

If you change the TOKEN_ENCRYPTION_SALT after tokens have been stored in sessions, those tokens will no longer be decryptable. This effectively invalidates all existing tokens, requiring users to re-authenticate.

Consider this when deploying changes to the salt in production environments.

Considerations

  • Token storage and encryption are handled automatically by the backend during authentication when the TokenLifecycleMiddleware is enabled.

  • Token refresh only works for authenticated users with valid sessions

  • If the refresh token is invalid or expired, the system will not be able to refresh the access token

  • By default, the system will not log the user out if token refresh fails, but this behavior can be changed with the LOGOUT_ON_TOKEN_REFRESH_FAILURE setting

  • The system will not store tokens in the session when using the signed_cookies session backend

  • OBO token storage is enabled by default but can be disabled with the STORE_OBO_TOKEN setting

  • Using the OBO token versus the regular access token is dependent on the resources you are accessing and the permissions granted to your Entra ID application. See the token types section for more details.

Token Refresh Failures

By default, when token refresh fails, the system logs the error but allows the user to continue using the application until their session expires naturally. This behavior can be changed with the LOGOUT_ON_TOKEN_REFRESH_FAILURE setting:

  • When set to False (default), users remain logged in even if their tokens can’t be refreshed

  • When set to True, users are automatically logged out when token refresh fails

Existing Sessions

When deploying the Token Lifecycle system to an existing application with active user sessions, be aware of the following:

The system only captures tokens during the authentication process. Existing authenticated sessions won’t have tokens stored in them, which means:

  • Users with existing sessions won’t have access to token-dependent features until they re-authenticate

  • Utility functions like get_access_token() and get_obo_access_token() will return None for these sessions

  • API calls that depend on these tokens will fail for existing sessions

The best approach is to ensure that all users re-authenticate after the system is deployed.

Azure AD Application Configuration

When using the Token Lifecycle system, your Microsoft Entra ID application registration needs additional permissions beyond those required for simple authentication. This extends the standard authentication-only setup with additional API permissions needed for delegated access.

Important

Your Django application’s session cookie age must be set to a value that is less than that of your Entra ID application’s refresh token lifetime.

If a user’s refresh token has expired, the user will be required to re-authenticate to continue making delegated requests.

Security Overview

Token Encryption

Tokens are automatically encrypted before being stored in the session and decrypted when they are retrieved. The encryption is handled transparently by the TokenManager and utility functions.

Signed Cookies Session Backend Restriction

If you’re using the signed_cookies session backend and need token storage, you won’t be able to use the token lifecycle system.

Note

This restriction only applies to the signed_cookies session backend. For other session backends (database, cache, file), tokens are stored securely on the server and only a session ID is stored in the cookie.

Automatic OBO Token Acquisition

By default, the system automatically requests OBO tokens when storing tokens. If your application doesn’t need OBO tokens, you can disable this behavior to reduce unnecessary token requests (see the OBO token configuration section for more details).

Disabling OBO Token Functionality

By default, the Token Lifecycle system automatically requests and stores OBO (On-Behalf-Of) tokens.

If you don’t need this functionality, you can disable it completely:

# In your Django settings.py
ENTRA_AUTH = {
    "STORE_OBO_TOKEN": False,
}

Note that disabling OBO tokens doesn’t affect the regular access token functionality. Your application will still be able to use the access token obtained during authentication for its own resources and APIs that directly trust your application.

See the token types section for more details.

Accessing Tokens in Your Views

Since tokens are encrypted in the session, the TokenLifecycleMiddleware provides access to the authentication backend instance as request.token_storage, which helps you access tokens safely:

# For your own APIs or APIs that trust your application directly
access_token = request.token_storage.get_access_token(request)

# For Microsoft Graph API or other APIs requiring delegated access
obo_token = request.token_storage.get_session_obo_access_token(request)

The backend automatically handles encryption/decryption of tokens, so you don’t need to worry about the encryption details.

Warning

You should always use the request.token_storage methods to access tokens rather than accessing them directly from the session. Direct access to the raw token data stored in the session will give you the encrypted token, not the actual token value.

Examples

Here are practical examples of using the token storage in your views:

Using with Microsoft Graph API

This example demonstrates using the OBO token to access Microsoft Graph API

from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
import requests

@login_required
def me_view(request):
    """Get the user's profile from Microsoft Graph API"""
    obo_token = request.token_storage.get_session_obo_access_token(request)

    if not obo_token:
        return JsonResponse({"error": "No OBO token available"}, status=401)

    headers = {
        "Authorization": f"Bearer {obo_token}",
        "Content-Type": "application/json",
    }

    try:
        response = requests.get("https://graph.microsoft.com/v1.0/me", headers=headers)
        response.raise_for_status()
        return JsonResponse(response.json())
    except requests.exceptions.RequestException as e:
        return JsonResponse(
            {"error": "Failed to fetch user profile", "details": str(e)},
            status=500
        )

Using with Custom Entra ID-Protected API

This example shows how to use the OBO token to access a custom API protected by Entra ID that supports the OBO flow.

from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
import requests

@login_required
def custom_api_view(request):
    """Access a custom API using OBO token"""
    obo_token = request.token_storage.get_session_obo_access_token(request)

    if not obo_token:
        return JsonResponse({"error": "No OBO token available"}, status=401)

    headers = {
        "Authorization": f"Bearer {obo_token}",
        "Content-Type": "application/json",
    }

    try:
        response = requests.get(
            "https://your-custom-api.example.com/data",
            headers=headers
        )
        response.raise_for_status()
        return JsonResponse(response.json())
    except requests.exceptions.RequestException as e:
        return JsonResponse(
            {"error": "Failed to fetch data", "details": str(e)},
            status=500
        )

Using with Direct Resource Access

For APIs that directly trust your application (no OBO flow needed), use the regular access token:

from rest_framework.views import APIView
from rest_framework.response import Response
import requests

class ExternalApiView(APIView):
    def get(self, request):
        """Call an API that accepts your application's token"""
        token = request.token_storage.get_access_token(request)

        if not token:
            return Response({"error": "No access token available"}, status=401)

        headers = {"Authorization": f"Bearer {token}"}
        response = requests.get("https://api.example.com/data", headers=headers)

        return Response(response.json())

Debug view

The following example code demonstrates a debug view to check the values of the tokens stored in the session:

import requests
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from datetime import datetime
from django_entra_auth.backend import AdfsBaseBackend

@login_required
def debug_view(request):
    """
    Debug view that provides detailed information about the authentication state,
    tokens, and session data.
    """
    if not request.user.is_authenticated:
        return JsonResponse({"authenticated": False})

    backend = request.token_storage if hasattr(request, "token_storage") else AdfsBaseBackend()

    # Basic session token info
    session_info = {
        "has_access_token": backend.ACCESS_TOKEN_KEY in request.session,
        "has_refresh_token": backend.REFRESH_TOKEN_KEY in request.session,
        "has_expires_at": backend.TOKEN_EXPIRES_AT_KEY in request.session,
    }

    # Add token expiration details if available
    if backend.TOKEN_EXPIRES_AT_KEY in request.session:
        try:
            expires_at = datetime.fromisoformat(
                request.session[backend.TOKEN_EXPIRES_AT_KEY]
            )
            now = datetime.now()
            session_info["token_expires_at"] = expires_at.isoformat()
            session_info["expires_in_seconds"] = max(
                0, int((expires_at - now).total_seconds())
            )
            session_info["is_expired"] = expires_at <= now
        except (ValueError, TypeError) as e:
            session_info["expiration_parse_error"] = str(e)

    # Show raw encrypted tokens for debugging
    if backend.ACCESS_TOKEN_KEY in request.session:
        raw_token = request.session[backend.ACCESS_TOKEN_KEY]
        session_info["raw_token_preview"] = f"{raw_token[:10]}...{raw_token[-10:]}"
        session_info["raw_token_length"] = len(raw_token)

        # Try to decode as JWT without decryption (should fail if properly encrypted)
        try:
            import jwt
            jwt.decode(raw_token, options={"verify_signature": False})
            session_info["is_encrypted"] = False
        except:
            session_info["is_encrypted"] = True

    # Get properly decrypted access token
    try:
        access_token = backend.get_access_token(request)
        session_info["decrypted_access_token_available"] = access_token is not None

        if access_token:
            if len(access_token) > 20:
                session_info["decrypted_access_token_preview"] = (
                    f"{access_token[:10]}...{access_token[-10:]}"
                )
            session_info["decrypted_access_token_length"] = len(access_token)

            # Try to decode as JWT (should succeed if properly decrypted)
            try:
                import jwt
                decoded = jwt.decode(access_token, options={"verify_signature": False})
                session_info["jwt_decode_success"] = True
                # Add some basic JWT info without exposing sensitive data
                if "exp" in decoded:
                    exp_time = datetime.fromtimestamp(decoded["exp"])
                    session_info["jwt_expiry"] = exp_time.isoformat()
            except Exception as e:
                session_info["jwt_decode_error"] = str(e)
    except Exception as e:
        session_info["access_token_error"] = f"Error getting access token: {str(e)}"

    # Check if OBO token is available
    try:
        obo_token = backend.get_session_obo_access_token(request)
        obo_info = {
            "has_obo_token": obo_token is not None,
        }

        # Show raw encrypted OBO token if available
        if backend.OBO_ACCESS_TOKEN_KEY in request.session:
            raw_obo = request.session[backend.OBO_ACCESS_TOKEN_KEY]
            obo_info["raw_obo_preview"] = f"{raw_obo[:10]}...{raw_obo[-10:]}"
            obo_info["raw_obo_length"] = len(raw_obo)

        if obo_token:
            if len(obo_token) > 20:
                obo_info["obo_token_preview"] = f"{obo_token[:10]}...{obo_token[-10:]}"
            obo_info["obo_token_length"] = len(obo_token)

            # Try to decode as JWT (should succeed if properly decrypted)
            try:
                import jwt
                decoded = jwt.decode(obo_token, options={"verify_signature": False})
                obo_info["jwt_decode_success"] = True
                # Add some basic JWT info without exposing sensitive data
                if "exp" in decoded:
                    exp_time = datetime.fromtimestamp(decoded["exp"])
                    obo_info["jwt_expiry"] = exp_time.isoformat()
            except Exception as e:
                obo_info["jwt_decode_error"] = str(e)
    except Exception as e:
        obo_info = {"error": f"Error getting OBO token: {str(e)}"}

    # Return all the collected information
    return JsonResponse(
        {
            "authenticated": True,
            "user": {
                "id": request.user.id,
                "username": request.user.username,
                "email": request.user.email,
                "is_staff": request.user.is_staff,
                "is_superuser": request.user.is_superuser,
            },
            "session_tokens": session_info,
            "obo_token": obo_info,
        },
        json_dumps_params={"indent": 2},
    )

Understanding Access Tokens vs. OBO Tokens

For more information on the different types of permissions and flows, see: