// EnfonicaCredentials.java — Enfonica service account authentication (reference implementation) // // Exchanges an Enfonica service account key for an OAuth2 bearer access token // using the JWT Bearer Grant (RFC 7523), and caches the token in memory. // Copy this single file into your project and change the package to suit. // // Requires: Java 11+ and Jackson for JSON (com.fasterxml.jackson.core:jackson-databind). // HTTP, RSA and SHA-256 use the JDK only. // // Creating credentials (pick one): // // EnfonicaCredentials creds = EnfonicaCredentials.fromEnv(); // path in ENFONICA_APPLICATION_CREDENTIALS // EnfonicaCredentials creds = EnfonicaCredentials.fromServiceAccountFile(Path.of("key.json")); // EnfonicaCredentials creds = EnfonicaCredentials.fromServiceAccountInfo(keyJson); // e.g. from a secret manager // // Using them on each API request: // // requestBuilder.header("Authorization", creds.getAuthorizationHeader()); // "Bearer " // String token = creds.getAccessToken(); // just the token // // Notes: // - Create ONE instance and reuse it for the life of your application (e.g. // a singleton bean). Tokens are cached in memory and refreshed // automatically after 30 minutes (the server issues 60-minute tokens), so // a new instance per request means a token request per API call. // - Instances are thread-safe. // - A 401 from the API is a real error (bad key, revoked key, missing // permission). Do not retry it by fetching a new token. // - Tokens are never written to disk. package com.enfonica.auth; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.Signature; import java.security.spec.PKCS8EncodedKeySpec; import java.time.Duration; import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** Service account credentials that produce cached Enfonica access tokens. */ public final class EnfonicaCredentials { /** Thrown when a key file is missing or invalid, or a token request fails. */ public static final class EnfonicaCredentialsException extends RuntimeException { private static final long serialVersionUID = 1L; public EnfonicaCredentialsException(String message) { super(message); } public EnfonicaCredentialsException(String message, Throwable cause) { super(message, cause); } } private static final String SCOPE = "https://api.enfonica.com/auth/platform"; private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; private static final String ENV_VAR = "ENFONICA_APPLICATION_CREDENTIALS"; private static final long JWT_LIFETIME_SECONDS = 3600; private static final long TOKEN_REFRESH_NANOS = Duration.ofMinutes(30).toNanos(); private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30); private static final String[] REQUIRED_FIELDS = {"type", "project_id", "private_key_id", "private_key", "client_email", "token_uri"}; private static final ObjectMapper MAPPER = new ObjectMapper(); private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); private final String projectId; private final String clientEmail; private final String tokenUri; private final String privateKeyId; private final PrivateKey privateKey; private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(HTTP_TIMEOUT).build(); private String token; private long fetchedAtNanos; /** Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable. */ public static EnfonicaCredentials fromEnv() { String path = System.getenv(ENV_VAR); if (path == null || path.isEmpty()) { throw new EnfonicaCredentialsException(ENV_VAR + " is not set. Set it to the path of a service " + "account key file, or use fromServiceAccountFile() / fromServiceAccountInfo()."); } return fromServiceAccountFile(Path.of(path)); } /** Load a service account key file from disk. */ public static EnfonicaCredentials fromServiceAccountFile(Path path) { try { return fromServiceAccountInfo(Files.readString(path)); } catch (IOException e) { throw new EnfonicaCredentialsException("Could not read key file '" + path + "': " + e.getMessage(), e); } } /** Load a service account key from its JSON contents. */ public static EnfonicaCredentials fromServiceAccountInfo(String json) { JsonNode node; try { node = MAPPER.readTree(json); } catch (JsonProcessingException e) { throw new EnfonicaCredentialsException("Invalid key file: not valid JSON: " + e.getOriginalMessage(), e); } if (node == null || !node.isObject()) { throw new EnfonicaCredentialsException("Invalid key file: expected a JSON object"); } if (!"service_account".equals(node.path("type").asText(""))) { throw new EnfonicaCredentialsException( "Invalid key file: expected type 'service_account', got '" + node.path("type").asText("") + "'"); } List missing = new ArrayList<>(); for (String field : REQUIRED_FIELDS) { if (!node.path(field).isTextual() || node.path(field).asText().isEmpty()) { missing.add(field); } } if (!missing.isEmpty()) { throw new EnfonicaCredentialsException("Invalid key file: missing field(s): " + String.join(", ", missing)); } return new EnfonicaCredentials( node.get("project_id").asText(), node.get("client_email").asText(), node.get("token_uri").asText(), node.get("private_key_id").asText(), loadPrivateKey(node.get("private_key").asText())); } private EnfonicaCredentials(String projectId, String clientEmail, String tokenUri, String privateKeyId, PrivateKey privateKey) { this.projectId = projectId; this.clientEmail = clientEmail; this.tokenUri = tokenUri; this.privateKeyId = privateKeyId; this.privateKey = privateKey; } public String getProjectId() { return projectId; } public String getClientEmail() { return clientEmail; } /** Always read from the key file, never hardcoded. */ public String getTokenUri() { return tokenUri; } /** Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old. */ public synchronized String getAccessToken() { if (token == null || System.nanoTime() - fetchedAtNanos >= TOKEN_REFRESH_NANOS) { token = fetchToken(); fetchedAtNanos = System.nanoTime(); } return token; } /** Return the value for the HTTP Authorization header: "Bearer <token>". */ public String getAuthorizationHeader() { return "Bearer " + getAccessToken(); } private String fetchToken() { String form = "grant_type=" + URLEncoder.encode(GRANT_TYPE, StandardCharsets.UTF_8) + "&assertion=" + URLEncoder.encode(signedAssertion(), StandardCharsets.UTF_8); HttpRequest request = HttpRequest.newBuilder(URI.create(tokenUri)) .timeout(HTTP_TIMEOUT) .header("Content-Type", "application/x-www-form-urlencoded") .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(form)) .build(); HttpResponse response; try { response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException e) { throw new EnfonicaCredentialsException("Token request to " + tokenUri + " failed: " + e.getMessage(), e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new EnfonicaCredentialsException("Token request to " + tokenUri + " was interrupted", e); } if (response.statusCode() < 200 || response.statusCode() > 299) { throw new EnfonicaCredentialsException("Token request failed: HTTP " + response.statusCode() + " from " + tokenUri + ": " + response.body()); } try { JsonNode accessToken = MAPPER.readTree(response.body()).path("access_token"); if (accessToken.isTextual() && !accessToken.asText().isEmpty()) { return accessToken.asText(); } } catch (JsonProcessingException e) { // Fall through to the malformed-response error below. } throw new EnfonicaCredentialsException("Token response was malformed: " + response.body()); } /** Build the RS256-signed JWT assertion (RFC 7523 §3). */ private String signedAssertion() { long now = System.currentTimeMillis() / 1000; Map header = new LinkedHashMap<>(); header.put("alg", "RS256"); header.put("typ", "JWT"); header.put("kid", privateKeyId); Map payload = new LinkedHashMap<>(); payload.put("iss", clientEmail); payload.put("aud", tokenUri); payload.put("scope", SCOPE); payload.put("iat", now); payload.put("exp", now + JWT_LIFETIME_SECONDS); try { String signingInput = B64URL.encodeToString(MAPPER.writeValueAsBytes(header)) + "." + B64URL.encodeToString(MAPPER.writeValueAsBytes(payload)); Signature signer = Signature.getInstance("SHA256withRSA"); signer.initSign(privateKey); signer.update(signingInput.getBytes(StandardCharsets.US_ASCII)); return signingInput + "." + B64URL.encodeToString(signer.sign()); } catch (JsonProcessingException | GeneralSecurityException e) { throw new EnfonicaCredentialsException("Could not sign JWT assertion: " + e.getMessage(), e); } } // Decode the PEM body directly so keys wrapped at any line width (or not // at all) load correctly. private static PrivateKey loadPrivateKey(String pem) { String body = pem.replaceAll("-----(BEGIN|END) [A-Z ]+-----|\\s+", ""); try { byte[] der = Base64.getDecoder().decode(body); return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der)); } catch (IllegalArgumentException | GeneralSecurityException e) { throw new EnfonicaCredentialsException( "Invalid key file: private_key is not a valid PKCS#8 RSA key: " + e.getMessage(), e); } } }