Skip to content

Commit 0e0f21c

Browse files
committed
updating mock auth
1 parent b65473c commit 0e0f21c

2 files changed

Lines changed: 252 additions & 6 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# Mock Authentication Script: How To Use
2+
3+
This guide shows how to use the command line tool in [scripts/mock-authentication.py](../scripts/mock-authentication.py) to:
4+
5+
- generate RSA keys and JWKS,
6+
- fetch an OAuth2 access token,
7+
- call an API endpoint with JWT-based app-restricted auth.
8+
9+
## What this script does
10+
11+
The script automates the JWT client assertion flow used by NHS application-restricted APIs:
12+
13+
1. Creates an RSA key pair.
14+
2. Generates a JWKS document from the public key.
15+
3. Signs a JWT assertion with the private key.
16+
4. Exchanges the assertion for an access token.
17+
5. Calls an API using `Authorization: Bearer <token>`.
18+
19+
## Prerequisites
20+
21+
- Python 3.10+ (or compatible Python 3 version).
22+
- A valid NHS API key from the developer portal.
23+
- Your public key uploaded to the portal (or hosted JWKS URL configured for your app).
24+
25+
Install required Python packages:
26+
27+
```bash
28+
pip install "PyJWT[crypto]" requests cryptography
29+
```
30+
31+
## Script location
32+
33+
From repo root:
34+
35+
```bash
36+
python3 scripts/mock-authentication.py --help
37+
```
38+
39+
## Commands overview
40+
41+
```bash
42+
python3 scripts/mock-authentication.py <command> [options]
43+
```
44+
45+
Available commands:
46+
47+
- `generate-keys` - Generate a private key, public key, and JWKS file.
48+
- `get-token` - Generate a JWT and exchange it for an access token.
49+
- `call-api` - Call an API endpoint using the access token.
50+
51+
## 1) Generate keys and JWKS
52+
53+
Generate a key pair and JWKS for integration environment:
54+
55+
```bash
56+
python3 scripts/mock-authentication.py generate-keys \
57+
--api-key YOUR_API_KEY \
58+
--env int \
59+
--output-dir ./.auth
60+
```
61+
62+
Outputs:
63+
64+
- `./.auth/int-1.pem` (private key)
65+
- `./.auth/int-1.pem.pub` (public key)
66+
- `./.auth/int-1.json` (JWKS)
67+
68+
Use a custom key ID:
69+
70+
```bash
71+
python3 scripts/mock-authentication.py generate-keys \
72+
--api-key YOUR_API_KEY \
73+
--env int \
74+
--kid my-int-key-01 \
75+
--output-dir ./.auth
76+
```
77+
78+
## 2) Get an access token
79+
80+
```bash
81+
python3 scripts/mock-authentication.py get-token \
82+
--api-key YOUR_API_KEY \
83+
--env int \
84+
--private-key ./.auth/int-1.pem
85+
```
86+
87+
Use `--kid` if you used a non-default KID:
88+
89+
```bash
90+
python3 scripts/mock-authentication.py get-token \
91+
--api-key YOUR_API_KEY \
92+
--env int \
93+
--kid my-int-key-01 \
94+
--private-key ./.auth/my-int-key-01.pem
95+
```
96+
97+
If `--kid` is omitted, the script now derives KID from the private key filename when it ends with `.pem`.
98+
For example, using `--private-key ./.auth/my-int-key-01.pem` will use `my-int-key-01` automatically.
99+
100+
## 3) Call an API endpoint
101+
102+
Basic GET call:
103+
104+
```bash
105+
python3 scripts/mock-authentication.py call-api \
106+
--api-key YOUR_API_KEY \
107+
--env int \
108+
--private-key ./.auth/int-1.pem \
109+
--url https://int.api.service.nhs.uk/eligibility-signposting-api/patient-check/123
110+
```
111+
112+
GET call including NHS number header and product ID header:
113+
114+
```bash
115+
python3 scripts/mock-authentication.py call-api \
116+
--api-key YOUR_API_KEY \
117+
--env int \
118+
--private-key ./.auth/int-1.pem \
119+
--url https://int.api.service.nhs.uk/eligibility-signposting-api/patient-check/123 \
120+
--nhs-number 1234567890
121+
```
122+
123+
Call with extra headers:
124+
125+
```bash
126+
python3 scripts/mock-authentication.py call-api \
127+
--api-key YOUR_API_KEY \
128+
--env int \
129+
--private-key ./.auth/int-1.pem \
130+
--url https://int.api.service.nhs.uk/some-api/endpoint \
131+
--header "X-Correlation-ID: 7d8ff2e8-6a69-4cbe-a0f3-6f67e6fc2f91" \
132+
--header "Accept: application/json"
133+
```
134+
135+
Use another HTTP method:
136+
137+
```bash
138+
python3 scripts/mock-authentication.py call-api \
139+
--api-key YOUR_API_KEY \
140+
--env int \
141+
--private-key ./.auth/int-1.pem \
142+
--method POST \
143+
--url https://int.api.service.nhs.uk/some-api/endpoint \
144+
--header "Content-Type: application/json"
145+
```
146+
147+
## Environment options
148+
149+
- `generate-keys` and `get-token` support: `dev`, `int`, `prod`
150+
- `call-api` supports: `dev`, `int`, `prod`, `sandbox`
151+
152+
Note about `sandbox`:
153+
154+
- For `call-api --env sandbox`, the script still uses the `int` OAuth token endpoint for token generation internally.
155+
- This is how the current script is implemented.
156+
157+
## Typical end-to-end workflow
158+
159+
```bash
160+
# 1) Generate keys and JWKS
161+
python3 scripts/mock-authentication.py generate-keys \
162+
--api-key YOUR_API_KEY \
163+
--env int \
164+
--output-dir ./.auth
165+
166+
# 2) Upload or host JWKS (manual step)
167+
# File to publish: ./.auth/int-1.json
168+
169+
# 3) Call target API
170+
python3 scripts/mock-authentication.py call-api \
171+
--api-key YOUR_API_KEY \
172+
--env int \
173+
--private-key ./.auth/int-1.pem \
174+
--url https://int.api.service.nhs.uk/eligibility-signposting-api/patient-check/123 \
175+
--nhs-number 1234567890
176+
```
177+
178+
## Troubleshooting
179+
180+
### Missing dependencies
181+
182+
If you see `Missing required dependencies`, run:
183+
184+
```bash
185+
pip install "PyJWT[crypto]" requests cryptography
186+
```
187+
188+
### Invalid environment
189+
190+
If you see an invalid environment error, check command-specific supported values:
191+
192+
- `generate-keys`, `get-token`: `dev|int|prod`
193+
- `call-api`: `dev|int|prod|sandbox`
194+
195+
### Token request fails (401/403)
196+
197+
Check:
198+
199+
- API key is correct.
200+
- JWKS in portal matches the private key used by the script.
201+
- KID (`--kid`) matches what is configured.
202+
- System clock is in sync (JWTs are short-lived).
203+
204+
### Invalid header format warning
205+
206+
`--header` values must use this format:
207+
208+
```text
209+
"Header-Name: value"
210+
```
211+
212+
## Security notes
213+
214+
- Never commit private keys (`*.pem`) to source control.
215+
- Store private keys in a secure location and rotate them periodically.
216+
- Treat access tokens as secrets.

