Skip to content

[google-auth-library-java] external_account (AWS) Credentials NPE after Java deserialization: transient HttpTransportFactory not restored on refresh #14050

Description

@sgokul55

Summary

When an external_account (AWS) credential is Java-serialized and then deserialized, a later refresh() throws a NullPointerException because the credential's internal HttpTransportFactory (transportFactory) is declared transient and is not restored on deserialization. There is no readObject (or equivalent) that re-initializes it to the default transport.

The region-lookup / STS path then dereferences the null transport and fails with:

com.google.api.client.googleapis.json.GoogleJsonResponseException / IllegalStateException
... UNAUTHENTICATED: Failed computing credential metadata
Caused by: java.lang.NullPointerException
at ...InternalAwsSecurityCredentialsSupplier... (transportFactory is null)

Why this matters / real-world trigger

Distributed frameworks routinely serialize a Credentials object on one JVM and use it on another e.g. Apache Spark builds credentials on the driver and ships them to executors. The credential often serializes and works initially (or the token is still valid), but the first refresh after deserialization (long-running jobs, past the ~1h token lifetime) NPEs on the null transport. This surfaces as intermittent UNAUTHENTICATED failures that look unrelated to serialization.

GoogleCredentials implements Serializable, so serializing one is a reasonable expectation; the silent loss of the transport is surprising.

Minimal reproduction

import com.google.auth.oauth2.GoogleCredentials;
import java.io.*;

public class Repro {
  public static void main(String[] args) throws Exception {
    // external_account (AWS) config; GOOGLE_APPLICATION_CREDENTIALS points at it,
    // or load via GoogleCredentials.fromStream(new FileInputStream(...)).
    GoogleCredentials cred =
        GoogleCredentials.getApplicationDefault()
            .createScoped("https://www.googleapis.com/auth/cloud-platform");

    // Round-trip through Java serialization (what Spark does driver -> executor).
    byte[] bytes;
    try (var bos = new ByteArrayOutputStream(); var oos = new ObjectOutputStream(bos)) {
      oos.writeObject(cred);
      oos.flush();
      bytes = bos.toByteArray();
    }
    GoogleCredentials restored;
    try (var ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
      restored = (GoogleCredentials) ois.readObject();
    }

    // Fresh instance refreshes fine; the deserialized one NPEs because transportFactory == null.
    restored.refresh();   // <-- NullPointerException here
  }
}

Run on an AWS host (EC2, so IMDS is reachable) with an external_account AWS credential config.

Root cause

The internal AWS security-credentials supplier holds an HttpTransportFactory marked transient.
Nothing re-initializes it after deserialization, so it comes back null, and the AWS region/metadata lookup dereferences it during refresh().

Expected behavior

After deserialization, refresh() should work: the transport should be restored to the default
(OAuth2Utils.HTTP_TRANSPORT_FACTORY) via a readObject/readResolve, matching the pattern other Credentials subclasses use for their transient transport fields.

Workaround (for others hitting this)

Don't serialize a live credential across JVMs. Instead serialize the config bytes and rebuild
GoogleCredentials.fromStream(...) on the target JVM so the transport is freshly initialized. (In Spark we do this via a custom AccessTokenProvider that carries the WIF config JSON and mints tokens per-executor.)

Environment

  • Library: com.google.auth:google-auth-library-oauth2-http
    • Version: 1.43.0
      (as shaded inside com.google.cloud.spark:spark-3.5-bigquery:0.44.1, but the bug is in the
      plain upstream library — reproducible against google-auth-library-oauth2-http:1.43.0 directly,
      and the transient field is still present on main)
    • Java: 17
    • Credential type: Workload Identity Federation, external_account with AWS source
      (credential_source.environment_id = "aws1", region/URL from EC2 IMDS)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions