Skip to content

Commit 09263e5

Browse files
Themitchelljamiefalcus
authored andcommitted
PPHA-475: Add basic NHS Login backend
1 parent 62ed0bd commit 09263e5

File tree

11 files changed

+603
-80
lines changed

11 files changed

+603
-80
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ DATABASE_PASSWORD=password
1111
POSTGRES_DB=lung_cancer_screening
1212
POSTGRES_USER=lung_cancer_screening
1313
POSTGRES_PASSWORD=password
14+
15+
OIDC_RP_CLIENT_PRIVATE_KEY="MYSUPERSECRETPRIVATEKEY"
16+
OIDC_RP_CLIENT_ID="lcrc"
17+
OIDC_OP_FQDN="https://example.com"
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""
2+
Custom OIDC authentication backend for NHS Login with private key JWT.
3+
"""
4+
import logging
5+
import time
6+
import requests
7+
import jwt
8+
from django.conf import settings
9+
from django.contrib.auth import get_user_model
10+
11+
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
12+
from cryptography.hazmat.primitives import serialization
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class NHSLoginOIDCBackend(OIDCAuthenticationBackend):
18+
"""
19+
Custom OIDC authentication backend that uses private key JWT
20+
for client authentication instead of client secret.
21+
Uses NHS number as the username field.
22+
"""
23+
24+
def filter_users_by_claims(self, claims):
25+
"""Find users by NHS number from OIDC claims."""
26+
User = get_user_model()
27+
28+
nhs_number = claims.get('nhs_number')
29+
if not nhs_number:
30+
return User.objects.none()
31+
32+
return User.objects.filter(nhs_number=nhs_number)
33+
34+
def create_user(self, claims):
35+
User = get_user_model()
36+
37+
nhs_number = claims.get('nhs_number')
38+
if not nhs_number:
39+
raise ValueError("Missing 'nhs_number' claim in OIDC token")
40+
return User.objects.create_user(nhs_number=nhs_number)
41+
42+
def update_user(self, user, _claims):
43+
return user
44+
45+
def _create_client_assertion(self):
46+
private_key_pem = settings.OIDC_RP_CLIENT_PRIVATE_KEY
47+
if not private_key_pem:
48+
return None
49+
50+
try:
51+
private_key = serialization.load_pem_private_key(
52+
private_key_pem.encode('utf-8'),
53+
password=None,
54+
)
55+
except Exception as e:
56+
raise ValueError(f"Failed to load private key: {e}") from e
57+
58+
token_endpoint = settings.OIDC_OP_TOKEN_ENDPOINT
59+
client_id = settings.OIDC_RP_CLIENT_ID
60+
61+
now = int(time.time())
62+
claims = {
63+
'iss': client_id,
64+
'sub': client_id,
65+
'aud': token_endpoint,
66+
'jti': f"{client_id}-{now}",
67+
'iat': now,
68+
'exp': now + 300,
69+
}
70+
71+
headers = {'alg': settings.OIDC_RP_SIGN_ALGO}
72+
73+
assertion = jwt.encode(
74+
claims, private_key, algorithm=settings.OIDC_RP_SIGN_ALGO, headers=headers
75+
)
76+
77+
if isinstance(assertion, bytes):
78+
return assertion.decode('utf-8')
79+
return assertion
80+
81+
def get_token(self, token_payload):
82+
token_endpoint = settings.OIDC_OP_TOKEN_ENDPOINT
83+
redirect_uri = token_payload.get('redirect_uri')
84+
authorization_code = token_payload.get('code')
85+
86+
if not redirect_uri:
87+
redirect_uri = settings.OIDC_RP_REDIRECT_URI
88+
89+
client_assertion = self._create_client_assertion()
90+
if not client_assertion:
91+
return super().get_token(token_payload)
92+
93+
token_payload_updated = {
94+
'grant_type': 'authorization_code',
95+
'code': authorization_code,
96+
'redirect_uri': redirect_uri,
97+
'client_assertion': client_assertion,
98+
'client_assertion_type': (
99+
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
100+
),
101+
}
102+
103+
response = requests.post(
104+
token_endpoint,
105+
data=token_payload_updated,
106+
headers={'Content-Type': 'application/x-www-form-urlencoded'},
107+
verify=True,
108+
timeout=30,
109+
)
110+
111+
if not response.ok:
112+
error_detail = response.text
113+
try:
114+
error_detail = response.json()
115+
except Exception:
116+
pass
117+
logger.error(
118+
"Token request failed: %s - %s",
119+
response.status_code,
120+
error_detail
121+
)
122+
raise ValueError(
123+
f"Token request failed: {response.status_code} - "
124+
f"{error_detail}"
125+
)
126+
127+
return response.json()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 5.2.9 on 2025-12-03 16:51
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('questions', '0018_responseset_respiratory_conditions'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='User',
15+
fields=[
16+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
('password', models.CharField(max_length=128, verbose_name='password')),
18+
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
19+
('nhs_number', models.CharField(max_length=10, unique=True)),
20+
('created_at', models.DateTimeField(auto_now_add=True)),
21+
('updated_at', models.DateTimeField(auto_now=True)),
22+
],
23+
options={
24+
'verbose_name': 'user',
25+
'verbose_name_plural': 'users',
26+
},
27+
)
28+
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from .participant import Participant # noqa: F401
22
from .response_set import ResponseSet # noqa: F401
3+
from .user import User # noqa: F401
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from django.contrib.auth.models import (
2+
AbstractBaseUser,
3+
BaseUserManager
4+
)
5+
from django.db import models
6+
7+
8+
class UserManager(BaseUserManager):
9+
10+
def create_user(self, nhs_number, **extra_fields):
11+
if not nhs_number:
12+
raise ValueError('The NHS number must be set')
13+
user = self.model(nhs_number=nhs_number, **extra_fields)
14+
# Set an unusable password since AbstractBaseUser requires it
15+
user.set_unusable_password()
16+
user.save(using=self._db)
17+
return user
18+
19+
20+
class User(AbstractBaseUser):
21+
nhs_number = models.CharField(max_length=10, unique=True)
22+
created_at = models.DateTimeField(auto_now_add=True)
23+
updated_at = models.DateTimeField(auto_now=True)
24+
25+
objects = UserManager()
26+
27+
USERNAME_FIELD = 'nhs_number'
28+
REQUIRED_FIELDS = []
29+
30+
def save(self, *args, **kwargs):
31+
self.full_clean() # Validate before saving
32+
super().save(*args, **kwargs)
33+
34+
class Meta:
35+
verbose_name = 'user'
36+
verbose_name_plural = 'users'
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from django.test import TestCase
2+
from datetime import datetime
3+
from django.core.exceptions import ValidationError
4+
5+
from ....models.user import User
6+
7+
8+
class TestUser(TestCase):
9+
def setUp(self):
10+
self.nhs_number = "1234567890"
11+
self.user = User.objects.create_user(self.nhs_number)
12+
13+
def test_has_nhs_number_as_a_string(self):
14+
self.assertIsInstance(
15+
self.user.nhs_number,
16+
str
17+
)
18+
19+
def test_has_created_at_as_a_datetime(self):
20+
self.assertIsInstance(
21+
self.user.created_at,
22+
datetime
23+
)
24+
25+
def test_has_updated_at_as_a_datetime(self):
26+
self.assertIsInstance(
27+
self.user.updated_at,
28+
datetime
29+
)
30+
31+
def test_nhs_number_has_a_max_length_of_10(self):
32+
with self.assertRaises(ValidationError) as context:
33+
User.objects.create_user("1"*11)
34+
35+
self.assertIn(
36+
"Ensure this value has at most 10 characters (it has 11).",
37+
context.exception.messages
38+
)
39+
40+
def test_raises_a_value_error_if_nhs_number_is_null(self):
41+
with self.assertRaises(ValueError):
42+
User.objects.create_user(None)
43+
44+
def test_raises_a_validation_error_if_nhs_number_is_duplicate(self):
45+
with self.assertRaises(ValidationError) as context:
46+
User.objects.create_user(self.nhs_number)
47+
48+
self.assertIn(
49+
"User with this Nhs number already exists.",
50+
context.exception.messages
51+
)

0 commit comments

Comments
 (0)