GdchCredentials.java

/*
 * Copyright 2022, Google Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *    * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *    * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *
 *    * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.google.auth.oauth2;

import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.http.json.JsonHttpContent;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.json.webtoken.JsonWebSignature;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.api.client.util.Clock;
import com.google.api.client.util.GenericData;
import com.google.api.client.util.PemReader;
import com.google.api.client.util.SecurityUtils;
import com.google.api.client.util.StringUtils;
import com.google.api.core.ObsoleteApi;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.Reader;
import java.io.StringReader;
import java.math.BigInteger;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.AlgorithmParameters;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECParameterSpec;
import java.security.spec.ECPrivateKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import java.util.Objects;

public class GdchCredentials extends GoogleCredentials {
  private static final LoggerProvider LOGGER_PROVIDER =
      LoggerProvider.forClazz(GdchCredentials.class);
  private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. ";

  /**
   * The expected format version for GDCH credential profiles. Version "1" indicates the initial and
   * currently supported JSON format for these credentials. See go/gdch-python-auth-lib for more
   * info.
   */
  @VisibleForTesting static final String SUPPORTED_JSON_FORMAT_VERSION = "1";

  // Custom URN used by GDCH to identify service account tokens in token exchange requests.
  // See go/gdch-python-auth-lib for more information.
  private static final String SERVICE_ACCOUNT_TOKEN_TYPE =
      "urn:k8s:params:oauth:token-type:serviceaccount";

  private static final String PRIVATE_KEY_PEM_TITLE = "PRIVATE KEY";
  private static final String EC_PRIVATE_KEY_PEM_TITLE = "EC PRIVATE KEY";

  private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600;

  private final PrivateKey privateKey;
  private final String privateKeyId;
  private final String projectId;
  private final String serviceIdentityName;
  private final URI tokenServerUri;
  private final String apiAudience;
  private final int lifetime;
  private final String transportFactoryClassName;
  private final String caCertPath;
  private transient HttpTransportFactory transportFactory;

  /**
   * Internal constructor.
   *
   * @param builder A builder for {@link GdchCredentials} See {@link GdchCredentials.Builder}.
   */
  @VisibleForTesting
  GdchCredentials(GdchCredentials.Builder builder) {
    this.projectId = Preconditions.checkNotNull(builder.projectId);
    this.privateKeyId = Preconditions.checkNotNull(builder.privateKeyId);
    this.privateKey = Preconditions.checkNotNull(builder.privateKey);
    this.serviceIdentityName = Preconditions.checkNotNull(builder.serviceIdentityName);
    this.tokenServerUri = Preconditions.checkNotNull(builder.tokenServerUri);
    this.transportFactory = Preconditions.checkNotNull(builder.transportFactory);
    this.transportFactoryClassName = this.transportFactory.getClass().getName();
    this.caCertPath = builder.caCertPath;
    this.apiAudience = builder.apiAudience;
    this.lifetime = builder.lifetime;
    this.name = GoogleCredentialsInfo.GDCH_CREDENTIALS.getCredentialName();
  }

  /**
   * Returns credentials defined by a GdchCredentials key file in JSON format from the Google
   * Developers Console.
   *
   * <p>Important: If you accept a credential configuration (credential JSON/File/Stream) from an
   * external source for authentication to Google Cloud Platform, you must validate it before
   * providing it to any Google API or library. Providing an unvalidated credential configuration to
   * Google APIs can compromise the security of your systems and data. For more information, refer
   * to {@see <a
   * href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}.
   *
   * @param credentialsStream the stream with the credential definition.
   * @return the credential defined by the credentialsStream.
   * @throws IOException if the credential cannot be created from the stream.
   */
  public static GdchCredentials fromStream(InputStream credentialsStream) throws IOException {
    return fromStream(credentialsStream, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
  }

  /**
   * Returns credentials defined by a GdchCredentials key file in JSON format from the Google
   * Developers Console.
   *
   * <p>Important: If you accept a credential configuration (credential JSON/File/Stream) from an
   * external source for authentication to Google Cloud Platform, you must validate it before
   * providing it to any Google API or library. Providing an unvalidated credential configuration to
   * Google APIs can compromise the security of your systems and data. For more information, refer
   * to {@see <a
   * href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}.
   *
   * @param credentialsStream the stream with the credential definition.
   * @param transportFactory HTTP transport factory, creates the transport used to get access
   *     tokens.
   * @return the credential defined by the credentialsStream.
   * @throws IOException if the credential cannot be created from the stream.
   */
  public static GdchCredentials fromStream(
      InputStream credentialsStream, HttpTransportFactory transportFactory) throws IOException {
    Preconditions.checkNotNull(transportFactory);
    GenericJson fileContents = parseJsonInputStream(credentialsStream);
    String fileType = extractFromJson(fileContents, "type");
    if (fileType.equals(GoogleCredentialsInfo.GDCH_CREDENTIALS.getFileType())) {
      return fromJson(fileContents, transportFactory);
    }

    throw new IOException(
        String.format(
            "Error reading credentials from stream, 'type' value '%s' not recognized."
                + " Expecting '%s'.",
            fileType, GoogleCredentialsInfo.GDCH_CREDENTIALS.getFileType()));
  }

  /**
   * Create GDCH service account credentials defined by JSON.
   *
   * @param json a map from the JSON representing the credentials.
   * @return the GDCH service account credentials defined by the JSON.
   * @throws IOException if the credential cannot be created from the JSON.
   */
  static GdchCredentials fromJson(Map<String, Object> json) throws IOException {
    String caCertPath = (String) json.get("ca_cert_path");
    return fromJson(json, new TransportFactoryForGdch(caCertPath));
  }

  /**
   * Create GDCH service account credentials defined by JSON.
   *
   * @param json a map from the JSON representing the credentials.
   * @param transportFactory HTTP transport factory, creates the transport used to get access
   *     tokens.
   * @return the GDCH service account credentials defined by the JSON.
   * @throws IOException if the credential cannot be created from the JSON.
   */
  @VisibleForTesting
  static GdchCredentials fromJson(Map<String, Object> json, HttpTransportFactory transportFactory)
      throws IOException {
    String formatVersion = validateField((String) json.get("format_version"), "format_version");
    String projectId = validateField((String) json.get("project"), "project");
    String privateKeyId = validateField((String) json.get("private_key_id"), "private_key_id");
    String privateKeyPem = validateField((String) json.get("private_key"), "private_key");
    String serviceIdentityName = validateField((String) json.get("name"), "name");
    String tokenServerUriStringFromCreds =
        validateField((String) json.get("token_uri"), "token_uri");
    String caCertPath = (String) json.get("ca_cert_path");

    if (!SUPPORTED_JSON_FORMAT_VERSION.equals(formatVersion)) {
      throw new IOException(
          String.format("Only format version %s is supported.", SUPPORTED_JSON_FORMAT_VERSION));
    }

    URI tokenServerUriFromCreds = null;
    try {
      tokenServerUriFromCreds = new URI(tokenServerUriStringFromCreds);
    } catch (URISyntaxException e) {
      throw new IOException("Token server URI specified in 'token_uri' could not be parsed.");
    }

    GdchCredentials.Builder builder =
        GdchCredentials.newBuilder()
            .setProjectId(projectId)
            .setPrivateKeyId(privateKeyId)
            .setTokenServerUri(tokenServerUriFromCreds)
            .setServiceIdentityName(serviceIdentityName)
            .setCaCertPath(caCertPath)
            .setHttpTransportFactory(transportFactory);

    return fromPem(privateKeyPem, builder);
  }

  /**
   * Reads a private key from a PEM encoded string, supporting both PKCS#8 and SEC1 formats.
   *
   * <p>If the key is labeled with "PRIVATE KEY", it is parsed as PKCS#8 as per RFC 7468 Section 10.
   * If it is labeled with "EC PRIVATE KEY", it is parsed as SEC1 as per RFC 5915 Section 3.
   *
   * @see <a href="https://datatracker.ietf.org/doc/html/rfc7468#section-10">RFC 7468 Section 10</a>
   * @see <a href="https://datatracker.ietf.org/doc/html/rfc5915#section-3">RFC 5915 Section 3</a>
   * @param privateKeyPem EC private key object for the service account in PEM format (PKCS#8 or
   *     SEC1).
   * @param builder A builder for GdchCredentials.
   * @return an instance of GdchCredentials.
   */
  static GdchCredentials fromPem(String privateKeyPem, GdchCredentials.Builder builder)
      throws IOException {
    Reader reader = new StringReader(privateKeyPem);
    // Read the first section regardless of title
    PemReader.Section section = PemReader.readFirstSectionAndClose(reader);

    if (section == null) {
      throw new GoogleAuthException(false, 0, "Invalid key data: no PEM section found.", null);
    }

    String title = section.getTitle();
    PrivateKey privateKey;

    if (PRIVATE_KEY_PEM_TITLE.equals(title)) {
      privateKey = OAuth2Utils.privateKeyFromPkcs8(privateKeyPem, OAuth2Utils.Pkcs8Algorithm.EC);
    } else if (EC_PRIVATE_KEY_PEM_TITLE.equals(title)) {
      privateKey = privateKeyFromSec1(section.getBase64DecodedBytes());
    } else {
      throw new GoogleAuthException(false, 0, "Unsupported key type: " + title, null);
    }

    builder.setPrivateKey(privateKey);

    return new GdchCredentials(builder);
  }

  /**
   * This method is obsolete. Please use {@link #createWithGdchAudience(String)}} instead. Create a
   * copy of GDCH credentials with the specified audience.
   *
   * @param apiAudience The intended audience for GDCH credentials.
   */
  @ObsoleteApi("Use createWithGdchAudience(String) instead.")
  public GdchCredentials createWithGdchAudience(URI apiAudience) {
    Preconditions.checkNotNull(
        apiAudience, "Audience are not configured for GDCH service account credentials.");
    return this.toBuilder().setGdchAudience(apiAudience.toString()).build();
  }

  /**
   * Create a copy of GDCH credentials with the specified audience.
   *
   * @param apiAudience The intended audience for GDCH credentials.
   */
  public GdchCredentials createWithGdchAudience(String apiAudience) {
    if (Strings.isNullOrEmpty(apiAudience)) {
      throw new IllegalArgumentException(
          "Audience cannot be null or empty for GDCH service account credentials.");
    }
    return this.toBuilder().setGdchAudience(apiAudience).build();
  }

  /**
   * Refresh the OAuth2 access token by getting a new access token using a JSON Web Token (JWT).
   *
   * <p>For GDCH credentials, this class creates a self-signed JWT, and sends to the GDCH
   * authentication endpoint (tokenServerUri) to exchange an access token for the intended api
   * audience (apiAudience).
   */
  @Override
  public AccessToken refreshAccessToken() throws IOException {
    Preconditions.checkNotNull(
        this.apiAudience,
        "Audience cannot be null or empty for GDCH service account credentials. "
            + "Specify the audience by calling createWithGdchAudience.");

    JsonFactory jsonFactory = GsonFactory.getDefaultInstance();

    long currentTime = Clock.SYSTEM.currentTimeMillis();
    String assertion = createAssertion(jsonFactory, currentTime);

    GenericData tokenRequest = new GenericData();
    tokenRequest.set("audience", apiAudience);
    tokenRequest.set("grant_type", OAuth2Utils.TOKEN_TYPE_TOKEN_EXCHANGE);
    tokenRequest.set("requested_token_type", OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN);
    tokenRequest.set("subject_token", assertion);
    tokenRequest.set("subject_token_type", SERVICE_ACCOUNT_TOKEN_TYPE);

    JsonHttpContent content = new JsonHttpContent(jsonFactory, tokenRequest);

    HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
    HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(tokenServerUri), content);
    // Disable automatic logging by google-http-java-client to prevent leakage of sensitive tokens.
    // Client Library Debug Logging via LoggingUtils is used instead.
    request.setLoggingEnabled(false);

    request.setParser(new JsonObjectParser(jsonFactory));

    HttpResponse response;
    String errorTemplate = "Error getting access token for GDCH service account: %s, iss: %s";

    try {
      LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to get GDCH access token");
      response = request.execute();
      LoggingUtils.logResponse(
          response, LOGGER_PROVIDER, "Received response for GDCH access token");
    } catch (HttpResponseException re) {
      String message = String.format(errorTemplate, re.getMessage(), getServiceIdentityName());
      throw GoogleAuthException.createWithTokenEndpointResponseException(re, message);
    } catch (IOException e) {
      String message = String.format(errorTemplate, e.getMessage(), getServiceIdentityName());
      throw GoogleAuthException.createWithTokenEndpointIOException(e, message);
    }

    GenericData responseData = response.parseAs(GenericData.class);
    LoggingUtils.logResponsePayload(
        responseData, LOGGER_PROVIDER, "Response payload for GDCH access token");
    String accessToken =
        OAuth2Utils.validateString(responseData, "access_token", PARSE_ERROR_PREFIX);
    int expiresInSeconds =
        OAuth2Utils.validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX);
    long expiresAtMilliseconds = clock.currentTimeMillis() + expiresInSeconds * 1000L;
    return new AccessToken(accessToken, new Date(expiresAtMilliseconds));
  }

  /**
   * Create a self-signed JWT for GDCH authentication flow.
   *
   * <p>The self-signed JWT is used to exchange access token from GDCH authentication
   * (tokenServerUri), not for API call. It uses the serviceIdentityName as the `iss` and `sub`
   * claim, and the tokenServerUri as the `aud` claim. The JWT is signed with the privateKey.
   */
  String createAssertion(JsonFactory jsonFactory, long currentTime) throws IOException {
    JsonWebSignature.Header header = new JsonWebSignature.Header();
    header.setAlgorithm("ES256");
    header.setType("JWT");
    header.setKeyId(privateKeyId);

    JsonWebToken.Payload payload = new JsonWebToken.Payload();
    payload.setIssuer(getIssuerSubjectValue(projectId, serviceIdentityName));
    payload.setSubject(getIssuerSubjectValue(projectId, serviceIdentityName));
    payload.setIssuedAtTimeSeconds(currentTime / 1000);
    payload.setExpirationTimeSeconds(currentTime / 1000 + this.lifetime);
    payload.setAudience(tokenServerUri.toString());

    String assertion;
    try {
      assertion = signUsingEsSha256(privateKey, jsonFactory, header, payload);
    } catch (GeneralSecurityException e) {
      throw new GoogleAuthException(
          false, 0, "Error signing service account access token request with private key.", e);
    }

    return assertion;
  }

  /**
   * Get the issuer and subject value in the format GDCH token server required.
   *
   * <p>This value is specific to GDCH and combined parameter used for both `iss` and `sub` fields
   * in JWT claim.
   */
  @VisibleForTesting
  static String getIssuerSubjectValue(String projectId, String serviceIdentityName) {
    return String.format("system:serviceaccount:%s:%s", projectId, serviceIdentityName);
  }

  /**
   * @return the projectId set in the GDCH SA Key file or the user set projectId
   */
  @Override
  public final String getProjectId() {
    return projectId;
  }

  public final String getPrivateKeyId() {
    return privateKeyId;
  }

  public final PrivateKey getPrivateKey() {
    return privateKey;
  }

  public final String getServiceIdentityName() {
    return serviceIdentityName;
  }

  public final URI getTokenServerUri() {
    return tokenServerUri;
  }

  /**
   * Returns the underlying audience string set for this credentials object.
   *
   * @return the audience string, or null if no audience has been set.
   */
  public final String getGdchAudience() {
    return apiAudience;
  }

  /**
   * NOTE: This method is obsolete, please use {@link #getGdchAudience()} instead. Returns a URI
   * representation of the underlying audience string set for this credentials object. This method
   * may fail if the underlying audience string does not conform to a URI format.
   *
   * @return a URI object representing the audience of the credentials, or null if no audience has
   *     been set or if the audience string is not a valid URI.
   */
  @ObsoleteApi("Use getGdchAudience() instead.")
  public final URI getApiAudience() {
    if (Strings.isNullOrEmpty(apiAudience)) {
      return null;
    }
    try {
      return new URI(apiAudience);
    } catch (URISyntaxException e) {
      return null;
    }
  }

  public final HttpTransportFactory getTransportFactory() {
    return transportFactory;
  }

  public final String getCaCertPath() {
    return caCertPath;
  }

  public static Builder newBuilder() {
    return new Builder();
  }

  @Override
  public Builder toBuilder() {
    return new Builder(this);
  }

  @SuppressWarnings("unused")
  private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
    // properly deserialize the transient transportFactory.
    input.defaultReadObject();
    transportFactory = newInstance(transportFactoryClassName);
  }

  @Override
  public int hashCode() {
    return Objects.hash(
        projectId,
        privateKeyId,
        privateKey,
        serviceIdentityName,
        tokenServerUri,
        transportFactoryClassName,
        apiAudience,
        caCertPath,
        lifetime);
  }

  @Override
  public String toString() {
    return MoreObjects.toStringHelper(this)
        .add("projectId", projectId)
        .add("privateKeyId", privateKeyId)
        .add("serviceIdentityName", serviceIdentityName)
        .add("tokenServerUri", tokenServerUri)
        .add("transportFactoryClassName", transportFactoryClassName)
        .add("caCertPath", caCertPath)
        .add("apiAudience", apiAudience)
        .add("lifetime", lifetime)
        .toString();
  }

  @Override
  public boolean equals(Object obj) {
    if (!(obj instanceof GdchCredentials)) {
      return false;
    }
    GdchCredentials other = (GdchCredentials) obj;
    return Objects.equals(this.projectId, other.projectId)
        && Objects.equals(this.privateKeyId, other.privateKeyId)
        && Objects.equals(this.privateKey, other.privateKey)
        && Objects.equals(this.serviceIdentityName, other.serviceIdentityName)
        && Objects.equals(this.tokenServerUri, other.tokenServerUri)
        && Objects.equals(this.transportFactoryClassName, other.transportFactoryClassName)
        && Objects.equals(this.apiAudience, other.apiAudience)
        && Objects.equals(this.caCertPath, other.caCertPath)
        && Objects.equals(this.lifetime, other.lifetime);
  }

  static InputStream readStream(File file) throws FileNotFoundException {
    return new FileInputStream(file);
  }

  public static class Builder extends GoogleCredentials.Builder {
    private String projectId;
    private String privateKeyId;
    private PrivateKey privateKey;
    private String serviceIdentityName;
    private URI tokenServerUri;
    private String apiAudience;
    private HttpTransportFactory transportFactory;
    private String caCertPath;
    private int lifetime = DEFAULT_LIFETIME_IN_SECONDS;

    protected Builder() {}

    protected Builder(GdchCredentials credentials) {
      this.projectId = credentials.projectId;
      this.privateKeyId = credentials.privateKeyId;
      this.privateKey = credentials.privateKey;
      this.serviceIdentityName = credentials.serviceIdentityName;
      this.tokenServerUri = credentials.tokenServerUri;
      this.transportFactory = credentials.transportFactory;
      this.caCertPath = credentials.caCertPath;
      this.lifetime = credentials.lifetime;
    }

    @CanIgnoreReturnValue
    public Builder setProjectId(String projectId) {
      this.projectId = projectId;
      return this;
    }

    @CanIgnoreReturnValue
    public Builder setPrivateKeyId(String privateKeyId) {
      this.privateKeyId = privateKeyId;
      return this;
    }

    @CanIgnoreReturnValue
    public Builder setPrivateKey(PrivateKey privateKey) {
      this.privateKey = privateKey;
      return this;
    }

    @CanIgnoreReturnValue
    public Builder setServiceIdentityName(String name) {
      this.serviceIdentityName = name;
      return this;
    }

    @CanIgnoreReturnValue
    public Builder setTokenServerUri(URI tokenServerUri) {
      this.tokenServerUri = tokenServerUri;
      return this;
    }

    @CanIgnoreReturnValue
    public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
      this.transportFactory = transportFactory;
      return this;
    }

    @CanIgnoreReturnValue
    public Builder setCaCertPath(String caCertPath) {
      this.caCertPath = caCertPath;
      return this;
    }

    /**
     * Sets the intended audience for GDCH credentials.
     *
     * @param apiAudience The audience string. Cannot be null or empty.
     * @return this builder.
     * @throws IllegalArgumentException if the audience is null or empty.
     */
    @CanIgnoreReturnValue
    public Builder setGdchAudience(String apiAudience) {
      if (Strings.isNullOrEmpty(apiAudience)) {
        throw new IllegalArgumentException(
            "Audience cannot be null or empty for GDCH service account credentials.");
      }
      this.apiAudience = apiAudience;
      return this;
    }

    public String getProjectId() {
      return projectId;
    }

    public String getPrivateKeyId() {
      return privateKeyId;
    }

    public PrivateKey getPrivateKey() {
      return privateKey;
    }

    public String getServiceIdentityName() {
      return serviceIdentityName;
    }

    public URI getTokenServerUri() {
      return tokenServerUri;
    }

    public HttpTransportFactory getHttpTransportFactory() {
      return transportFactory;
    }

    public String getCaCertPath() {
      return caCertPath;
    }

    public int getLifetime() {
      return lifetime;
    }

    @Override
    public GdchCredentials build() {
      return new GdchCredentials(this);
    }
  }

  private static String validateField(String field, String fieldName) throws IOException {
    if (field == null || field.isEmpty()) {
      throw new IOException(
          String.format(
              "Error reading GDCH service account credential from JSON, %s is misconfigured.",
              fieldName));
    }
    return field;
  }

  /*
   * Internal HttpTransportFactory for GDCH credentials.
   *
   * <p> GDCH authentication server could use a self-signed certificate, thus the
   * client could
   * provide the CA certificate path through the `ca_cert_path` in GDCH JSON file.
   *
   * <p> The TransportFactoryForGdch subclass would read the certificate and
   * create a trust store,
   * then use the trust store to create a transport.
   *
   * <p> If the GDCH authentication server uses well known CA certificate, then a
   * regular transport
   * would be set.
   */
  static class TransportFactoryForGdch implements HttpTransportFactory {
    HttpTransport transport;

    public TransportFactoryForGdch(String caCertPath) throws IOException {
      setTransport(caCertPath);
    }

    @Override
    public HttpTransport create() {
      return transport;
    }

    private void setTransport(String caCertPath) throws IOException {
      if (caCertPath == null || caCertPath.isEmpty()) {
        this.transport = new NetHttpTransport();
        return;
      }
      try {
        InputStream certificateStream = readStream(new File(caCertPath));
        this.transport =
            new NetHttpTransport.Builder().trustCertificatesFromStream(certificateStream).build();
      } catch (IOException e) {
        throw new IOException(
            String.format(
                "Error reading certificate file from CA cert path, value '%s': %s",
                caCertPath, e.getMessage()),
            e);
      } catch (GeneralSecurityException e) {
        throw new IOException("Error initiating transport with certificate stream.", e);
      }
    }
  }

  /**
   * Signs the JWS header and payload using the ES256 algorithm (ECDSA with SHA-256).
   *
   * <p>The ES256 algorithm is defined in <a
   * href="https://tools.ietf.org/html/rfc7518#section-3.4">RFC 7518 Section 3.4</a>. This method
   * follows the JWS Compact Serialization format described in <a
   * href="https://tools.ietf.org/html/rfc7515#section-3.1">RFC 7515 Section 3.1</a>.
   *
   * <p>Unlike RSA signatures, ECDSA signatures produced by the Java Cryptography Architecture (JCA)
   * are DER-encoded. This method transcodes the DER-encoded signature into the concatenated R|S
   * format required by the JWS standard, as specified in <a
   * href="https://tools.ietf.org/html/rfc7515#appendix-A.3">RFC 7515 Appendix A.3</a>.
   *
   * @param privateKey The Elliptic Curve private key used for signing.
   * @param jsonFactory The JSON factory to serialize header and payload.
   * @param header The JWS header (e.g., containing "alg": "ES256").
   * @param payload The JWS payload containing claims like "iss", "sub", and "aud".
   * @return A complete, signed JWS string in the format {@code [header].[payload].[signature]}.
   * @throws GeneralSecurityException If signing fails due to cryptographic errors.
   * @throws IOException If serialization or transcoding fails.
   */
  @VisibleForTesting
  static String signUsingEsSha256(
      PrivateKey privateKey,
      JsonFactory jsonFactory,
      JsonWebSignature.Header header,
      JsonWebToken.Payload payload)
      throws GeneralSecurityException, GoogleAuthException {

    try {
      // 1. Construct the JWS Signing Input: Base64URL(UTF8(Header)) + '.' +
      // Base64URL(UTF8(Payload))
      String content =
          Base64.getUrlEncoder().withoutPadding().encodeToString(jsonFactory.toByteArray(header))
              + "."
              + Base64.getUrlEncoder()
                  .withoutPadding()
                  .encodeToString(jsonFactory.toByteArray(payload));
      byte[] contentBytes = StringUtils.getBytesUtf8(content);

      // 2. Create the digital signature using SHA256withECDSA.
      byte[] signature =
          SecurityUtils.sign(SecurityUtils.getEs256SignatureAlgorithm(), privateKey, contentBytes);

      // 3. Transcode the signature from DER to Concatenated R|S.
      byte[] jwsSignature = transcodeDerToConcat(signature, 64);

      // 4. Return final JWS: [Signing Input] + '.' + Base64URL(Signature)
      return content + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(jwsSignature);
    } catch (IOException e) {
      throw new GoogleAuthException(false, 0, "Error serializing or transcoding JWT.", e);
    }
  }

  /**
   * Transcodes a DER-encoded ECDSA signature into the concatenated R|S format.
   *
   * <p>DER format (ASN.1): {@code SEQUENCE { r INTEGER, s INTEGER }}
   *
   * <p>Concatenated format: {@code r | s} (where {@code |} is concatenation).
   *
   * @param derSignature The raw bytes of the DER-encoded signature.
   * @param outputLength The total expected length of the concatenated signature (64 bytes for
   *     ES256).
   * @return The signature in concatenated R|S format.
   * @throws IOException If the DER format is invalid.
   */
  @VisibleForTesting
  static byte[] transcodeDerToConcat(byte[] derSignature, int outputLength)
      throws GoogleAuthException {
    // Validate basic ASN.1 DER structure (0x30 = SEQUENCE)
    if (derSignature.length < 8 || derSignature[0] != 0x30) {
      throw new GoogleAuthException(false, 0, "Invalid DER signature format.", null);
    }

    int offset = 2;
    int seqLength = derSignature[1] & 0xFF;
    // Handle long-form length encoding for the sequence
    if (seqLength == 0x81) {
      offset = 3;
      seqLength = derSignature[2] & 0xFF;
    }

    if (derSignature.length - offset != seqLength) {
      throw new GoogleAuthException(false, 0, "Invalid DER signature length.", null);
    }

    // Parse Integer R (0x02 = INTEGER)
    if (derSignature[offset++] != 0x02) {
      throw new GoogleAuthException(false, 0, "Expected INTEGER for R.", null);
    }
    int rLength = derSignature[offset++];
    // Skip leading zero byte if it exists (DER integers are signed; zero is added to stay positive)
    if (derSignature[offset] == 0x00 && rLength > 1 && (derSignature[offset + 1] & 0x80) != 0) {
      offset++;
      rLength--;
    }
    byte[] r = new byte[rLength];
    System.arraycopy(derSignature, offset, r, 0, rLength);
    offset += rLength;

    // Parse Integer S
    if (derSignature[offset++] != 0x02) {
      throw new GoogleAuthException(false, 0, "Expected INTEGER for S.", null);
    }
    int sLength = derSignature[offset++];
    if (derSignature[offset] == 0x00 && sLength > 1 && (derSignature[offset + 1] & 0x80) != 0) {
      offset++;
      sLength--;
    }
    byte[] s = new byte[sLength];
    System.arraycopy(derSignature, offset, s, 0, sLength);

    // Concatenate r and s into fixed-length segments (32 bytes each for ES256)
    int keySizeBytes = outputLength / 2;
    if (r.length > keySizeBytes || s.length > keySizeBytes) {
      throw new GoogleAuthException(
          false,
          0,
          String.format(
              "Invalid R or S length. R: %d, S: %d, Expected: %d",
              r.length, s.length, keySizeBytes),
          null);
    }

    byte[] result = new byte[outputLength];
    System.arraycopy(r, 0, result, keySizeBytes - r.length, r.length);
    System.arraycopy(s, 0, result, outputLength - s.length, s.length);

    return result;
  }

  /**
   * Parses an EC private key in SEC1 format using fixed prefix verification.
   *
   * <p>This function assumes that standard SEC1 keys for P-256 generated by OpenSSL have a known,
   * stable structure of bytes at the beginning. This "fingerprint" allows us to verify the format
   * without complete ASN.1 parsing. If the fingerprint matches, we can safely extract the private
   * key value using fixed offsets.
   *
   * @param bytes The raw bytes of the SEC1 key.
   * @return The PrivateKey object.
   * @throws GoogleAuthException If parsing fails or the key format is unsupported.
   */
  private static PrivateKey privateKeyFromSec1(byte[] bytes) throws IOException {
    if (!hasStandardSec1P256Prefix(bytes)) {
      throw new GoogleAuthException(
          false, 0, "Unsupported SEC1 key format: standard prefix not found.", null);
    }
    BigInteger s = extractPrivateKeyValue(bytes);
    return createEcPrivateKey(s);
  }

  /**
   * Verifies if the bytes start with the standard SEC1 P-256 prefix.
   *
   * <p>The prefix is derived from the standard DER encoding of the ECPrivateKey structure defined
   * in RFC 5915 Section 3. For P-256 with named curve parameters and public key included, the
   * prefix is stable: <code>[0x30, 0x77, 0x02, 0x01, 0x01, 0x04, 0x20]</code>
   *
   * @see <a href="https://datatracker.ietf.org/doc/html/rfc5915#section-3">RFC 5915 Section 3</a>
   * @param bytes The raw bytes of the key.
   * @return true if the prefix matches.
   */
  private static boolean hasStandardSec1P256Prefix(byte[] bytes) {
    if (bytes.length < 7) return false;
    return bytes[0] == 0x30 // Sequence
        && bytes[1] == 0x77 // Length
        && bytes[2] == 0x02 // Integer
        && bytes[3] == 0x01 // Length
        && bytes[4] == 0x01 // Version
        && bytes[5] == 0x04 // Octet String
        && bytes[6] == 0x20; // Length 32
  }

  /**
   * Extracts the private key value 's' from the SEC1 bytes using fixed offset.
   *
   * <p>Assumes the prefix has already been verified by {@link #hasStandardSec1P256Prefix(byte[])}.
   *
   * @param bytes The raw bytes of the key.
   * @return The BigInteger value of 's'.
   */
  private static BigInteger extractPrivateKeyValue(byte[] bytes) {
    // P-256 private key size is 32 bytes as per RFC 5915 Section 3.
    byte[] sBytes = new byte[32];
    // Copy 32 bytes starting at offset 7 (after the 7-byte metadata prefix verified by
    // hasStandardSec1P256Prefix).
    System.arraycopy(bytes, 7, sBytes, 0, 32);
    // Use signum 1 to ensure the byte array is interpreted as a positive integer.
    return new BigInteger(1, sBytes);
  }

  /**
   * Creates an EC PrivateKey from the private key value 's' using P-256 parameters.
   *
   * <p>Algorithm steps: 1. Get an instance of AlgorithmParameters for "EC". 2. Initialize it with
   * secp256r1 curve spec (requirement as per GDCH supported curve). 3. Extract ECParameterSpec from
   * parameters. 4. Create ECPrivateKeySpec with the extracted private key value and parameters. 5.
   * Generate PrivateKey using KeyFactory.
   *
   * @param s The private key value.
   * @return The PrivateKey object.
   * @throws GoogleAuthException If key creation fails.
   */
  private static PrivateKey createEcPrivateKey(BigInteger s) throws IOException {
    try {
      AlgorithmParameters params = AlgorithmParameters.getInstance("EC");

      params.init(new ECGenParameterSpec("secp256r1"));

      ECParameterSpec ecParams = params.getParameterSpec(ECParameterSpec.class);

      ECPrivateKeySpec keySpec = new ECPrivateKeySpec(s, ecParams);

      KeyFactory keyFactory = KeyFactory.getInstance("EC");

      return keyFactory.generatePrivate(keySpec);
    } catch (GeneralSecurityException e) {
      throw new GoogleAuthException(
          false,
          0,
          "Failed to create EC Private Key for GDCH. Please ensure the private key data is valid and represents a P-256 private key.",
          e);
    }
  }
}