Generating Tokens for Apple API Requests

770 views Asked by At

I create this class to access Apple API Requests

@Transactional(readOnly = true)
public class AppleAPIService {

    public static void main(String[] args) {


            Path privateKeyPath = Paths.get("/Users/ricardolle/IdeaProjects/mystic-planets-api/src/main/resources/cert/AuthKey_5425KFDYSC.p8");

    String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8);
    System.out.println("Original Key Content: " + keyContent); // Logging the original content
    keyContent = keyContent.replace("-----BEGIN PRIVATE KEY-----", "")
            .replace("-----END PRIVATE KEY-----", "")
            .replaceAll("\\s+", ""); // Remove all whitespaces and newlines, more robust than just replacing \n
    System.out.println("Processed Key Content: " + keyContent); // Logging processed content
    byte[] decodedKey = Base64.getDecoder().decode(keyContent);
    PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decodedKey);
    KeyFactory kf = KeyFactory.getInstance("EC");
    PrivateKey pk =  kf.generatePrivate(spec);

    Map<String, Object> headerMap = new HashMap<>();

    headerMap.put("alg", "ES256"); // Algorithm, e.g., RS256 for asymmetric signing
    headerMap.put("kid", "5425KFDYSC"); // Algorithm, e.g., RS256 for asymmetric signing
    headerMap.put("typ", "JWT"); //
    
    String issuer = "68a6Se82-111e-47e3-e053-5b8c7c11a4d1"; // Replace with your issuer
    //String subject = "subject"; // Replace with your subject
    long nowMillis = System.currentTimeMillis();
    Date issuedAt = new Date(nowMillis);
    Date expiration = new Date(nowMillis + 3600000); // Expiration time (1 hour in this example)


    JwtBuilder jwtBuilder = Jwts.builder()
            .setHeader(headerMap)
            .setIssuer(issuer)
            .setAudience("appstoreconnect-v1")
            .setIssuedAt(issuedAt)
            .signWith(pk)
            .setExpiration(expiration);
    
    // Print the JWT header as a JSON string
    String headerJson = jwtBuilder.compact();

    System.out.println("JWT Header: " + headerJson);

    String apiUrl = "https://api.appstoreconnect.apple.com/v1/apps";

    // Create headers with Authorization
    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + headerJson);
    headers.setContentType(MediaType.APPLICATION_JSON);

    // Create HttpEntity with headers
    HttpEntity<String> entity = new HttpEntity<>(headers);

    // Make GET request using RestTemplate
    RestTemplate restTemplate = new RestTemplate();
    ResponseEntity<String> response = restTemplate.exchange(
            apiUrl,
            HttpMethod.GET,
            entity,
            String.class
    );

    // Handle the response
    if (response.getStatusCode() == HttpStatus.OK) {
        String responseBody = response.getBody();
        System.out.println("Response: " + responseBody);
    } else {
        System.out.println("Error: " + response.getStatusCodeValue());
    }

    // Print the JWT payload as a JSON string
    String payloadJson = jwtBuilder.compact();
    System.out.println("JWT Payload: " + payloadJson);

but I have this error:

Exception in thread "main" org.springframework.web.client.HttpClientErrorException$Unauthorized: 401 Unauthorized: "{<EOL>?"errors": [{<EOL>??"status": "401",<EOL>??"code": "NOT_AUTHORIZED",<EOL>??"title": "Authentication credentials are missing or invalid.",<EOL>??"detail": "Provide a properly configured and signed bearer token, and make sure that it has not expired. Learn more about Generating Tokens for API Requests https://developer.apple.com/go/?id=api-generating-tokens"<EOL>?}]<EOL>}"
at org.springframework.web.client.HttpClientErrorException.create(HttpClientErrorException.java:106)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:183)
at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:137)
at org.springframework.web.client.ResponseErrorHandler.handleError(ResponseErrorHandler.java:63)
at org.springframework.web.client.RestTemplate.handleResponse(RestTemplate.java:932)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:881)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:781)
at org.springframework.web.client.RestTemplate.exchange(RestTemplate.java:663)
at com.mysticriver.service.AppleAPIService.main(AppleAPIService.java:77)

Opening the file with an editor gives me this:

-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg5Fu6zyvQDhgGvevK
pe4OYs32cFSz1oxLd/YCYWJSOPagCgYIKoZIzj0DAQehRANCAATrJf+q7/nieM4y
V9/v71e/Xl/aS+LF4riW5lkcld8lFQB5ekivp5T7w57t6nqp8rCqtq79nEhIyzDr
hCMnmLEk
-----END PRIVATE KEY-----
3

There are 3 answers

1
VonC On

Original question (before edit): 401 Unauthorized: that should mean the authentication token (JWT) you are using is either not properly formatted, not signed correctly, or expired.

From your code, you might be missing the "signing the JWT with your private key" step. The JWT must be signed with your private key for the Apple API to authenticate it.
See "Creating API Keys for App Store Connect API" to create the key.
Then, to sign the JWT with said private key:

