JwtService.java

package edu.ucsb.cs156.frontiers.services;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import edu.ucsb.cs156.frontiers.entities.Course;
import edu.ucsb.cs156.frontiers.errors.NoLinkedOrganizationException;
import io.jsonwebtoken.Jwts;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
@Slf4j
public class JwtService {
  @Value("${app.private.key:no-key-present}")
  private String privateKey;

  @Value("${app.client.id:no-client-id}")
  private String clientId;

  private final RestTemplate restTemplate;

  private final ObjectMapper objectMapper;

  private final DateTimeProvider dateTimeProvider;

  public JwtService(
      RestTemplateBuilder restTemplateBuilder,
      ObjectMapper objectMapper,
      DateTimeProvider dateTimeProvider) {
    this.restTemplate = restTemplateBuilder.build();
    this.objectMapper = objectMapper;
    this.dateTimeProvider = dateTimeProvider;
  }

  private RSAPrivateKey getPrivateKey() throws NoSuchAlgorithmException, InvalidKeySpecException {
    String key = privateKey;
    key = key.replace("-----BEGIN PRIVATE KEY-----", "");
    key = key.replace("-----END PRIVATE KEY-----", "");
    key = key.replaceAll(" ", "");
    key = key.replaceAll(System.lineSeparator(), "");
    PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(key.getBytes()));
    KeyFactory kf = KeyFactory.getInstance("RSA");
    return (RSAPrivateKey) kf.generatePrivate(spec);
  }

  /**
   * Method to retrieve a signed JWT that a service can use to authenticate with GitHub as an app
   * installation without permissions to a specific organization.
   *
   * @return Signed JWT that expires in 5 minutes in the form of a String
   * @throws InvalidKeySpecException if the key is invalid, the exception will be thrown.
   */
  public String getJwt() throws NoSuchAlgorithmException, InvalidKeySpecException {
    Instant currentTime = Instant.from(dateTimeProvider.getNow().get());
    String token =
        Jwts.builder()
            .issuedAt(Date.from(currentTime.minus(30, ChronoUnit.SECONDS)))
            .expiration(Date.from(currentTime.plus(5, ChronoUnit.MINUTES)))
            .issuer(clientId)
            .signWith(getPrivateKey(), Jwts.SIG.RS256)
            .compact();
    return token;
  }

  /**
   * Method to retrieve a token to act as a particular app installation in a particular organization
   *
   * @param course ID of the particular app installation to act as
   * @return Token accepted by GitHub to act as a particular installation.
   */
  public String getInstallationToken(Course course)
      throws JsonProcessingException,
          NoSuchAlgorithmException,
          InvalidKeySpecException,
          NoLinkedOrganizationException {
    if (course.getOrgName() == null || course.getInstallationId() == null) {
      throw new NoLinkedOrganizationException(course.getCourseName());
    } else {
      String token = getJwt();
      String ENDPOINT =
          "https://api.github.com/app/installations/"
              + course.getInstallationId()
              + "/access_tokens";
      HttpHeaders headers = new HttpHeaders();
      headers.add("Authorization", "Bearer " + token);
      headers.add("Accept", "application/vnd.github+json");
      headers.add("X-GitHub-Api-Version", "2022-11-28");
      HttpEntity<String> entity = new HttpEntity<>(headers);
      ResponseEntity<String> response =
          restTemplate.exchange(ENDPOINT, HttpMethod.POST, entity, String.class);
      JsonNode responseJson = objectMapper.readTree(response.getBody());
      String installationToken = responseJson.get("token").asText();
      return installationToken;
    }
  }
}