JwtService.java

package com.rbctcsworld.ecommerce.auth;

import com.rbctcsworld.ecommerce.common.SharedEnvironment;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

/**
 * Issues and verifies HS256 tokens. Only the e-mail (subject) is trusted from the token: the ROLE is read
 * from the database on every request (JwtFilter), so editing the token to "role: ADMIN" gives nothing.
 *
 * Verification rejects: a wrong/edited signature, "alg: none" (unsigned) tokens, expired tokens and
 * tokens from another issuer.
 */
@Service
public class JwtService {

    public static final String ISSUER = "rbctcsworld-ecommerce";
    /** The development default in application.yml. It is public (in Git), so it must never run in production. */
    static final String DEV_DEFAULT_SECRET = "change-this-development-secret-key-which-must-be-long-enough-123456789";

    private final String secret;
    private final long expirationMs;
    private final Environment env;
    private SecretKey key;

    public JwtService(@Value("${app.jwt.secret}") String secret,
                      @Value("${app.jwt.expiration-ms}") long expirationMs,
                      Environment env) {
        this.secret = secret;
        this.expirationMs = expirationMs;
        this.env = env;
        validateSecret();
    }

    /** Fails the application start instead of running with an unsafe key. */
    private void validateSecret() {
        byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
        if (bytes.length < 32) {
            throw new IllegalStateException("app.jwt.secret must be at least 32 bytes (256 bits) for HS256");
        }
        if (SharedEnvironment.isShared(env) && DEV_DEFAULT_SECRET.equals(secret)) {
            throw new IllegalStateException("Refusing to start: the public development JWT secret is used with the 'prod' profile"
                    + " or the public demo."
                    + " Set the JWT_SECRET environment variable.");
        }
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String generate(String email) {
        long now = System.currentTimeMillis();
        return Jwts.builder()
                .issuer(ISSUER)
                .subject(email)
                .issuedAt(new Date(now))
                .expiration(new Date(now + expirationMs))
                .signWith(key)
                .compact();
    }

    /** Lifetime of a new access token, sent to the client as expiresIn. */
    public long expiresInSeconds() {
        return expirationMs / 1000;
    }

    /** Returns the e-mail of a valid token; throws for anything else (JwtFilter then treats the caller as anonymous). */
    public String email(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .requireIssuer(ISSUER)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }
}