From fbf694259a6649c3f34e77e42bc2e3372363a1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CDove1010=E2=80=9D?= <“bookwiffidrips@gmail.com”> Date: Mon, 19 Oct 2026 03:30:54 +0100 Subject: [PATCH] fix(auth): enforce and test JWT aud/iss validation on every verification Tokens minted for another service with the same signing secret must not be accepted here (token confusion / cross-service replay). - Bind issuer/audience to this service, configurable via JWT_ISSUER / JWT_AUDIENCE (defaults: parashield-api / parashield-clients) so services sharing a secret can be distinguished - Share a single verifyOptions (HS256 + iss + aud) between access-token verification and refresh-token rotation so the paths cannot drift - Tests: isolate wrong iss and wrong aud (previous tests also omitted the other claim, masking a broken check), missing iss/aud, verifyAsync, rotateRefresh, claims present on all issued tokens, and custom issuer/audience not crossing over with defaults Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 +++ src/auth/jwt.service.spec.ts | 105 ++++++++++++++++++++++++++++++----- src/auth/jwt.service.ts | 36 ++++++++---- src/config/env.d.ts | 3 + 4 files changed, 126 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 3d18ea6..3bd548b 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,13 @@ DATABASE_URL="postgresql://parashield:non-production-dev-pg-password-change-in-p # For development/local only, a non-production default of sufficient length (>= 32 chars) is provided below: JWT_SECRET="non-production-dev-jwt-secret-min-32-chars-long-do-not-use-in-production" +# Issuer and audience claims embedded in, and required on, every JWT. Tokens with a +# missing or mismatched `iss`/`aud` are rejected. Give each service its own values +# so tokens minted for one service cannot be replayed against another. +# Defaults: JWT_ISSUER=parashield-api, JWT_AUDIENCE=parashield-clients +# JWT_ISSUER=parashield-api +# JWT_AUDIENCE=parashield-clients + # Challenge nonce expiration time in seconds (#681). Default: 300 (5 minutes). # AUTH_NONCE_TTL_SECONDS=300 diff --git a/src/auth/jwt.service.spec.ts b/src/auth/jwt.service.spec.ts index e20bc19..f227289 100644 --- a/src/auth/jwt.service.spec.ts +++ b/src/auth/jwt.service.spec.ts @@ -82,23 +82,102 @@ describe('JwtService', () => { }).toThrow(UnauthorizedException); }); - // #555 — tokens with wrong issuer or audience must be rejected - it('should reject a token signed with a different issuer', () => { + // #555 — tokens with wrong issuer or audience must be rejected. + // Each case keeps every other claim valid so the rejection is attributable + // to the claim under test (a token missing `aud` would otherwise mask a + // broken issuer check, and vice versa). + describe('iss / aud claim validation', () => { const walletAddress = 'GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBKQTRB7KXQZ'; - const token = jwt.sign({ walletAddress, iss: 'wrong-issuer' }, VALID_SECRET, { - algorithm: 'HS256', - expiresIn: '1h', + const ISSUER = 'parashield-api'; + const AUDIENCE = 'parashield-clients'; + + const mint = (claims: { iss?: string; aud?: string; type?: string; family?: string; jti?: string }) => + jwt.sign({ walletAddress, type: 'access', ...claims }, VALID_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + + it('embeds iss and aud in every issued token', () => { + for (const token of [ + service.sign(walletAddress), + service.signWithRole(walletAddress, 'operator'), + service.signRefresh(walletAddress), + ]) { + const decoded = jwt.decode(token) as jwt.JwtPayload; + expect(decoded.iss).toBe(ISSUER); + expect(decoded.aud).toBe(AUDIENCE); + } }); - expect(() => service.verify(token)).toThrow(UnauthorizedException); - }); - it('should reject a token signed with a different audience', () => { - const walletAddress = 'GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBKQTRB7KXQZ'; - const token = jwt.sign({ walletAddress, aud: 'wrong-audience' }, VALID_SECRET, { - algorithm: 'HS256', - expiresIn: '1h', + it('accepts a token with the correct iss and aud', () => { + const token = mint({ iss: ISSUER, aud: AUDIENCE }); + expect(service.verify(token).walletAddress).toBe(walletAddress); + }); + + it('rejects a token with the correct aud but a different issuer', () => { + const token = mint({ iss: 'other-service', aud: AUDIENCE }); + expect(() => service.verify(token)).toThrow(UnauthorizedException); + expect(() => service.verify(token)).toThrow('Invalid token'); + }); + + it('rejects a token with the correct iss but a different audience', () => { + const token = mint({ iss: ISSUER, aud: 'other-service-clients' }); + expect(() => service.verify(token)).toThrow(UnauthorizedException); + expect(() => service.verify(token)).toThrow('Invalid token'); + }); + + it('rejects a token missing the iss claim', () => { + const token = mint({ aud: AUDIENCE }); + expect(() => service.verify(token)).toThrow(UnauthorizedException); + }); + + it('rejects a token missing the aud claim', () => { + const token = mint({ iss: ISSUER }); + expect(() => service.verify(token)).toThrow(UnauthorizedException); + }); + + it('rejects a token missing both iss and aud', () => { + const token = mint({}); + expect(() => service.verify(token)).toThrow(UnauthorizedException); + }); + + it('verifyAsync rejects a token with a wrong audience', async () => { + const token = mint({ iss: ISSUER, aud: 'other-service-clients' }); + await expect(service.verifyAsync(token)).rejects.toThrow(UnauthorizedException); + }); + + it('rotateRefresh rejects a refresh token with a wrong issuer or audience', async () => { + const base = { type: 'refresh', family: 'fam-1', jti: 'jti-1' }; + await expect( + service.rotateRefresh(mint({ ...base, iss: 'other-service', aud: AUDIENCE })), + ).rejects.toThrow('Invalid refresh token'); + await expect( + service.rotateRefresh(mint({ ...base, iss: ISSUER, aud: 'other-service-clients' })), + ).rejects.toThrow('Invalid refresh token'); + await expect(service.rotateRefresh(mint({ ...base }))).rejects.toThrow('Invalid refresh token'); + }); + + it('honours JWT_ISSUER / JWT_AUDIENCE and rejects tokens minted for the defaults', () => { + const config = { + get: jest.fn((key: string) => { + if (key === 'JWT_SECRET') return VALID_SECRET; + if (key === 'JWT_ISSUER') return 'parashield-api-staging'; + if (key === 'JWT_AUDIENCE') return 'parashield-staging-clients'; + return undefined; + }), + }; + const custom = new JwtService(config as any, redisStub() as any); + + const token = custom.sign(walletAddress); + const decoded = jwt.decode(token) as jwt.JwtPayload; + expect(decoded.iss).toBe('parashield-api-staging'); + expect(decoded.aud).toBe('parashield-staging-clients'); + expect(custom.verify(token).walletAddress).toBe(walletAddress); + + // Same secret, different service identity: tokens must not cross over. + expect(() => custom.verify(service.sign(walletAddress))).toThrow(UnauthorizedException); + expect(() => service.verify(token)).toThrow(UnauthorizedException); }); - expect(() => service.verify(token)).toThrow(UnauthorizedException); }); // #182 — the whole point of a 7-day expiry is that expired tokens are diff --git a/src/auth/jwt.service.ts b/src/auth/jwt.service.ts index a858365..ac22083 100644 --- a/src/auth/jwt.service.ts +++ b/src/auth/jwt.service.ts @@ -33,8 +33,11 @@ export class JwtService { private readonly logger = new Logger(JwtService.name); private readonly secret: string; private readonly tokenExpiry = "1h"; - private readonly issuer = "parashield-api"; - private readonly audience = "parashield-clients"; + // Tokens are bound to this service via `iss` and `aud`. Both are required on + // every verification so a token minted for another service sharing the same + // secret (or the same signing infrastructure) cannot be replayed here. + private readonly issuer: string; + private readonly audience: string; constructor(private readonly config: ConfigService, @Inject('REDIS_CLIENT') private readonly redis: Redis) { const secret = config.get("JWT_SECRET"); @@ -44,6 +47,20 @@ export class JwtService { throw new Error(validation.error); } this.secret = secret!; + this.issuer = config.get("JWT_ISSUER")?.trim() || "parashield-api"; + this.audience = config.get("JWT_AUDIENCE")?.trim() || "parashield-clients"; + } + + /** + * Options shared by every verification path. jsonwebtoken rejects a token + * whose `iss`/`aud` is missing or does not match when these are set. + */ + private get verifyOptions(): jwt.VerifyOptions { + return { + algorithms: ['HS256'], + issuer: this.issuer, + audience: this.audience, + }; } get expiresIn(): string { @@ -96,11 +113,10 @@ export class JwtService { // falls back to trusting the `alg` header in the token itself, which opens // the door to algorithm-confusion attacks (e.g. `alg: none`, or a token // re-signed with RS256 using the HMAC secret as the public key). - const decoded = jwt.verify(token, this.secret, { - algorithms: ['HS256'], - issuer: this.issuer, - audience: this.audience, - }) as JwtPayload & { jti?: string; type?: string }; + const decoded = jwt.verify(token, this.secret, this.verifyOptions) as JwtPayload & { + jti?: string; + type?: string; + }; if (decoded.type === "refresh") { throw new UnauthorizedException("Cannot use refresh token as access token"); @@ -178,11 +194,7 @@ export class JwtService { async rotateRefresh(oldToken: string): Promise<{ accessToken: string; refreshToken: string }> { let decoded: JwtPayload & { jti?: string }; try { - decoded = jwt.verify(oldToken, this.secret, { - algorithms: ['HS256'], - issuer: this.issuer, - audience: this.audience, - }) as JwtPayload & { jti?: string }; + decoded = jwt.verify(oldToken, this.secret, this.verifyOptions) as JwtPayload & { jti?: string }; } catch { throw new UnauthorizedException("Invalid refresh token"); } diff --git a/src/config/env.d.ts b/src/config/env.d.ts index f0f8f4b..29a7363 100644 --- a/src/config/env.d.ts +++ b/src/config/env.d.ts @@ -24,6 +24,9 @@ export interface EnvironmentVariables { REDIS_STARTUP_INITIAL_DELAY_MS?: string; REDIS_STARTUP_MAX_DELAY_MS?: string; REDIS_STARTUP_ATTEMPT_TIMEOUT_MS?: string; + // JWT `iss` / `aud` claims (defaults in src/auth/jwt.service.ts) + JWT_ISSUER?: string; + JWT_AUDIENCE?: string; ADMIN_API_KEY?: string; ORACLE_OPERATOR_API_KEY?: string; AVIATIONSTACK_API_KEY?: string;