diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py index ee542b1c7..ce527dcb7 100644 --- a/src/backend/core/services/converter_services.py +++ b/src/backend/core/services/converter_services.py @@ -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.""" diff --git a/src/backend/core/tests/test_services_converter_services.py b/src/backend/core/tests/test_services_converter_services.py index 345e4fb24..0b6f5183b 100644 --- a/src/backend/core/tests/test_services_converter_services.py +++ b/src/backend/core/tests/test_services_converter_services.py @@ -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(): diff --git a/src/frontend/servers/y-provider/__tests__/middlewares.test.ts b/src/frontend/servers/y-provider/__tests__/middlewares.test.ts index cc782f9fa..935abb73c 100644 --- a/src/frontend/servers/y-provider/__tests__/middlewares.test.ts +++ b/src/frontend/servers/y-provider/__tests__/middlewares.test.ts @@ -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' }); diff --git a/src/frontend/servers/y-provider/__tests__/testUtils/adminJwt.ts b/src/frontend/servers/y-provider/__tests__/testUtils/adminJwt.ts index 66d522aa6..e5ffe3288 100644 --- a/src/frontend/servers/y-provider/__tests__/testUtils/adminJwt.ts +++ b/src/frontend/servers/y-provider/__tests__/testUtils/adminJwt.ts @@ -27,7 +27,12 @@ export const signToken = (claims: Record) => .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 = () => diff --git a/src/frontend/servers/y-provider/src/middlewares.ts b/src/frontend/servers/y-provider/src/middlewares.ts index c7364ddac..863afa3b4 100644 --- a/src/frontend/servers/y-provider/src/middlewares.ts +++ b/src/frontend/servers/y-provider/src/middlewares.ts @@ -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 => { 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;