Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 92 additions & 13 deletions src/auth/jwt.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 24 additions & 12 deletions src/auth/jwt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>("JWT_SECRET");
Expand All @@ -44,6 +47,20 @@ export class JwtService {
throw new Error(validation.error);
}
this.secret = secret!;
this.issuer = config.get<string>("JWT_ISSUER")?.trim() || "parashield-api";
this.audience = config.get<string>("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 {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
}
Expand Down
3 changes: 3 additions & 0 deletions src/config/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down