Skip to content

Commit f999af7

Browse files
committed
✨(oidc) add refresh token tools
This provides a way to to refresh the OIDC access token. The OIDC token will be used to request data to a resource server. This code is highly related to mozilla/mozilla-django-oidc#377 The refresh token is encrypted in the session.
1 parent 2237242 commit f999af7

File tree

11 files changed

+832
-5
lines changed

11 files changed

+832
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ and this project adheres to
1212

1313
- ✨(tools) extract domain from email address #2
1414
- ✨(oidc) add the authentication backends #2
15+
- ✨(oidc) add refresh token tools #3
1516

1617
[unreleased]: https://github.com/suitenumerique/django-lasuite/commits/main/
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Using the OIDC Authentication Backend to request a resource server
2+
3+
Once your project is configured with the OIDC authentication backend, you can use it to request resources from a resource server. This guide will help you set up and use the `ResourceServerBackend` for token introspection and secure API access.
4+
5+
## Configuration
6+
7+
You need to follow the steps from [how-to-use-oidc-backend.md](how-to-use-oidc-backend.md)
8+
9+
## Additional Settings for Resource Server Communication
10+
11+
To enable your application to communicate with protected resource servers, you'll need to configure token storage in your Django settings:
12+
13+
```python
14+
# Store OIDC tokens in the session
15+
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
16+
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
17+
18+
# Required for refresh token encryption
19+
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
20+
```
21+
22+
### Purpose of Each Setting
23+
24+
1. **`OIDC_STORE_ACCESS_TOKEN`**: When set to `True`, the access token received from the OIDC provider will be stored in the user's session. This token is required for making authenticated requests to protected resource servers.
25+
26+
2. **`OIDC_STORE_REFRESH_TOKEN`**: When set to `True`, enables storing the refresh token in the user's session. The refresh token allows your application to request a new access token when the current one expires without requiring user re-authentication.
27+
28+
3. **`OIDC_STORE_REFRESH_TOKEN_KEY`**: This is a cryptographic key used to encrypt the refresh token before storing it in the session. This provides an additional layer of security since refresh tokens are sensitive credentials that can be used to obtain new access tokens.
29+
30+
## Generating a Secure Refresh Token Key
31+
32+
You can generate a secure Fernet key using Python:
33+
34+
```python
35+
from cryptography.fernet import Fernet
36+
key = Fernet.generate_key()
37+
print(key.decode()) # Add this value to your settings
38+
```
39+
40+
## Using the Stored Tokens
41+
42+
Once you have configured these settings, your application can use the stored tokens to make authenticated requests to resource servers:
43+
44+
```python
45+
import requests
46+
from django.http import JsonResponse
47+
48+
def call_resource_server(request):
49+
# Get the access token from the session
50+
access_token = request.session.get('oidc_access_token')
51+
52+
if not access_token:
53+
return JsonResponse({'error': 'Not authenticated'}, status=401)
54+
55+
# Make an authenticated request to the resource server
56+
response = requests.get(
57+
'https://resource-server.example.com/api/resource',
58+
headers={'Authorization': f'Bearer {access_token}'},
59+
)
60+
61+
return JsonResponse(response.json())
62+
```
63+
64+
## Token Refresh management
65+
66+
### View Based Token Refresh (via decorator)
67+
68+
Request the access token refresh only on specific views using the `refresh_oidc_access_token` decorator:
69+
70+
```python
71+
from lasuite.oidc.decorators import refresh_oidc_access_token
72+
73+
class SomeViewSet(GenericViewSet):
74+
75+
@method_decorator(refresh_oidc_access_token)
76+
def some_action(self, request):
77+
# Your action logic here
78+
79+
# The call to the resource server
80+
access_token = request.session.get('oidc_access_token')
81+
requests.get(
82+
'https://resource-server.example.com/api/resource',
83+
headers={'Authorization': f'Bearer {access_token}'},
84+
)
85+
```
86+
87+
This will trigger the token refresh process only when the `some_action` method is called.
88+
If the access token is expired, it will attempt to refresh it using the stored refresh token.
89+
90+
### Automatic Token Refresh (via middleware)
91+
92+
You can also use the `RefreshOIDCAccessToken` middleware to automatically refresh expired tokens:
93+
94+
```python
95+
# Add to your MIDDLEWARE setting
96+
MIDDLEWARE = [
97+
# Other middleware...
98+
'lasuite.oidc.middleware.RefreshOIDCAccessToken',
99+
]
100+
```
101+
102+
This middleware will:
103+
1. Check if the current access token is expired
104+
2. Use the stored refresh token to obtain a new access token
105+
3. Update the session with the new token
106+
4. Continue processing the request with the fresh token
107+
108+
If token refresh fails, the middleware will return a 401 response with a `refresh_url` header to redirect the user to re-authenticate.
109+

