diff --git a/simple-jwt/src/main/java/com/onixbyte/jwt/TokenCreator.java b/simple-jwt/src/main/java/com/onixbyte/jwt/TokenCreator.java new file mode 100644 index 0000000..d0e8f73 --- /dev/null +++ b/simple-jwt/src/main/java/com/onixbyte/jwt/TokenCreator.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024-2025 OnixByte. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.onixbyte.jwt; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +/** + * + */ +public interface TokenCreator { + + String sign(TokenPayload payload); + +} diff --git a/simple-jwt/src/main/java/com/onixbyte/jwt/TokenResolver.java b/simple-jwt/src/main/java/com/onixbyte/jwt/TokenResolver.java new file mode 100644 index 0000000..c8d1b81 --- /dev/null +++ b/simple-jwt/src/main/java/com/onixbyte/jwt/TokenResolver.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2024-2025 OnixByte. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.onixbyte.jwt; + +import com.onixbyte.jwt.data.RawTokenComponent; + +import java.util.Map; + +/** + * + */ +public interface TokenResolver { + + /** + * + * @param token + */ + void verify(String token); + + /** + * + * @param token + * @return + */ + Map getHeader(String token); + + /** + * + * @param payload + * @return + */ + Map getPayload(String payload); + + /** + * + * @param token + * @return + */ + RawTokenComponent splitToken(String token); + +} diff --git a/simple-jwt/src/main/java/com/onixbyte/jwt/constant/Algorithm.java b/simple-jwt/src/main/java/com/onixbyte/jwt/constant/Algorithm.java new file mode 100644 index 0000000..46c3e07 --- /dev/null +++ b/simple-jwt/src/main/java/com/onixbyte/jwt/constant/Algorithm.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024-2025 OnixByte. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.onixbyte.jwt.constant; + +/** + * + */ +public enum Algorithm { + HS256(1, 256, "HmacSHA256"), + HS384(1, 384, "HmacSHA384"), + HS512(1, 512, "HmacSHA512"), + RS256(2, 256, "SHA256withRSA"), + RS384(2, 384, "SHA384withRSA"), + RS512(2, 512, "SHA512withRSA"), + ES256(3, 256, "SHA256withECDSA"), + ES384(3, 384, "SHA384withECDSA"), + ES512(3, 512, "SHA512withECDSA"); + + /** + * + */ + private static final int HS_FLAG = 1; // 001 + + /** + * + */ + private static final int RS_FLAG = 2; // 010 + + /** + * + */ + private static final int ES_FLAG = 3; // 011 + + /** + * + */ + private final int typeFlag; + private final int shaLength; + private final String algorithm; + + /** + * + * @param typeFlag + * @param shaLength + * @param algorithm + */ + Algorithm(int typeFlag, int shaLength, String algorithm) { + this.typeFlag = typeFlag; + this.shaLength = shaLength; + this.algorithm = algorithm; + } + + /** + * + * @return + */ + public boolean isHmac() { + return (this.typeFlag & HS_FLAG) != 0; + } + + /** + * + * @return + */ + public boolean isRsa() { + return (this.typeFlag & RS_FLAG) != 0; + } + + /** + * + * @return + */ + public boolean isEcdsa() { + return (this.typeFlag & ES_FLAG) != 0; + } + + /** + * + * @return + */ + public int getShaLength() { + return shaLength; + } + + /** + * + * @return + */ + public int getTypeFlag() { + return typeFlag; + } + + /** + * + * @return + */ + public String getAlgorithm() { + return algorithm; + } +} diff --git a/simple-jwt/src/main/java/com/onixbyte/jwt/impl/HmacTokenCreator.java b/simple-jwt/src/main/java/com/onixbyte/jwt/impl/HmacTokenCreator.java new file mode 100644 index 0000000..f31e193 --- /dev/null +++ b/simple-jwt/src/main/java/com/onixbyte/jwt/impl/HmacTokenCreator.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2024-2025 OnixByte. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.onixbyte.jwt.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onixbyte.jwt.TokenCreator; +import com.onixbyte.jwt.TokenPayload; +import com.onixbyte.jwt.constant.Algorithm; +import com.onixbyte.jwt.constant.HeaderClaims; +import com.onixbyte.jwt.holder.ObjectMapperHolder; +import com.onixbyte.jwt.util.CryptoUtil; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; + +/** + * Implementation of {@link TokenCreator} that generates HMAC-signed JSON Web Tokens (JWTs). + *

+ * This class uses a specified HMAC algorithm to create signed tokens, incorporating a header, + * payload, and signature. It ensures the secret key meets the minimum length requirement for + * the chosen algorithm and handles JSON serialisation of the token components. + * + * @author zihluwang + */ +public class HmacTokenCreator implements TokenCreator { + + private final Algorithm algorithm; + private final String issuer; + private final byte[] secret; + + private final ObjectMapper objectMapper; + + /** + * Constructs an HMAC token creator with the specified algorithm, issuer, and secret key. + *

+ * Validates that the secret key length meets the minimum requirement for the chosen algorithm. + * + * @param algorithm the HMAC algorithm to use for signing (e.g., HS256, HS384, HS512) + * @param issuer the issuer identifier to include in the token payload if not already present + * @param secret the secret key as a string, used to generate the HMAC signature + * @throws IllegalArgumentException if the secret key is shorter than the minimum required + * length for the specified algorithm + */ + public HmacTokenCreator(Algorithm algorithm, String issuer, String secret) { + var _minSecretLength = algorithm.getShaLength() >> 3; + var secretBytesLength = secret.getBytes(StandardCharsets.UTF_8).length; + if (secretBytesLength < _minSecretLength) { + throw new IllegalArgumentException("Secret key too short for HS%d: minimum %d bytes required, got %d." + .formatted(algorithm.getShaLength(), _minSecretLength, secretBytesLength) + ); + } + + this.algorithm = algorithm; + this.issuer = issuer; + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.objectMapper = ObjectMapperHolder.getInstance().getObjectMapper(); + } + + /** + * Creates and signs a JWT using the HMAC algorithm. + *

+ * Generates a token by encoding the header and payload as Base64 URL-safe strings, + * creating an HMAC signature, and concatenating them with dots. If the payload does not + * include an issuer, the configured issuer is added. + * + * @param payload the {@link TokenPayload} containing claims to include in the token + * @return the signed JWT as a string in the format "header.payload.signature" + * @throws IllegalArgumentException if the payload cannot be serialised to JSON due to + * invalid data or structure + * @throws RuntimeException if an unexpected error occurs during JSON processing + */ + @Override + public String sign(TokenPayload payload) { + var header = new HashMap(); + + header.put(HeaderClaims.ALGORITHM, algorithm.name()); + if (!header.containsKey(HeaderClaims.TYPE)) { + header.put(HeaderClaims.TYPE, "JWT"); + } + + if (!payload.hasIssuer()) { + payload.withIssuer(issuer); + } + + try { + var encodedHeader = Base64.getUrlEncoder().withoutPadding() + .encodeToString(objectMapper.writeValueAsBytes(header)); + var encodedPayload = Base64.getUrlEncoder().withoutPadding() + .encodeToString(objectMapper.writeValueAsBytes(payload.getPayload())); + + var signatureBytes = CryptoUtil.createSignatureFor(algorithm, + secret, + encodedHeader.getBytes(StandardCharsets.UTF_8), + encodedPayload.getBytes(StandardCharsets.UTF_8)); + var signature = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString((signatureBytes)); + + return "%s.%s.%s".formatted(encodedHeader, encodedPayload, signature); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Failed to serialise token header or payload to JSON.", e); + } + } +} diff --git a/simple-jwt/src/main/java/com/onixbyte/jwt/impl/HmacTokenResolver.java b/simple-jwt/src/main/java/com/onixbyte/jwt/impl/HmacTokenResolver.java new file mode 100644 index 0000000..e0eabf1 --- /dev/null +++ b/simple-jwt/src/main/java/com/onixbyte/jwt/impl/HmacTokenResolver.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2024-2025 OnixByte. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.onixbyte.jwt.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onixbyte.jwt.TokenResolver; +import com.onixbyte.jwt.constant.Algorithm; +import com.onixbyte.jwt.constant.RegisteredClaims; +import com.onixbyte.jwt.data.RawTokenComponent; +import com.onixbyte.jwt.holder.ObjectMapperHolder; +import com.onixbyte.jwt.util.CryptoUtil; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +/** + * Implementation of {@link TokenResolver} that resolves and verifies HMAC-signed JSON Web + * Tokens (JWTs). + *

+ * This class splits a JWT into its components, verifies its signature using an HMAC algorithm, and + * deserialises the header and payload into usable data structures. It ensures the secret key meets + * the minimum length requirement for the specified algorithm. + * + * @author zihluwang + */ +public class HmacTokenResolver implements TokenResolver { + + private final Algorithm algorithm; + private final byte[] secret; + + private final ObjectMapper objectMapper; + + /** + * Constructs an HMAC token resolver with the specified algorithm and secret key. + *

+ * Validates that the secret key length meets the minimum requirement for the chosen algorithm. + * + * @param algorithm the HMAC algorithm used for signature verification (e.g., HS256, + * HS384, HS512) + * @param secret the secret key as a string, used to verify the HMAC signature + * @throws IllegalArgumentException if the secret key is shorter than the minimum required + * length for the specified algorithm + */ + public HmacTokenResolver(Algorithm algorithm, String secret) { + var _minSecretLength = algorithm.getShaLength() >> 3; + var secretBytesLength = secret.getBytes(StandardCharsets.UTF_8).length; + if (secretBytesLength < _minSecretLength) { + throw new IllegalArgumentException("Secret key too short for HS%d: minimum %d bytes required, got %d" + .formatted(algorithm.getShaLength(), _minSecretLength, secretBytesLength) + ); + } + + this.algorithm = algorithm; + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.objectMapper = ObjectMapperHolder.getInstance().getObjectMapper(); + } + + /** + * Splits a JWT into its raw components: header, payload, and signature. + * + * @param token the JWT string to split + * @return a {@link RawTokenComponent} containing the header, payload, and signature as strings + * @throws IllegalArgumentException if the token does not consist of exactly three parts + * separated by dots + */ + @Override + public RawTokenComponent splitToken(String token) { + var tokenTuple = token.split("\\."); + + if (tokenTuple.length != 3) { + throw new IllegalArgumentException( + "The provided JWT is invalid: it must consist of exactly three parts separated by dots."); + } + + return new RawTokenComponent(tokenTuple[0], tokenTuple[1], tokenTuple[2]); + } + + /** + * Verifies the HMAC signature of the provided JWT. + *

+ * Splits the token into its components and uses the configured algorithm and secret to check + * the signature's validity. If the signature does not match, an exception is thrown by the + * underlying cryptographic utility. + * + * @param token the JWT string to verify + * @throws IllegalArgumentException if the token is malformed or the signature verification + * fails due to an invalid algorithm, key, or + * mismatched signature + */ + @Override + public void verify(String token) { + var _token = splitToken(token); + + var isValid = CryptoUtil.verifySignatureFor(algorithm, + secret, + _token.header(), + _token.payload(), + _token.signature().getBytes(StandardCharsets.UTF_8) + ); + if (!isValid) throw new IllegalArgumentException( + "JWT signature verification failed: the token may be tampered with or invalid."); + } + + /** + * Retrieves the header claims from the provided JWT. + *

+ * Decodes the Base64-encoded header and deserialises it into a map of strings. + * + * @param token the JWT string from which to extract the header + * @return a map containing the header claims as key-value pairs + * @throws IllegalArgumentException if the token is malformed or the header cannot be + * deserialised due to invalid JSON format + */ + @Override + public Map getHeader(String token) { + var _token = splitToken(token); + + var headerBytes = Base64.getDecoder().decode(_token.header()); + var headerJson = new String(headerBytes); + + try { + return objectMapper.readValue(headerJson, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException( + "Failed to deserialise JWT header: the header JSON is invalid or malformed.", e + ); + } + } + + /** + * Retrieves the payload claims from the provided JWT, excluding registered claims. + *

+ * Decodes the Base64-encoded payload, deserialises it into a map, and removes any registered + * claims as defined in {@link RegisteredClaims}. + * + * @param token the JWT string from which to extract the payload + * @return a map containing the custom payload claims as key-value pairs + * @throws IllegalArgumentException if the token is malformed or the payload cannot be + * deserialised due to invalid JSON format + */ + @Override + public Map getPayload(String token) { + var _token = splitToken(token); + + var payloadBytes = Base64.getDecoder().decode(_token.payload()); + var payloadJson = new String(payloadBytes); + + try { + var payloadMap = objectMapper.readValue(payloadJson, new TypeReference>() { + }); + + payloadMap.keySet().removeIf(RegisteredClaims.VALUES::contains); + return payloadMap; + } catch (JsonProcessingException e) { + throw new IllegalArgumentException( + "Failed to deserialise JWT payload: the payload JSON is invalid or malformed.", e + ); + } + } +} diff --git a/simple-jwt/src/main/java/com/onixbyte/jwt/util/CryptoUtil.java b/simple-jwt/src/main/java/com/onixbyte/jwt/util/CryptoUtil.java new file mode 100644 index 0000000..33a067b --- /dev/null +++ b/simple-jwt/src/main/java/com/onixbyte/jwt/util/CryptoUtil.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2024-2025 OnixByte. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.onixbyte.jwt.util; + +import com.onixbyte.jwt.constant.Algorithm; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Utility class for cryptographic operations related to JWT processing. + *

+ * Provides methods for creating and verifying signatures using specified algorithms, primarily for + * JSON Web Token (JWT) authentication purposes. + * + * @author zihluwang + */ +public final class CryptoUtil { + + /** + * Private constructor to prevent instantiation of this utility class. + */ + private CryptoUtil() { + } + + private static final byte JWT_PART_SEPARATOR = (byte) 46; + + /** + * Creates a signature for the given header and payload using the specified algorithm + * and secret. + * + * @param algorithm the cryptographic algorithm to use (e.g., HMAC-SHA256) + * @param secret the secret key bytes used for signing + * @param header the header bytes to include in the signature + * @param payload the payload bytes to include in the signature + * @return the generated signature bytes + * @throws IllegalArgumentException if the algorithm is not supported or the key is invalid + */ + public static byte[] createSignatureFor( + Algorithm algorithm, + byte[] secret, + byte[] header, + byte[] payload) { + try { + final var mac = Mac.getInstance(algorithm.getAlgorithm()); + mac.init(new SecretKeySpec(secret, algorithm.getAlgorithm())); + mac.update(header); + mac.update(JWT_PART_SEPARATOR); + return mac.doFinal(payload); + } catch (InvalidKeyException e) { + throw new IllegalArgumentException("The provided secret key is invalid for the algorithm '%s'." + .formatted(algorithm.getAlgorithm()), e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException("The specified algorithm '%s' is not supported." + .formatted(algorithm.getAlgorithm()), e); + } + } + + /** + * Verifies the signature for the given header and payload using the specified algorithm + * and secret. + *

+ * This method converts the header and payload strings to UTF-8 bytes before verification. + * + * @param algorithm the cryptographic algorithm used for signing + * @param secretBytes the secret key bytes used for signing + * @param header the header string to verify + * @param payload the payload string to verify + * @param signatureBytes the signature bytes to check against + * @return {@code true} if the signature is valid, {@code false} otherwise + * @throws IllegalArgumentException if the algorithm is not supported or the key is invalid + */ + public static boolean verifySignatureFor( + Algorithm algorithm, + byte[] secretBytes, + String header, + String payload, + byte[] signatureBytes) { + return verifySignatureFor( + algorithm, + secretBytes, + header.getBytes(StandardCharsets.UTF_8), + payload.getBytes(StandardCharsets.UTF_8), + signatureBytes); + } + + /** + * Verifies the signature for the given header and payload bytes using the specified algorithm + * and secret. + * + * @param algorithm the cryptographic algorithm used for signing + * @param secretBytes the secret key bytes used for signing + * @param headerBytes the header bytes to verify + * @param payloadBytes the payload bytes to verify + * @param signatureBytes the signature bytes to check against + * @return {@code true} if the signature matches, {@code false} otherwise + * @throws IllegalArgumentException if the algorithm is not supported or the key is invalid + */ + public static boolean verifySignatureFor( + Algorithm algorithm, + byte[] secretBytes, + byte[] headerBytes, + byte[] payloadBytes, + byte[] signatureBytes) { + return MessageDigest.isEqual( + createSignatureFor( + algorithm, + secretBytes, + headerBytes, + payloadBytes), + signatureBytes); + } + +}