Repository: GoogleCloudPlatform/pgadapter
Pull request #360 (2022-09-18 14:58:31)

feat: support OAuth2 token authentication
Add support for authentication with simple OAuth2 tokens.

codecov[bot]: # [Codecov](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/360?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) Report
> Merging [#360](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/360?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (96fa84a) into [postgresql-dialect](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/commit/9c601733a0488e6708ce3a44b87dff761ce99d3d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) (9c60173) will **decrease** coverage by `0.01%`.
> The diff coverage is `66.66%`.

```diff
@@                   Coverage Diff                    @@
##             postgresql-dialect     #360      +/-   ##
========================================================
- Coverage                 85.87%   85.86%   -0.02%     
- Complexity                 1751     1752       +1     
========================================================
  Files                       119      119              
  Lines                      5827     5829       +2     
  Branches                    794      795       +1     
========================================================
+ Hits                       5004     5005       +1     
  Misses                      598      598              
- Partials                    225      226       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| all_tests | `85.86% <66.66%> (-0.02%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/360?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform) | Coverage Δ | |
|---|---|---|
| [...ud/spanner/connection/ConnectionOptionsHelper.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/360/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvY29ubmVjdGlvbi9Db25uZWN0aW9uT3B0aW9uc0hlbHBlci5qYXZh) | `66.66% <ø> (ø)` | |
| [...gle/cloud/spanner/pgadapter/ConnectionHandler.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/360/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL0Nvbm5lY3Rpb25IYW5kbGVyLmphdmE=) | `79.07% <ø> (ø)` | |
| [...spanner/pgadapter/wireprotocol/StartupMessage.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/360/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3dpcmVwcm90b2NvbC9TdGFydHVwTWVzc2FnZS5qYXZh) | `87.75% <ø> (ø)` | |
| [...panner/pgadapter/wireprotocol/PasswordMessage.java](https://codecov.io/gh/GoogleCloudPlatform/pgadapter/pull/360/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform#diff-c3JjL21haW4vamF2YS9jb20vZ29vZ2xlL2Nsb3VkL3NwYW5uZXIvcGdhZGFwdGVyL3dpcmVwcm90b2NvbC9QYXNzd29yZE1lc3NhZ2UuamF2YQ==) | `58.00% <66.66%> (-0.34%)` | :arrow_down: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=GoogleCloudPlatform)


Patch:
diff --git a/clirr-ignored-differences.xml b/clirr-ignored-differences.xml
index a940dda7..a5d8d47d 100644
--- a/clirr-ignored-differences.xml
+++ b/clirr-ignored-differences.xml
@@ -55,6 +55,18 @@
     <className>com/google/cloud/spanner/pgadapter/wireprotocol/AbstractQueryProtocolMessage</className>
     <method>java.lang.String getSql()</method>
   </difference>
+  <difference>
+    <differenceType>7005</differenceType>
+    <className>com/google/cloud/spanner/connection/ConnectionOptionsHelper</className>
+    <method>com.google.cloud.spanner.connection.ConnectionOptions$Builder setCredentials(com.google.cloud.spanner.connection.ConnectionOptions$Builder, com.google.auth.oauth2.GoogleCredentials)</method>
+    <to>com.google.cloud.spanner.connection.ConnectionOptions$Builder setCredentials(com.google.cloud.spanner.connection.ConnectionOptions$Builder, com.google.auth.Credentials)</to>
+  </difference>
+  <difference>
+    <differenceType>7005</differenceType>
+    <className>com/google/cloud/spanner/pgadapter/ConnectionHandler</className>
+    <method>void connectToSpanner(java.lang.String, com.google.auth.oauth2.GoogleCredentials)</method>
+    <to>void connectToSpanner(java.lang.String, com.google.auth.Credentials)</to>
+  </difference>
 
   <!-- Add ignores for all sub packages, as these are considered internal. -->
   <difference>
diff --git a/docs/authentication.md b/docs/authentication.md
index aa431f13..7b1ede2e 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -33,9 +33,10 @@ java -jar pgadapter.jar -p my-project -i my-instance
 PGAdapter will require all connections to supply credentials for each connection if it is started
 with the `-a` command line argument. The credentials are sent as a password message. The password
 message must contain one of the following:
-1. The password field can contain the JSON payload of a credentials file, for example the contents
+1. The password field can contain a valid OAuth2 token. The username must be `oauth2`.
+2. The password field can contain the JSON payload of a credentials file, for example the contents
    of a service account key file. The username will be ignored in this case.
-2. The username field can contain the email address of a service account and the password field can
+3. The username field can contain the email address of a service account and the password field can
    contain a private key for that service account. Note: The password must include the
    `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` header and footer.
 
@@ -45,6 +46,12 @@ message must contain one of the following:
 # The -a argument instructs PGAdapter to require authentication.
 java -jar pgadapter.jar -p my-project -i my-instance -a
 
+# Set the username to 'oauth2' and the password to a valid OAuth2 token.
+# Note that PGAdapter will not be able to refresh the OAuth2 token, which means that the connection
+# will expire when the token has expired.
+PGPASSWORD=$(gcloud auth application-default print-access-token --quiet) \
+   psql -U oauth2 -h /tmp -d my-database
+
 # Set the PG password to the contents of a credentials file. This can be both a service account or a
 # user account file. The username will be ignored by PGAdapter.
 PGPASSWORD=$(cat /path/to/credentials.json) psql -h /tmp -d my-database
@@ -73,3 +80,13 @@ PGPASSWORD=$(cat /path/to/credentials.json) \
   psql -h /tmp -d "projects/my-project/instances/my-instance/databases/my-database"
 ```
 
+`psql` will by default show the name of the connected database on the prompt. You can shorten this
+by changing the default prompt of `psql` to for example show `'~'` when connected to the default
+database.
+
+```shell
+PGPASSWORD=$(gcloud auth application-default print-access-token --quiet) \
+PGDATABASE=projects/my-project/instances/my-instance/databases/my-database \
+   psql -U oauth2 -h /tmp \
+   -v "PROMPT1=%~%R%x%#" -v "PROMPT2=%~%R%x%#"
+```
diff --git a/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java b/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java
index 8441153a..3acbbce0 100644
--- a/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java
+++ b/src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java
@@ -15,7 +15,7 @@
 package com.google.cloud.spanner.connection;
 
 import com.google.api.core.InternalApi;
-import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.Credentials;
 import com.google.cloud.spanner.Spanner;
 import com.google.cloud.spanner.connection.ConnectionOptions.Builder;
 
@@ -23,8 +23,7 @@ import com.google.cloud.spanner.connection.ConnectionOptions.Builder;
 @InternalApi
 public class ConnectionOptionsHelper {
   // TODO: Remove when Builder.setCredentials(..) has been made public.
-  public static Builder setCredentials(
-      Builder connectionOptionsBuilder, GoogleCredentials credentials) {
+  public static Builder setCredentials(Builder connectionOptionsBuilder, Credentials credentials) {
     return connectionOptionsBuilder.setCredentials(credentials);
   }
 
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
index 2f50583b..c36dc442 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
@@ -15,7 +15,7 @@
 package com.google.cloud.spanner.pgadapter;
 
 import com.google.api.core.InternalApi;
-import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.Credentials;
 import com.google.cloud.spanner.Database;
 import com.google.cloud.spanner.DatabaseAdminClient;
 import com.google.cloud.spanner.DatabaseId;
@@ -128,7 +128,7 @@ public class ConnectionHandler extends Thread {
   }
 
   @InternalApi
-  public void connectToSpanner(String database, @Nullable GoogleCredentials credentials) {
+  public void connectToSpanner(String database, @Nullable Credentials credentials) {
     OptionsMetadata options = getServer().getOptions();
     String uri =
         options.hasDefaultConnectionUrl()
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/error/PGException.java b/src/main/java/com/google/cloud/spanner/pgadapter/error/PGException.java
index 467ff0b1..aef650f6 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/error/PGException.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/error/PGException.java
@@ -27,6 +27,7 @@ public class PGException extends RuntimeException {
     private Severity severity;
     private SQLState sqlState;
     private String message;
+    private String hints;
 
     private Builder() {}
 
@@ -45,8 +46,13 @@ public class PGException extends RuntimeException {
       return this;
     }
 
+    public Builder setHints(String hints) {
+      this.hints = Preconditions.checkNotNull(hints);
+      return this;
+    }
+
     public PGException build() {
-      return new PGException(severity, sqlState, message);
+      return new PGException(severity, sqlState, message, hints);
     }
   }
 
@@ -56,11 +62,13 @@ public class PGException extends RuntimeException {
 
   private final Severity severity;
   private final SQLState sqlState;
+  private final String hints;
 
-  private PGException(Severity severity, SQLState sqlState, String message) {
+  private PGException(Severity severity, SQLState sqlState, String message, String hints) {
     super(Preconditions.checkNotNull(message));
     this.severity = severity;
     this.sqlState = sqlState;
+    this.hints = hints;
   }
 
   public Severity getSeverity() {
@@ -70,4 +78,8 @@ public class PGException extends RuntimeException {
   public SQLState getSQLState() {
     return sqlState;
   }
+
+  public String getHints() {
+    return hints;
+  }
 }
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponse.java b/src/main/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponse.java
index abf45d23..e37964fd 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponse.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponse.java
@@ -16,6 +16,7 @@ package com.google.cloud.spanner.pgadapter.wireoutput;
 
 import com.google.api.core.InternalApi;
 import com.google.cloud.spanner.pgadapter.error.PGException;
+import com.google.common.base.Strings;
 import java.io.DataOutputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
@@ -32,15 +33,27 @@ public class ErrorResponse extends WireOutput {
   private static final byte CODE_FLAG = 'C';
   private static final byte MESSAGE_FLAG = 'M';
   private static final byte SEVERITY_FLAG = 'S';
+  private static final byte HINT_FLAG = 'H';
   private static final byte NULL_TERMINATOR = 0;
 
   private final byte[] severity;
   private final byte[] errorMessage;
   private final byte[] errorState;
+  private final byte[] hints;
 
   public ErrorResponse(DataOutputStream output, PGException pgException) {
-    super(
-        output,
+    super(output, calculateLength(pgException));
+    this.errorMessage = pgException.getMessage().getBytes(StandardCharsets.UTF_8);
+    this.errorState = pgException.getSQLState().getBytes();
+    this.severity = pgException.getSeverity().name().getBytes(StandardCharsets.UTF_8);
+    this.hints =
+        Strings.isNullOrEmpty(pgException.getHints())
+            ? null
+            : pgException.getHints().getBytes(StandardCharsets.UTF_8);
+  }
+
+  static int calculateLength(PGException pgException) {
+    int length =
         HEADER_LENGTH
             + FIELD_IDENTIFIER_LENGTH
             + pgException.getSeverity().name().getBytes(StandardCharsets.UTF_8).length
@@ -51,10 +64,14 @@ public class ErrorResponse extends WireOutput {
             + FIELD_IDENTIFIER_LENGTH
             + pgException.getMessage().getBytes(StandardCharsets.UTF_8).length
             + NULL_TERMINATOR_LENGTH
-            + NULL_TERMINATOR_LENGTH);
-    this.errorMessage = pgException.getMessage().getBytes(StandardCharsets.UTF_8);
-    this.errorState = pgException.getSQLState().getBytes();
-    this.severity = pgException.getSeverity().name().getBytes(StandardCharsets.UTF_8);
+            + NULL_TERMINATOR_LENGTH;
+    if (!Strings.isNullOrEmpty(pgException.getHints())) {
+      length +=
+          FIELD_IDENTIFIER_LENGTH
+              + pgException.getHints().getBytes(StandardCharsets.UTF_8).length
+              + NULL_TERMINATOR_LENGTH;
+    }
+    return length;
   }
 
   @Override
@@ -68,6 +85,11 @@ public class ErrorResponse extends WireOutput {
     this.outputStream.writeByte(MESSAGE_FLAG);
     this.outputStream.write(this.errorMessage);
     this.outputStream.writeByte(NULL_TERMINATOR);
+    if (this.hints != null) {
+      this.outputStream.writeByte(HINT_FLAG);
+      this.outputStream.write(this.hints);
+      this.outputStream.writeByte(NULL_TERMINATOR);
+    }
     this.outputStream.writeByte(NULL_TERMINATOR);
   }
 
@@ -83,7 +105,12 @@ public class ErrorResponse extends WireOutput {
 
   @Override
   protected String getPayloadString() {
-    return new MessageFormat("Length: {0}, " + "Error Message: {1}")
-        .format(new Object[] {this.length, new String(this.errorMessage, UTF8)});
+    return new MessageFormat("Length: {0}, Error Message: {1}, Hints: {2}")
+        .format(
+            new Object[] {
+              this.length,
+              new String(this.errorMessage, UTF8),
+              this.hints == null ? "" : new String(this.hints, UTF8)
+            });
   }
 }
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/PasswordMessage.java b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/PasswordMessage.java
index 3753a524..12419775 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/PasswordMessage.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/PasswordMessage.java
@@ -21,7 +21,10 @@ import com.google.api.client.util.PemReader;
 import com.google.api.client.util.PemReader.Section;
 import com.google.api.client.util.Strings;
 import com.google.api.core.InternalApi;
+import com.google.auth.Credentials;
+import com.google.auth.oauth2.AccessToken;
 import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.oauth2.OAuth2Credentials;
 import com.google.auth.oauth2.ServiceAccountCredentials;
 import com.google.cloud.spanner.pgadapter.ConnectionHandler;
 import com.google.cloud.spanner.pgadapter.error.PGException;
@@ -36,8 +39,15 @@ import java.util.Map;
 import org.postgresql.util.ReaderInputStream;
 
 /**
- * A Password Message takes a username and password and input and supposedly handles auth. Here,
- * however, since connections are through localhost, we do not do so.
+ * PGAdapter will convert a password message into gRPC authentication in the following ways:
+ *
+ * <ol>
+ *   <li>If the username is 'oauth2' the password will be interpreted as an OAuth2 token.
+ *   <li>If the username is an email address and the password contains private key section,
+ *       PGAdapter will construct a service account from the email address and private key.
+ *   <li>Otherwise, PGAdapter will try to construct a Google credentials instance from the string in
+ *       the password message. The username will be ignored.
+ * </ol>
  */
 @InternalApi
 public class PasswordMessage extends ControlMessage {
@@ -71,16 +81,17 @@ public class PasswordMessage extends ControlMessage {
       return;
     }
 
-    GoogleCredentials credentials = checkCredentials(this.username, this.password);
+    Credentials credentials = checkCredentials(this.username, this.password);
     if (credentials == null) {
       new ErrorResponse(
               this.outputStream,
               PGException.newBuilder()
-                  .setMessage(
-                      "Invalid credentials received. "
-                          + "PGAdapter expects the password to contain the JSON payload of a credentials file. "
-                          + "Alternatively, the password may contain only the private key of a service account. "
-                          + "The user name must in that case contain the service account email address.")
+                  .setMessage("Invalid credentials received.")
+                  .setHints(
+                      "PGAdapter expects credentials to be one of the following:\n"
+                          + "1. Username contains the fixed string 'oauth2' and the password field contains a valid OAuth2 token.\n"
+                          + "2. Username contains any string and the password field contains the JSON payload of a credentials file (e.g. a service account file).\n"
+                          + "3. Username contains the email address of a service account and the password contains the corresponding private key for the service account.")
                   .setSQLState(SQLState.InvalidPassword)
                   .setSeverity(Severity.ERROR)
                   .build())
@@ -96,7 +107,7 @@ public class PasswordMessage extends ControlMessage {
     return this.connection.getServer().getOptions().shouldAuthenticate();
   }
 
-  private GoogleCredentials checkCredentials(String username, String password) {
+  private Credentials checkCredentials(String username, String password) {
     if (Strings.isNullOrEmpty(password)) {
       return null;
     }
@@ -126,6 +137,11 @@ public class PasswordMessage extends ControlMessage {
       }
     }
 
+    if (!Strings.isNullOrEmpty(username) && username.equalsIgnoreCase("oauth2")) {
+      // Interpret the password as an OAuth2 token.
+      return OAuth2Credentials.create(new AccessToken(password, null));
+    }
+
     // Try to parse the password field as a JSON string that contains a credentials object.
     try {
       return GoogleCredentials.fromStream(new ReaderInputStream(new StringReader(password)));
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/StartupMessage.java b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/StartupMessage.java
index c4a7235a..56d3744b 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/StartupMessage.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/StartupMessage.java
@@ -15,7 +15,7 @@
 package com.google.cloud.spanner.pgadapter.wireprotocol;
 
 import com.google.api.core.InternalApi;
-import com.google.auth.oauth2.GoogleCredentials;
+import com.google.auth.Credentials;
 import com.google.cloud.spanner.pgadapter.ConnectionHandler;
 import com.google.cloud.spanner.pgadapter.ConnectionHandler.ConnectionStatus;
 import com.google.cloud.spanner.pgadapter.utils.ClientAutoDetector;
@@ -79,7 +79,7 @@ public class StartupMessage extends BootstrapMessage {
       ConnectionHandler connection,
       String database,
       Map<String, String> parameters,
-      @Nullable GoogleCredentials credentials)
+      @Nullable Credentials credentials)
       throws Exception {
     connection.connectToSpanner(database, credentials);
     for (Entry<String, String> parameter : parameters.entrySet()) {


Test patch:
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java
index 1d741e92..0106cebb 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/AuthMockServerTest.java
@@ -19,6 +19,7 @@ import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
 
 import com.google.cloud.spanner.pgadapter.wireprotocol.PasswordMessage;
 import com.google.common.collect.ImmutableList;
@@ -42,6 +43,13 @@ import org.postgresql.util.PSQLException;
 
 @RunWith(JUnit4.class)
 public class AuthMockServerTest extends AbstractMockServerTest {
+  static final String CREDENTIALS_ERROR =
+      "ERROR: Invalid credentials received.\n"
+          + "  Hint: PGAdapter expects credentials to be one of the following:\n"
+          + "1. Username contains the fixed string 'oauth2' and the password field contains a valid OAuth2 token.\n"
+          + "2. Username contains any string and the password field contains the JSON payload of a credentials file (e.g. a service account file).\n"
+          + "3. Username contains the email address of a service account and the password contains the corresponding private key for the service account.";
+
   @BeforeClass
   public static void loadPgJdbcDriver() throws Exception {
     // Make sure the PG JDBC driver is loaded.
@@ -77,12 +85,58 @@ public class AuthMockServerTest extends AbstractMockServerTest {
     PSQLException exception =
         assertThrows(
             PSQLException.class, () -> DriverManager.getConnection(createUrl(), "foo", "bar"));
-    // TODO: Split this error message into a message and a hint.
+    assertEquals(CREDENTIALS_ERROR, exception.getMessage());
+  }
+
+  @Test
+  public void testConnectFailsWithEmptyUsername() {
+    PSQLException exception =
+        assertThrows(
+            PSQLException.class,
+            () -> DriverManager.getConnection(createUrl(), "", "any-random-oauth2-token"));
+    assertEquals(CREDENTIALS_ERROR, exception.getMessage());
+  }
+
+  @Test
+  public void testConnectFailsWithEmptyPassword() {
+    PSQLException exception =
+        assertThrows(
+            PSQLException.class, () -> DriverManager.getConnection(createUrl(), "oauth2", ""));
     assertEquals(
-        "ERROR: Invalid credentials received. PGAdapter expects the password to contain the JSON payload of a credentials file. Alternatively, the password may contain only the private key of a service account. The user name must in that case contain the service account email address.",
+        "ERROR: Invalid credentials received.\n"
+            + "  Hint: PGAdapter expects credentials to be one of the following:\n"
+            + "1. Username contains the fixed string 'oauth2' and the password field contains a valid OAuth2 token.\n"
+            + "2. Username contains any string and the password field contains the JSON payload of a credentials file (e.g. a service account file).\n"
+            + "3. Username contains the email address of a service account and the password contains the corresponding private key for the service account.",
         exception.getMessage());
   }
 
+  @Test
+  public void testConnectWithOAuth2Token() throws Exception {
+    String username = "oauth2";
+    String password = "any-random-oauth2-token";
+    // Note that even though we are sending credentials here, these will never reach the mock
+    // server. The Connection API will never send credentials over a plain-text connection to
+    // Spanner.
+    try (Connection connection = DriverManager.getConnection(createUrl(), username, password)) {
+      try (ResultSet resultSet = connection.createStatement().executeQuery(SELECT1.getSql())) {
+        assertTrue(resultSet.next());
+        assertEquals(1, resultSet.getInt(1));
+        assertFalse(resultSet.next());
+      }
+    }
+
+    PasswordMessage passwordMessage =
+        pgServer.getDebugMessages().stream()
+            .filter(message -> message instanceof PasswordMessage)
+            .map(message -> (PasswordMessage) message)
+            .findAny()
+            .orElseGet(() -> mock(PasswordMessage.class));
+    assertNotNull(passwordMessage);
+    assertEquals(username, passwordMessage.getUsername());
+    assertEquals(password, passwordMessage.getPassword());
+  }
+
   @Test
   public void testConnectWithPrivateKey() throws Exception {
     String username = "foo@bar.com";
@@ -103,7 +157,7 @@ public class AuthMockServerTest extends AbstractMockServerTest {
             .filter(message -> message instanceof PasswordMessage)
             .map(message -> (PasswordMessage) message)
             .findAny()
-            .orElseGet(null);
+            .orElseGet(() -> mock(PasswordMessage.class));
     assertNotNull(passwordMessage);
     assertEquals("foo@bar.com", passwordMessage.getUsername());
     assertEquals(password, passwordMessage.getPassword());
@@ -148,7 +202,7 @@ public class AuthMockServerTest extends AbstractMockServerTest {
             .filter(message -> message instanceof PasswordMessage)
             .map(message -> (PasswordMessage) message)
             .findAny()
-            .orElseGet(null);
+            .orElseGet(() -> mock(PasswordMessage.class));
     assertNotNull(passwordMessage);
     assertEquals("whatever", passwordMessage.getUsername());
     assertEquals(password, passwordMessage.getPassword());
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ITAuthTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ITAuthTest.java
index cb0d6094..6d7cf5ce 100644
--- a/src/test/java/com/google/cloud/spanner/pgadapter/ITAuthTest.java
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/ITAuthTest.java
@@ -16,6 +16,7 @@ package com.google.cloud.spanner.pgadapter;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assume.assumeTrue;
@@ -26,12 +27,15 @@ import com.google.api.client.json.JsonObjectParser;
 import com.google.api.client.json.gson.GsonFactory;
 import com.google.cloud.spanner.Database;
 import com.google.common.collect.ImmutableList;
+import java.io.BufferedReader;
 import java.io.FileInputStream;
+import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.util.stream.Collectors;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -72,6 +76,20 @@ public class ITAuthTest implements IntegrationTest {
         testEnv.getPGAdapterHostAndPort(), database.getId().getDatabase());
   }
 
+  private static boolean isGcloudAvailable() {
+    ProcessBuilder builder = new ProcessBuilder();
+    String[] gcloudCommand = new String[] {"gcloud", "--version"};
+    builder.command(gcloudCommand);
+    try {
+      Process process = builder.start();
+      int res = process.waitFor();
+
+      return res == 0;
+    } catch (Exception ignored) {
+      return false;
+    }
+  }
+
   @Test
   public void testConnectFailsWithoutAuth() {
     // The server is started with authentication required. Trying to obtain a connection without any
@@ -92,12 +110,45 @@ public class ITAuthTest implements IntegrationTest {
         assertThrows(
             SQLException.class,
             () -> DriverManager.getConnection(getConnectionUrl(), "foo", "bar"));
-    assertEquals(
-        "ERROR: Invalid credentials received. "
-            + "PGAdapter expects the password to contain the JSON payload of a credentials file. "
-            + "Alternatively, the password may contain only the private key of a service account. "
-            + "The user name must in that case contain the service account email address.",
-        exception.getMessage());
+    assertEquals(AuthMockServerTest.CREDENTIALS_ERROR, exception.getMessage());
+  }
+
+  @Test
+  public void testConnectWithOAuth2Token() throws Exception {
+    String credentialsFile = System.getenv("GOOGLE_APPLICATION_CREDENTIALS");
+    assumeTrue("Auth tests require default (service account) credentials", credentialsFile != null);
+    assumeTrue("OAuth2 test requires gcloud", isGcloudAvailable());
+
+    ProcessBuilder builder = new ProcessBuilder();
+    String[] gcloudCommand =
+        new String[] {"gcloud", "auth", "application-default", "print-access-token", "--quiet"};
+    builder.command(gcloudCommand);
+    Process process = builder.start();
+    String errors;
+    String output;
+
+    try (BufferedReader reader =
+            new BufferedReader(new InputStreamReader(process.getInputStream()));
+        BufferedReader errorReader =
+            new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
+      errors = errorReader.lines().collect(Collectors.joining("\n"));
+      output = reader.lines().collect(Collectors.joining("\n"));
+    }
+
+    assertEquals("", errors);
+    assertNotEquals("", output);
+    int res = process.waitFor();
+    assertEquals(0, res);
+
+    try (Connection connection =
+        DriverManager.getConnection(getConnectionUrl(), "oauth2", output)) {
+      try (ResultSet resultSet =
+          connection.createStatement().executeQuery("SELECT 'Hello World!'")) {
+        assertTrue(resultSet.next());
+        assertEquals("Hello World!", resultSet.getString(1));
+        assertFalse(resultSet.next());
+      }
+    }
   }
 
   @Test
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/PGExceptionTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/PGExceptionTest.java
new file mode 100644
index 00000000..92020bff
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/PGExceptionTest.java
@@ -0,0 +1,49 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.cloud.spanner.pgadapter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import com.google.cloud.spanner.pgadapter.error.PGException;
+import com.google.cloud.spanner.pgadapter.error.SQLState;
+import com.google.cloud.spanner.pgadapter.error.Severity;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class PGExceptionTest {
+
+  @Test
+  public void testHints() {
+    assertNull(
+        PGException.newBuilder()
+            .setMessage("test message")
+            .setSQLState(SQLState.InternalError)
+            .setSeverity(Severity.ERROR)
+            .build()
+            .getHints());
+    assertEquals(
+        "test hint\nsecond line",
+        PGException.newBuilder()
+            .setMessage("test message")
+            .setSQLState(SQLState.InternalError)
+            .setSeverity(Severity.ERROR)
+            .setHints("test hint\nsecond line")
+            .build()
+            .getHints());
+  }
+}
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponseTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponseTest.java
new file mode 100644
index 00000000..48ea52b7
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponseTest.java
@@ -0,0 +1,109 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.cloud.spanner.pgadapter.wireoutput;
+
+import static com.google.cloud.spanner.pgadapter.wireoutput.ErrorResponse.calculateLength;
+import static org.junit.Assert.assertEquals;
+
+import com.google.cloud.spanner.pgadapter.error.PGException;
+import com.google.cloud.spanner.pgadapter.error.SQLState;
+import com.google.cloud.spanner.pgadapter.error.Severity;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ErrorResponseTest {
+
+  @Test
+  public void testCalculateLength() {
+    assertEquals(
+        4
+            + "test message".length()
+            + /* Field header + null terminator */ 2
+            + "ERROR".length()
+            + 2
+            + "P0001".length()
+            + 2
+            + 1,
+        calculateLength(
+            PGException.newBuilder()
+                .setMessage("test message")
+                .setSeverity(Severity.ERROR)
+                .setSQLState(SQLState.RaiseException)
+                .build()));
+    assertEquals(
+        4
+            + "test message".length()
+            + /* Field header + null terminator */ 2
+            + "ERROR".length()
+            + 2
+            + "P0001".length()
+            + 2
+            + "test hint".length()
+            + 2
+            + 1,
+        calculateLength(
+            PGException.newBuilder()
+                .setMessage("test message")
+                .setSeverity(Severity.ERROR)
+                .setSQLState(SQLState.RaiseException)
+                .setHints("test hint")
+                .build()));
+  }
+
+  @Test
+  public void testSendPayload() throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    DataOutputStream output = new DataOutputStream(out);
+    ErrorResponse response =
+        new ErrorResponse(
+            output,
+            PGException.newBuilder()
+                .setMessage("test message")
+                .setSeverity(Severity.ERROR)
+                .setSQLState(SQLState.RaiseException)
+                .build());
+
+    response.sendPayload();
+
+    assertEquals("SERROR\0CP0001\0Mtest message\0\0", out.toString());
+    assertEquals("Length: 33, Error Message: test message, Hints: ", response.getPayloadString());
+  }
+
+  @Test
+  public void testSendPayloadWithHint() throws Exception {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    DataOutputStream output = new DataOutputStream(out);
+    ErrorResponse response =
+        new ErrorResponse(
+            output,
+            PGException.newBuilder()
+                .setMessage("test message")
+                .setSeverity(Severity.ERROR)
+                .setSQLState(SQLState.RaiseException)
+                .setHints("test hint\nline 2")
+                .build());
+
+    response.sendPayload();
+
+    assertEquals("SERROR\0CP0001\0Mtest message\0Htest hint\nline 2\0\0", out.toString());
+    assertEquals(
+        "Length: 51, Error Message: test message, Hints: test hint\n" + "line 2",
+        response.getPayloadString());
+  }
+}