Skip to content

Commit b4cf71c

Browse files
allouissagzy
andauthored
Added cache invalidation for JWKS (#1360)
closes https://linear.app/ghost/issue/PROD-2665 - Context: ActivityPub uses identity tokens provided by Ghost to authenticate and authorize requests. Identity tokens are JWT signed with RS256 and verifiable by a public key. Ghost exposes the public key as a JSON Web Key Set (JWKS) on <site_url>/ghost/.well-known/jwks.json. To avoid fetching the public key on each ActivityPub request, we cache it in Redis. - Problem: we currently don't have any invalidation mechanism for the cached public key. If the key is rotated after e.g. a site migration, users experience a HTTP 403 authorization error and are unable to access the API/use the app - Solution: if the JWT verification fails against a cached public key, we now retry by fetching a new key from Ghost and save the new key into the cache --------- Co-authored-by: Sag <[email protected]>
1 parent 71c1ea3 commit b4cf71c

File tree

3 files changed

+321
-63
lines changed

3 files changed

+321
-63
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Feature: JWKS Cache Invalidation
2+
ActivityPub uses identity tokens provided by Ghost to authenticate requests.
3+
Identity tokens are JWT signed with RS256 and verifiable by a public key.
4+
Ghost exposes the public key on the JWKS endpoint at <site_url>/ghost/.well-known/jwks.json.
5+
To avoid fetching the key on each ActivityPub request, we cache it in Redis.
6+
7+
The public key might change after e.g. a site migration. In this case, we want to invalidate the cached key and fetch a new, valid one.
8+
9+
@jwks-cache-invalidation
10+
Scenario: After public key rotation, the cache is refreshed and requests signed by the new key are accepted
11+
Given the JWKS endpoint is serving an old key
12+
And the old key has been cached by making a successful request
13+
When the JWKS endpoint is updated to serve a new key
14+
And an authenticated request is made with a token signed by the new key
15+
Then the request is accepted with a 200
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { After, Given, When } from '@cucumber/cucumber';
2+
3+
import assert from 'node:assert';
4+
import fs from 'node:fs';
5+
import { resolve } from 'node:path';
6+
7+
import jwt from 'jsonwebtoken';
8+
import jose from 'node-jose';
9+
10+
import { getCurrentDirectory } from '../support/path.js';
11+
import { getGhostWiremock } from '../support/wiremock.js';
12+
13+
// Store key pairs for the test
14+
let oldKeyPair;
15+
let newKeyPair;
16+
17+
Given('the JWKS endpoint is serving an old key', async function () {
18+
const privateKeyPem = fs.readFileSync(
19+
resolve(getCurrentDirectory(), '../fixtures/private.key'),
20+
'utf8',
21+
);
22+
23+
const oldKey = await jose.JWK.asKey(privateKeyPem, 'pem', {
24+
kid: 'test-key-id',
25+
});
26+
27+
oldKeyPair = {
28+
publicKey: oldKey.toJSON(),
29+
privateKey: privateKeyPem,
30+
};
31+
32+
const ghostActivityPub = getGhostWiremock();
33+
await ghostActivityPub.register(
34+
{
35+
method: 'GET',
36+
endpoint: '/ghost/.well-known/jwks.json',
37+
},
38+
{
39+
status: 200,
40+
body: {
41+
keys: [oldKeyPair.publicKey],
42+
},
43+
headers: {
44+
'Content-Type': 'application/json',
45+
},
46+
},
47+
);
48+
49+
this.oldKeyPair = oldKeyPair;
50+
});
51+
52+
Given(
53+
'the old key has been cached by making a successful request',
54+
async function () {
55+
// Make a successful authenticated request to ensure the old key is cached
56+
const token = jwt.sign(
57+
{
58+
59+
role: 'Owner',
60+
},
61+
this.oldKeyPair.privateKey,
62+
{
63+
algorithm: 'RS256',
64+
keyid: 'test-key-id',
65+
expiresIn: '5m',
66+
},
67+
);
68+
69+
const response = await fetch(
70+
'https://self.test/.ghost/activitypub/v1/account/me',
71+
{
72+
method: 'GET',
73+
headers: {
74+
Accept: 'application/ld+json',
75+
Authorization: `Bearer ${token}`,
76+
},
77+
},
78+
);
79+
80+
assert(
81+
response.ok,
82+
'Initial request with old key should succeed to populate cache',
83+
);
84+
},
85+
);
86+
87+
When('the JWKS endpoint is updated to serve a new key', async function () {
88+
const newKey = await jose.JWK.createKey('RSA', 2048, {
89+
kid: 'new-key-id',
90+
use: 'sig',
91+
alg: 'RS256',
92+
});
93+
94+
newKeyPair = {
95+
publicKey: newKey.toJSON(),
96+
privateKey: newKey.toPEM(true), // true = private key
97+
};
98+
99+
this.newKeyPair = newKeyPair;
100+
101+
const ghostActivityPub = getGhostWiremock();
102+
await ghostActivityPub.register(
103+
{
104+
method: 'GET',
105+
endpoint: '/ghost/.well-known/jwks.json',
106+
},
107+
{
108+
status: 200,
109+
body: {
110+
keys: [this.newKeyPair.publicKey],
111+
},
112+
headers: {
113+
'Content-Type': 'application/json',
114+
},
115+
},
116+
);
117+
});
118+
119+
When(
120+
'an authenticated request is made with a token signed by the new key',
121+
async function () {
122+
// Create a token signed with the NEW key
123+
const token = jwt.sign(
124+
{
125+
126+
role: 'Owner',
127+
},
128+
this.newKeyPair.privateKey,
129+
{
130+
algorithm: 'RS256',
131+
keyid: 'new-key-id',
132+
expiresIn: '5m',
133+
},
134+
);
135+
136+
// Make the request - this should trigger cache invalidation and retry
137+
// The middleware should:
138+
// 1. Fail to verify with cached old key
139+
// 2. Delete the cached key
140+
// 3. Refetch from JWKS endpoint (which now serves the new key)
141+
// 4. Retry verification with the new key
142+
// 5. Succeed and return 200
143+
this.response = await fetch(
144+
'https://self.test/.ghost/activitypub/v1/account/me',
145+
{
146+
method: 'GET',
147+
headers: {
148+
Accept: 'application/ld+json',
149+
Authorization: `Bearer ${token}`,
150+
},
151+
},
152+
);
153+
},
154+
);
155+
156+
// Restore the original JWKS configuration after this test
157+
After({ tags: '@jwks-cache-invalidation' }, async () => {
158+
const privateKeyPem = fs.readFileSync(
159+
resolve(getCurrentDirectory(), '../fixtures/private.key'),
160+
'utf8',
161+
);
162+
163+
const key = await jose.JWK.asKey(privateKeyPem, 'pem', {
164+
kid: 'test-key-id',
165+
});
166+
const jwk = key.toJSON();
167+
168+
const ghostActivityPub = getGhostWiremock();
169+
await ghostActivityPub.register(
170+
{
171+
method: 'GET',
172+
endpoint: '/ghost/.well-known/jwks.json',
173+
},
174+
{
175+
status: 200,
176+
body: {
177+
keys: [jwk],
178+
},
179+
headers: {
180+
'Content-Type': 'application/json',
181+
},
182+
},
183+
);
184+
});

0 commit comments

Comments
 (0)