SignedApiTokenSecurity.java

/*
 * Copyright 2025 Global Crop Diversity Trust
 *
 * 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 org.genesys.blocks.tokenauth.service.impl;

import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Base64.Decoder;
import java.util.Base64.Encoder;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.genesys.blocks.tokenauth.service.ApiTokenSecurity;

import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SignedApiTokenSecurity implements ApiTokenSecurity {

	SecretKeySpec secretKey;

	Encoder base64encoder = Base64.getUrlEncoder();
	Decoder base64decoder = Base64.getUrlDecoder();

	public SignedApiTokenSecurity(byte[] signingKey) {
		 secretKey = new SecretKeySpec(signingKey, "HmacSHA256");
	}

	/**
	 * Does the {@code secureToken} look like something we support?
	 *
	 * @return {@code true} if in 
	 */
	@Override
	public boolean supports(@NonNull String secureToken) {
		if (StringUtils.countMatches(secureToken, '.') == 1) {
			return true;
		}
		log.trace("Secure token {} is not in the right format.", secureToken);
		return false;
	}

	/**
	 * The secured token is Base64.encode(token) + "." + Base64.encode(signature)
	 * @throws GeneralSecurityException When stuff happens
	 * @return A signed token
	 */
	@Override
	public String signToken(byte[] tokenBytes) throws GeneralSecurityException {
		Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
		sha256_HMAC.init(secretKey);
		var digitalSignature = sha256_HMAC.doFinal(tokenBytes);

		var signedToken = base64encoder.encodeToString(tokenBytes) + "." + base64encoder.encodeToString(DigestUtils.sha256(digitalSignature));
		log.trace("For produced signed token {}", signedToken);
		return signedToken;
	}

	@Override
	public byte[] getRawToken(String secureToken) throws GeneralSecurityException {
		// To read: formatted token -> UUID + Signature
		// Check signature: 
		if (! supports(secureToken)) {
			throw new GeneralSecurityException("Invalid token format");
		}

		var splitToken = secureToken.split("\\.", 2);
		if (splitToken.length != 2) {
			throw new GeneralSecurityException("Invalid token format");
		}
		log.trace("Split token into data={} and signature={}", splitToken[0], splitToken[1]);

		byte[] tokenData = null;
		byte[] tokenSignature = null;
		try {
			tokenData = base64decoder.decode(splitToken[0]);
			tokenSignature = base64decoder.decode(splitToken[1]);
		} catch (IllegalArgumentException e) {
			log.warn("Token data={} or signature={} are not Base64 encoded", splitToken[0], splitToken[1]);
			throw new GeneralSecurityException("Invalid token encoding");
		}

		Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
		sha256_HMAC.init(secretKey);
		var digitalSignature = sha256_HMAC.doFinal(tokenData);
		var signatureHash = DigestUtils.sha256(digitalSignature);		
		var isValid = Arrays.equals(signatureHash, tokenSignature);
		log.debug("Token signature is {}", isValid ? "valid" : "not valid");
		if (isValid) {
			return tokenData;
		} else {
			throw new GeneralSecurityException("Invalid signature");
		}
	}
}