When your application wants to access a Google service, it first needs to obtain authorization with OAuth 2.0. OAuth 2.0 is often used so that a user grants permission to an application, and the application then accesses a service on behalf of this user. However, you can also use OAuth on the server-side when a server application needs to access a service without any user interaction. In this scenario, only two parties are involved: your server application and the Google service. Therefore, you often see and hear the term "two-legged OAuth" for this kind of authorization flow. The related term "three-legged OAuth" refers to the scenario in which your application calls a Google service on behalf of a user and in which the user has to grant permission to the application.
In this blog post, we take a closer look at how the server-to-server OAuth 2.0 flow works with a Java application that accesses the Cloud Translation API. For production systems, Google recommends using Cloud Client Libraries or the Google Auth Library for Java instead of building the JWT exchange yourself. Still, it is useful to understand how the flow works under the hood, so this post shows both a manual HTTP implementation and the modern Java client library.
The authorization flow only requires one POST HTTP request to the OAuth endpoint. The body of the request carries a JSON Web Token (JWT).
When the request is valid, the Google server sends back an access token that is valid for one hour.
The application must then send this access token with each request in the Authorization HTTP header to a Google service. After one hour, when the token expires, the application has to request a new one. This request is built the same way as the initial token request. There is no special token refresh workflow.
This workflow is comparable to a user login request with a username and password that sends back a session cookie. The browser sends the cookie with each later request to the server. Both the session cookie and the access token are sent to the server in the HTTP header.
Service Account ¶
Before we start coding, we need a service account. This is an account that belongs to your application instead of an individual user. The application calls the Google service on behalf of this service account.
Create a new Google Cloud project or select an existing one, then enable the Cloud Translation API for that project. Pricing, quotas, and billing rules change over time, so check the current pricing page before you use the API.
Next, open the Service Accounts page, create a service account, and grant it the IAM permissions it needs to call Cloud Translation in your project.
If your application runs on Google Cloud, the best option is usually to attach a service account to the workload and let Application Default Credentials handle authentication. If your application runs outside Google Cloud, consider Workload Identity Federation before you create a long-lived key.
For this standalone sample, we use a JSON service account key file because it contains everything we need to demonstrate the JWT flow. Open the service account, switch to the Keys tab, and create a JSON key. Store this file in a safe location and never commit it to a public repository. Anyone with the private key can request access tokens for that service account.
Depending on your organization policies, key creation might be blocked by default. Google now recommends restricting service account key creation wherever possible and using shorter-lived credentials in production.
Java ¶
With the Google Cloud project and service account ready, we can write the Java application. I use Maven as the build system and add these dependencies to the project.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<version>26.78.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.0.4</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.5.1</version>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-translate</artifactId>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.43.0</version>
</dependency>
</dependencies>
The sample uses the JDK's HttpClient for the raw HTTP calls, java-jwt for JWT creation, Jackson 3 for JSON serialization and deserialization, and the official Cloud Translation client library for the higher-level example.
First, we read the service account JSON file. For that, I created a POJO, Credentials.java, and use a Jackson ObjectMapper to deserialize the file into that class.
Path credentialPath = resolveCredentialPath(args);
ObjectMapper om = new ObjectMapper();
Credentials credentials = om.readValue(Files.readAllBytes(credentialPath),
Credentials.class);
The service account file contains the fields we need for the sample:
project_idfor the Cloud Translation API request pathprivate_keyfor signing the JWTprivate_key_idfor the optionalkidJWT headerclient_emailfor the iss (issuer) field in the JWTtoken_urifor the OAuth 2.0 token endpoint
Next, we extract the private key string and convert it into a RSAPrivateKey instance.
The private key string from the service account JSON file is stored in PKCS #8 format. To use it in the application, we remove the begin and end markers, strip newlines, decode the Base64 data, and ask an RSA KeyFactory to build the private key instance.
String privateKey = credentials.getPrivateKey().replace("\n", "")
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "");
byte[] decoded = Base64.getDecoder().decode(privateKey);
KeyFactory kf = KeyFactory.getInstance("RSA");
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded);
RSAPrivateKey privKey = (RSAPrivateKey) kf.generatePrivate(keySpec);
Now we create the JWT. Google requires the following claims in the JWT claim set. The order does not matter.
- iss (issuer): The email address of the service account. Here we fill in the
client_emailvalue from the service account file. - scope: A space-delimited list of the permissions the application requests. See the OAuth 2.0 scopes list for the available scopes.
- aud (audience): A descriptor of the intended target of the assertion. When making an access token request, this value is always the
token_urifrom the service account file. - exp (expiration): The expiration time of the assertion. This value has a maximum of 1 hour after the issued time.
- iat (issued at): The time the assertion was issued
Instant now = Instant.now();
Algorithm algorithm = Algorithm.RSA256(null, privKey);
Builder jwtBuilder = JWT.create()
.withIssuer(credentials.getClientEmail())
.withAudience(credentials.getTokenUri())
.withClaim("scope", "https://www.googleapis.com/auth/cloud-translation")
.withExpiresAt(now.plus(1, ChronoUnit.HOURS))
.withIssuedAt(now);
if (credentials.getPrivateKeyId() != null && !credentials.getPrivateKeyId().isBlank()) {
jwtBuilder.withKeyId(credentials.getPrivateKeyId());
}
String compactedJWT = jwtBuilder.sign(algorithm);
The sample also adds the kid header when the service account JSON file provides a private_key_id. After setting the claims, the JWT is signed with RSA SHA-256 and the private key we created in the previous step.
The JWT is a string that looks like this:
eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiMmMzZDQifQ.eyJpc3MiOiJteS1zZXJ2aWNlLWFjY291bnRAbXktcHJvamVjdC5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsImF1ZCI6Imh0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwic2NvcGUiOiJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9hdXRoL2Nsb3VkLXRyYW5zbGF0aW9uIiwiZXhwIjoxNzExMDQwMDAwLCJpYXQiOjE3MTEwMzY0MDB9.signature
A JWT consists of three parts separated by a dot: Header, payload, and signature. A JWT is not encrypted, so you can paste it into a service like jwt.io and inspect the header and payload. The important part is the signature. The JWT is signed with the private key from the service account JSON file, and Google verifies that signature with the corresponding public key.
Header:
{
"alg": "RS256",
"kid": "1b2c3d4"
}
Payload:
{
"iss": "my-service-account@my-project.iam.gserviceaccount.com",
"aud": "https://oauth2.googleapis.com/token",
"scope": "https://www.googleapis.com/auth/cloud-translation",
"exp": 1711040000,
"iat": 1711036400
}
After generating the JWT, the application sends it to the URL from the token_uri field to request the access token.
This is an HTTP POST request over TLS. The request body is URL encoded and contains these two required parameters:
- grant_type: Always the URL-encoded string
urn:ietf:params:oauth:grant-type:jwt-bearer - assertion: The JWT
String formBody = "grant_type="
+ urlEncode("urn:ietf:params:oauth:grant-type:jwt-bearer") + "&assertion="
+ urlEncode(compactedJWT);
HttpRequest request = HttpRequest.newBuilder(URI.create(credentials.getTokenUri()))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(formBody)).build();
byte[] response = sendRequest(client, request);
TokenResponse tokenResponse = om.readValue(response, TokenResponse.class);
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjFiMmMzZDQifQ...
Google returns a response like this if the JWT is valid:
{
"access_token": "ya29.a0AfH6SMB...",
"expires_in": 3600,
"token_type": "Bearer"
}
I use a POJO, TokenResponse.java, and the Jackson ObjectMapper to deserialize the response.
byte[] response = sendRequest(client, request);
TokenResponse tokenResponse = om.readValue(response, TokenResponse.class);
System.out.println(tokenResponse);
We are only interested in the access_token field. Our application sends this token in the Authorization HTTP header in each request to Cloud Translation.
The token itself needs to be prepended with the word Bearer .
// Translation Request
String translationUrl = "https://translate.googleapis.com/v3/projects/"
+ credentials.getProjectId() + "/locations/global:translateText";
Map<String, Object> translationRequest = Map.of("contents", List.of("Hello world"),
"mimeType", "text/plain", "sourceLanguageCode", "en",
"targetLanguageCode", "fr");
request = HttpRequest.newBuilder(URI.create(translationUrl))
.header("Authorization", "Bearer " + tokenResponse.getAccessToken())
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers
.ofByteArray(om.writeValueAsBytes(translationRequest)))
.build();
response = sendRequest(client, request);
JsonNode obj = om.readValue(response, JsonNode.class);
JsonNode translations = obj.path("translations");
String translatedText = translations.isArray() && !translations.isEmpty()
? translations.get(0).path("translatedText").asString() : null;
System.out.println(translatedText);
For the current Cloud Translation Advanced API, the REST request goes to the v3 translateText endpoint and the project appears in the path.
POST https://translate.googleapis.com/v3/projects/my-project/locations/global:translateText
Content-Type: application/json; charset=utf-8
Authorization: Bearer ya29.a0AfH6SMB...
{"contents":["Hello world"],"mimeType":"text/plain","sourceLanguageCode":"en","targetLanguageCode":"fr"}
The response contains a translations array, and the sample reads the first translatedText value from it with Jackson 3's JsonNode.asString() accessor.
As mentioned before, the access token is only valid for one hour. After the token expires, you have to obtain a new token with the same workflow shown above. There is no special refresh token flow.
If you try to access a service with an expired token, the API returns a 401 Unauthorized response with a JSON body similar to this:
{
"error": {
"code": 401,
"message": "Request had invalid authentication credentials.",
"errors": [{
"message": "Request had invalid authentication credentials.",
"reason": "authError"
}],
"status": "UNAUTHENTICATED"
}
}
Client Library ¶
As mentioned at the beginning of this post, you do not have to write all of this code yourself. Google provides Cloud Client Libraries that handle authentication and API calls for you.
A client library has many advantages over a self-built solution. The library...
- tracks current service APIs because Google keeps the library up to date
- automatically handles the OAuth 2.0 flow and fetches the access token
- automatically refreshes the token when it expires
- provides typed request and response objects
- integrates naturally with Application Default Credentials
For Cloud Translation, we only need the google-cloud-translate library.
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-translate</artifactId>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.43.0</version>
</dependency>
To get the same result as with our self-built solution, we only need a few lines of code.
The sample accepts an optional path to the service account JSON file as the first argument. If you omit it, the code falls back to Application Default Credentials. The project ID comes from the second argument, the GOOGLE_CLOUD_PROJECT environment variable, or the service account file itself.
public static void main(String[] args) throws IOException {
GoogleCredentials credentials = loadCredentials(args)
.createScoped(List.of("https://www.googleapis.com/auth/cloud-translation"));
String projectId = resolveProjectId(args, credentials);
TranslationServiceSettings settings = TranslationServiceSettings.newBuilder()
.setCredentialsProvider(FixedCredentialsProvider.create(credentials)).build();
TranslateTextRequest request = TranslateTextRequest.newBuilder()
.setParent("projects/" + projectId + "/locations/global")
.addContents("Hello world")
.setMimeType("text/plain")
.setSourceLanguageCode("en")
.setTargetLanguageCode("fr")
.build();
try (TranslationServiceClient client = TranslationServiceClient.create(settings)) {
TranslateTextResponse response = client.translateText(request);
Translation translation = response.getTranslationsList().stream().findFirst()
.orElseThrow(() -> new IllegalStateException("No translation returned"));
System.out.println(translation.getTranslatedText());
}
You can find the code presented in this blog post on GitHub: https://github.com/ralscha/blog/tree/master/twolegged