scripts/mock-authentication.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ def generate_jwt(self, private_key_path: str, expires_in_minutes: int = 5) -> st
165165
Returns:
166166
Signed JWT string
167167
"""
168+
if expires_in_minutes <= 0:
169+
raise ValueError("JWT expiry must be greater than 0 minutes")
170+
168171
if expires_in_minutes > 5:
169172
raise ValueError("JWT expiry must not exceed 5 minutes")
170173

@@ -173,10 +176,16 @@ def generate_jwt(self, private_key_path: str, expires_in_minutes: int = 5) -> st
173176
private_key = f.read()
174177

175178
# Create claims
176-
# Use slightly less than requested time to account for clock skew
177-
# Subtract 10 seconds as a safety buffer
179+
# Keep expiry comfortably below 5 minutes so minor clock skew does not
180+
# push exp beyond the provider's strict validation threshold.
178181
current_time = int(time())
179-
expiry_seconds = (expires_in_minutes * 60) - 10
182+
requested_lifetime = expires_in_minutes * 60
183+
clock_skew_buffer_seconds = 60
184+
minimum_lifetime_seconds = 30
185+
expiry_seconds = max(
186+
minimum_lifetime_seconds,
187+
requested_lifetime - clock_skew_buffer_seconds
188+
)
180189

181190
claims = {
182191
"sub": self.api_key,
@@ -203,6 +212,7 @@ def generate_jwt(self, private_key_path: str, expires_in_minutes: int = 5) -> st
203212
print(f" Current time: {datetime.fromtimestamp(current_time)}")
204213
print(f" Expiry time: {exp_datetime}")
205214
print(f" Time until expiry: {(claims['exp'] - current_time)} seconds")
215+
print(f" Clock skew buffer: {clock_skew_buffer_seconds} seconds")
206216

207217
return signed_jwt
208218

@@ -295,6 +305,18 @@ def call_api(self, api_url: str, private_key_path: str, method: str = 'GET',
295305

296306

297307
def main():
308+
def resolve_kid(explicit_kid: Optional[str], private_key_path: Optional[str], env: str) -> str:
309+
"""Resolve KID from explicit arg, private key filename, or environment default."""
310+
if explicit_kid:
311+
return explicit_kid
312+
313+
if private_key_path:
314+
private_key_name = Path(private_key_path).name
315+
if private_key_name.endswith('.pem'):
316+
return Path(private_key_path).stem
317+
318+
return f"{env}-1"
319+
298320
parser = argparse.ArgumentParser(
299321
description='NHS API JWT Authentication Helper',
300322
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -382,7 +404,11 @@ def main():
382404

383405
# Handle get-token command
384406
elif args.command == 'get-token':
385-
auth = NHSAPIAuth(args.api_key, args.env, args.kid)
407+
resolved_kid = resolve_kid(args.kid, args.private_key, args.env)
408+
if not args.kid:
409+
print(f"Using KID: {resolved_kid}")
410+
411+
auth = NHSAPIAuth(args.api_key, args.env, resolved_kid)
386412
token = auth.get_access_token(args.private_key)
387413
print("\n" + "="*70)
388414
print(f"Access Token: {token}")
@@ -397,15 +423,19 @@ def main():
397423
else:
398424
env = args.env
399425

400-
auth = NHSAPIAuth(args.api_key, env, args.kid)
426+
resolved_kid = resolve_kid(args.kid, args.private_key, env)
427+
if not args.kid:
428+
print(f"Using KID: {resolved_kid}")
429+
430+
auth = NHSAPIAuth(args.api_key, env, resolved_kid)
401431

402432
# Build additional headers
403433
additional_headers = {}
404434

405435
# Add NHS number header if provided
406436
if args.nhs_number:
407437
additional_headers['nhs-login-nhs-number'] = args.nhs_number
408-
additional_headers['NHSE-Product-ID'] = 'P.XWA-VFF'
438+
additional_headers['nhse_product_id'] = 'P.WTJ-FJT'
409439

410440
# Add any custom headers
411441
if args.headers:

0 commit comments

Comments
 (0)