🛂(backend) add audience to jwt

Add audience to the jwt, scoping the token to it
prevents an admin JWT issued for another backend
service from being replayed against y-provider.
This commit is contained in:
Anthony LC
2026-08-05 12:23:07 +02:00
committed by Anthony LC
parent 0fb5d6979a
commit 7ca5ffa591
5 changed files with 41 additions and 6 deletions
@@ -13,6 +13,11 @@ from core.services.jwt_services import JWTService
logger = logging.getLogger(__name__)
# Audience of the admin token y-provider expects. Scoping the token to it
# prevents an admin JWT issued for another backend service from being
# replayed against y-provider.
Y_CONVERTER_AUDIENCE = "y-converter"
class ConversionError(Exception):
"""Base exception for conversion-related errors."""
@@ -110,7 +115,8 @@ class YdocConverter:
@property
def auth_header(self):
"""Build microservice authentication header."""
return f"Bearer {JWTService().get_admin_token()}"
token = JWTService().get_admin_token({"aud": Y_CONVERTER_AUDIENCE})
return f"Bearer {token}"
def _request(self, url, data, content_type, accept):
"""Make a request to the Y-Provider API."""
@@ -27,14 +27,17 @@ def jwt_settings(settings):
def test_auth_header():
"""The auth header carries an admin JWT signed with the configured key."""
"""The auth header carries an admin JWT scoped to the y-converter audience."""
converter = YdocConverter()
scheme, token = converter.auth_header.split(" ")
assert scheme == "Bearer"
payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
payload = jwt.decode(
token, PUBLIC_KEY, algorithms=["RS256"], audience="y-converter"
)
assert payload["admin"] is True
assert payload["aud"] == "y-converter"
def test_convert_empty_text():
@@ -18,6 +18,7 @@ import { httpSecurity } from '@/middlewares';
import {
mockJwksEndpoint,
signAdminToken,
signAdminTokenForAudience,
signAdminTokenWithWrongKey,
signExpiredAdminToken,
signToken,
@@ -88,6 +89,19 @@ describe('httpSecurity', () => {
});
});
it('rejects a valid admin JWT issued for another audience', async () => {
const token = await signAdminTokenForAudience('some-other-service');
const response = await request(buildApp())
.get('/protected')
.set('authorization', `Bearer ${token}`);
expect(response.status).toBe(401);
expect(response.body).toStrictEqual({
error: 'Unauthorized: Invalid API Key',
});
});
it('rejects a validly signed JWT missing the admin claim', async () => {
const token = await signToken({ sub: 'someone' });
@@ -27,7 +27,12 @@ export const signToken = (claims: Record<string, unknown>) =>
.setExpirationTime('1h')
.sign(privateKey);
export const signAdminToken = () => signToken({ admin: true });
export const signAdminToken = () =>
signToken({ admin: true, aud: 'y-converter' });
/** An admin token correctly signed but scoped to another service's audience. */
export const signAdminTokenForAudience = (aud: string) =>
signToken({ admin: true, aud });
/** An admin token signed correctly but already past its expiry. */
export const signExpiredAdminToken = () =>
@@ -16,14 +16,21 @@ export const corsMiddleware = cors({
// keeps them until their "kid" no longer matches a token, per jose's own policy.
const jwks = createRemoteJWKSet(new URL(JWKS_URL));
// Requiring this audience stops a valid admin JWT issued for another service
// from being replayed against y-provider.
const Y_CONVERTER_AUDIENCE = 'y-converter';
export const JWT_ALGORITHM = 'RS256';
/**
* Verify that the given token is an admin JWT signed by the Django backend.
* Verify that the given token is an admin JWT signed by the Django backend
* for the y-converter audience.
*/
const isValidAdminToken = async (token: string): Promise<boolean> => {
try {
const { payload } = await jwtVerify(token, jwks, { algorithms: [JWT_ALGORITHM] });
const { payload } = await jwtVerify(token, jwks, {
algorithms: [JWT_ALGORITHM],
audience: Y_CONVERTER_AUDIENCE,
});
return payload.admin === true;
} catch {
return false;