PrivateKey privateKey = // Load your private key here
JwtBuilder jwtBuilder = Jwts.builder()
        .setHeader(headerMap)
        .setIssuer(issuer)
        .setAudience("appstoreconnect-v1")
        .setIssuedAt(issuedAt)
        .setExpiration(expiration)
        .signWith(SignatureAlgorithm.RS256, privateKey); // Signing the JWT

See "REST Security With JWT Using Java and Spring Security" from Dejan Milosevic.

Also, check that the JWT structure (header, payload, signature) is correct as per Apple's guidelines.
And, as usual, add some error handling to provide more informative feedback in case of failures.

try {
    ResponseEntity<String> response = restTemplate.exchange(
            apiUrl,
            HttpMethod.GET,
            entity,
            String.class
    );
    // Rest of the code
} catch (HttpClientErrorException ex) {
    System.out.println("HTTP Error: " + ex.getStatusCode());
    System.out.println("Error Body: " + ex.getResponseBodyAsString());
}

Your updated code now includes:

  • Loading the private key from a .p8 file, which is typically used for Apple API authentication. It uses KeyFactory with "RSA" for generating the PrivateKey.
  • Signing the JWT with the loaded private key, a necessary step for authentication with the Apple API.

You now have a InvalidKeySpecException indicating an issue with the key specification or format.

The .p8 file likely contains an EC (Elliptic Curve) private key, not RSA. So the KeyFactory instance should use "EC" instead of "RSA".
Make sure the .p8 file is not corrupted and correctly formatted.

KeyFactory keyFactory = KeyFactory.getInstance("EC"); // Change from "RSA" to "EC"
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);

And, again, add error handling for loading the private key to pinpoint issues.

try {
    PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
    // Rest of the code
} catch (InvalidKeySpecException e) {
    e.printStackTrace(); // More informative error output
    return;
}

For the third edit:

jdk.crypto.ec/sun.security.ec.ECKeyFactory.engineGeneratePrivate(ECKeyFactory.java:168)should indicate a problem with loading the private key from the .p8 file. The stack trace suggests that the issue lies in the PKCS8EncodedKeySpec, which is unable to decode the key.

You have updated the KeyFactory instance to use "EC" instead of "RSA", which is correct for Apple's .p8 private keys. But despite this change, the InvalidKeySpecException persists, indicating the problem is not with the choice of key factory algorithm (RSA or EC), but rather with the format or content of the key file.

The error message "Unable to decode key" and "extra data at the end" suggest that the .p8 file might not be in the correct PKCS#8 format.

So try and make sure the .p8 file is a proper PKCS#8 format private key. It should start with "-----BEGIN PRIVATE KEY-----" and end with "-----END PRIVATE KEY-----". Any additional data or formatting issues can cause the error.

If the .p8 file contains any headers or footers (like "BEGIN PRIVATE KEY"), these need to be removed before being passed to PKCS8EncodedKeySpec.

The key data in the .p8 file is typically Base64 encoded. Make sure it is correctly decoded to its binary form before using it in PKCS8EncodedKeySpec.

String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8)
                        .replaceAll("\\n", "")
                        .replace("-----BEGIN PRIVATE KEY-----", "")
                        .replace("-----END PRIVATE KEY-----", "");
byte[] decodedKey = Base64.getDecoder().decode(keyContent);
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(decodedKey);

With some error handling:

try {
    PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
    // Rest of the code
} catch (InvalidKeySpecException e) {
    e.printStackTrace(); // More detailed error information
    return;
}

If you still have an issue with loading the private key, even after formatting and Base64 decoding the content, that would suggest an issue with the key itself or the way it is being processed.

I will assume your .p8 file does starts with -----BEGIN PRIVATE KEY----- and ends with -----END PRIVATE KEY-----, and that it is complete and not truncated. You have generated a new key from the Apple Developer account, so you can compare with your previous one.

Make sure the Base64 decoding is happening correctly. Incorrect decoding can lead to an invalid key specification.
And make sure the file reading process is not altering the key content in any way. For example, make sure the character encoding used to read the file matches the encoding of the file.

public static PrivateKey loadPrivateKey(Path privateKeyPath) throws IOException, GeneralSecurityException {
    String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8);
    System.out.println("Original Key Content: " + keyContent); // Logging the original content
    keyContent = keyContent.replace("-----BEGIN PRIVATE KEY-----", "")
                           .replace("-----END PRIVATE KEY-----", "")
                           .replaceAll("\\s+", ""); // Remove all whitespaces and newlines, more robust than just replacing \n
    System.out.println("Processed Key Content: " + keyContent); // Logging processed content
    byte[] decodedKey = Base64.getDecoder().decode(keyContent);
    PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decodedKey);
    KeyFactory kf = KeyFactory.getInstance("EC");
    return kf.generatePrivate(spec);
}

This is basically your code, but in a function set to throw exception in case of error, with a replaceAll using \s+ instead of \s+, and with some logging (the System.out.println)