src/lasuite/oidc/backends.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
"""Authentication Backends for the People core app."""
22

33
import logging
4+
from functools import lru_cache
45

56
import requests
7+
from cryptography.fernet import Fernet
68
from django.conf import settings
79
from django.core.exceptions import SuspiciousOperation
810
from django.utils.translation import gettext_lazy as _
911
from mozilla_django_oidc.auth import (
1012
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
1113
)
14+
from mozilla_django_oidc.utils import import_from_settings
1215

1316
logger = logging.getLogger(__name__)
1417

@@ -20,6 +23,41 @@
2023
) # Default to 'sub' if not set in settings
2124

2225

26+
@lru_cache(maxsize=0)
27+
def get_cipher_suite():
28+
"""Return a Fernet cipher suite."""
29+
key = import_from_settings("OIDC_STORE_REFRESH_TOKEN_KEY", None)
30+
if not key:
31+
raise ValueError("OIDC_STORE_REFRESH_TOKEN_KEY setting is required.")
32+
return Fernet(key)
33+
34+
35+
def store_oidc_refresh_token(session, refresh_token):
36+
"""Store the encrypted OIDC refresh token in the session if enabled in settings."""
37+
if import_from_settings("OIDC_STORE_REFRESH_TOKEN", False):
38+
encrypted_token = get_cipher_suite().encrypt(refresh_token.encode())
39+
session["oidc_refresh_token"] = encrypted_token.decode()
40+
41+
42+
def get_oidc_refresh_token(session):
43+
"""Retrieve and decrypt the OIDC refresh token from the session."""
44+
encrypted_token = session.get("oidc_refresh_token")
45+
if encrypted_token:
46+
return get_cipher_suite().decrypt(encrypted_token.encode()).decode()
47+
return None
48+
49+
50+
def store_tokens(session, access_token, id_token, refresh_token):
51+
"""Store tokens in the session if enabled in settings."""
52+
if import_from_settings("OIDC_STORE_ACCESS_TOKEN", False):
53+
session["oidc_access_token"] = access_token
54+
55+
if import_from_settings("OIDC_STORE_ID_TOKEN", False):
56+
session["oidc_id_token"] = id_token
57+
58+
store_oidc_refresh_token(session, refresh_token)
59+
60+
2361
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
2462
"""
2563
Custom OpenID Connect (OIDC) Authentication Backend.
@@ -28,6 +66,36 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
2866
in the User model, and handles signed and/or encrypted UserInfo response.
2967
"""
3068

69+
def __init__(self, *args, **kwargs):
70+
"""
71+
Initialize the OIDC Authentication Backend.
72+
Adds an internal attribute to store the token_info dictionary.
73+
The purpose of `self._token_info` is to not duplicate code from
74+
the original `authenticate` method.
75+
This won't be needed after https://github.com/mozilla/mozilla-django-oidc/pull/377
76+
is merged.
77+
"""
78+
super().__init__(*args, **kwargs)
79+
self._token_info = None
80+
81+
def get_token(self, payload):
82+
"""
83+
Return token object as a dictionary.
84+
Store the value to extract the refresh token in the `authenticate` method.
85+
"""
86+
self._token_info = super().get_token(payload)
87+
return self._token_info
88+
89+
def authenticate(self, request, **kwargs):
90+
"""Authenticate a user based on the OIDC code flow."""
91+
user = super().authenticate(request, **kwargs)
92+
93+
if user is not None:
94+
# Then the user successfully authenticated
95+
store_oidc_refresh_token(request.session, self._token_info.get("refresh_token"))
96+
97+
return user
98+
3199
def get_extra_claims(self, user_info):
32100
"""
33101
Return extra claims from user_info.

src/lasuite/oidc/decorators.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""
2+
Decorators for the authentication app.
3+
We don't want (yet) to enforce the OIDC access token to be "fresh" for all
4+
views, so we provide a decorator to refresh the access token only when needed.
5+
"""
6+
7+
from django.utils.decorators import decorator_from_middleware
8+
9+
from .middleware import RefreshOIDCAccessToken
10+
11+
refresh_oidc_access_token = decorator_from_middleware(RefreshOIDCAccessToken)

0 commit comments

Comments
 (0)