Or, for testing, try a more direct approach to decoding:

String keyContent = new String(Files.readAllBytes(privateKeyPath), StandardCharsets.UTF_8);
keyContent = keyContent.replace("-----BEGIN PRIVATE KEY-----", "")
                        .replace("-----END PRIVATE KEY-----", "")
                        .replaceAll("\\s+", ""); // Remove all whitespaces and newlines
byte[] decodedKey = Base64.getDecoder().decode(keyContent);

In any case, try using a tool like OpenSSL to check the validity of the key. That can help determine if the issue lies with the key itself or the Java code.

openssl pkcs8 -in [path-to-your-key].p8 -nocrypt -topk8

That command should output the key details if the file is correctly formatted. If it fails, the problem likely is with the key file itself.

7
jccampanero On

tl;dr

Try providing an expiration no greater than 20 minutes, let's say, 15, for instance (although the documentation states no greater than I am afraid it should be less than 20):

Date expiration = new Date(nowMillis + 15 * 60 * 1000);

Details

The last version of the code provided in your answer is mostly fine.

I think the problem has to do with the lifetime of the token you are specifying, one hour.

As explained in the Apple Developer documentation when describing the expiration JWT payload field:

The token’s expiration time in Unix epoch time. Tokens that expire more than 20 minutes into the future are not valid except for resources listed in Determine the Appropriate Token Lifetime.

The referenced Determine the Appropriate Token Lifetime documentation states that the App Store Connect accepts a token with a lifetime greater than 20 minutes if:

  • The token defines a scope.
  • The scope only includes GET requests.
  • The resources in the scope allow long-lived tokens.

Your Java code meets the first two conditions but not the third one: the aforementioned documentation lists the resources that can accept long-lived tokens and the List Apps endpoint you are using, in general, the Apps resource, is not included in it.

As indicated, to solve the problem, please, try defining an expiration less than 20 minutes when performing your request. For example:

Date expiration = new Date(nowMillis + 15 * 60 * 1000);

The rest of your code looks fine: please, only, be aware that all the information you provided when generating your JWT token is the right one, and that the key is not revoked and it has been assigned a role authorized to perform the request.

Please, consider for reference this related article, I think it exemplifies very well how to perform the operation.

13
Anish B. On

Easiest solution is to use BountyCastle Library.

This library will takes care of everything when it comes to removal of the unnecessary headers and decoding the Base64 PEM data.

Note: BountyCastle has a good support for Elliptic Curve Cryptography Algorithm parsing.

Also, you try to use com.auth0:java-jwt dependency instead as it provides much more functionalities than io.jsonwebtoken:jjwt dependency.

Add this dependency in the pom.xml :

<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcpkix-jdk18on</artifactId>
    <version>1.76</version>
</dependency>
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>

Change/Update your logic to this:

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;

import java.io.FileReader;
import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Date;
import java.util.concurrent.TimeUnit;

@Transactional(readOnly = true)
public class AppleAPIService {

    public static void main(String[] args) {

        try (PemReader pemReader = new PemReader(
                new FileReader("/Users/ricardolle/IdeaProjects/mystic-planets-api/src/main/resources/cert/AuthKey_5425KFDYSC.p8"))) {

            KeyFactory keyFactory = KeyFactory.getInstance("EC");
            PemObject pemObj = pemReader.readPemObject();
            byte[] content = pemObj.getContent();
            PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(content);
            ECPrivateKey privateKey = (ECPrivateKey) keyFactory.generatePrivate(privateKeySpec);

            String token = JWT.create()
                    .withKeyId("5525KFDYSC")
                    .withIssuer("69a6de82-121e-48e3-e053-5b8c7c11a4d1")
                    .withExpiresAt(new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)))
                    .withClaim("scope", Collections.singletonList("GET /v1/apps"))
                    .withAudience("appstoreconnect-v1")
                    .sign(Algorithm.ECDSA256(privateKey));

            System.out.println("JWT token: " + token);


            // Create headers with Authorization
            HttpHeaders headers = new HttpHeaders();
            headers.setBearerAuth(token);
            headers.setContentType(MediaType.APPLICATION_JSON);

            // Create HttpEntity with headers
            HttpEntity<String> entity = new HttpEntity<>(headers);

            // Make GET request using RestTemplate
            ResponseEntity<String> response = new RestTemplate().exchange(
                    "https://api.appstoreconnect.apple.com/v1/apps",
                    HttpMethod.GET, entity, String.class);

            // Handle the response
            if (response.getStatusCode() == HttpStatus.OK) {
                String responseBody = response.getBody();
                System.out.println("Response: " + responseBody);
            } else {
                System.out.println("Error: " + response.getStatusCodeValue());
            }
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }
    }

}

Please look at this Apple Developer thread forum question to resolve your issue - https://developer.apple.com/forums/thread/707220

If the above thread is still not resolving your issue, then try adding bid (apple bundle id) via withClaims("bid", "put your bundle id")

That's all.