Skip to content

Commit 0cedf15

Browse files
authored
feat: support OAuth2 token authentication (#360)
* feat: support OAuth2 token authentication Add support for authentication with simple OAuth2 tokens. * chore: move error message to hint + more tests * fix: clirr check + credentials error message
1 parent c856ce2 commit 0cedf15

File tree

12 files changed

+384
-38
lines changed

12 files changed

+384
-38
lines changed

clirr-ignored-differences.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@
5555
<className>com/google/cloud/spanner/pgadapter/wireprotocol/AbstractQueryProtocolMessage</className>
5656
<method>java.lang.String getSql()</method>
5757
</difference>
58+
<difference>
59+
<differenceType>7005</differenceType>
60+
<className>com/google/cloud/spanner/connection/ConnectionOptionsHelper</className>
61+
<method>com.google.cloud.spanner.connection.ConnectionOptions$Builder setCredentials(com.google.cloud.spanner.connection.ConnectionOptions$Builder, com.google.auth.oauth2.GoogleCredentials)</method>
62+
<to>com.google.cloud.spanner.connection.ConnectionOptions$Builder setCredentials(com.google.cloud.spanner.connection.ConnectionOptions$Builder, com.google.auth.Credentials)</to>
63+
</difference>
64+
<difference>
65+
<differenceType>7005</differenceType>
66+
<className>com/google/cloud/spanner/pgadapter/ConnectionHandler</className>
67+
<method>void connectToSpanner(java.lang.String, com.google.auth.oauth2.GoogleCredentials)</method>
68+
<to>void connectToSpanner(java.lang.String, com.google.auth.Credentials)</to>
69+
</difference>
5870

5971
<!-- Add ignores for all sub packages, as these are considered internal. -->
6072
<difference>

docs/authentication.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ java -jar pgadapter.jar -p my-project -i my-instance
3333
PGAdapter will require all connections to supply credentials for each connection if it is started
3434
with the `-a` command line argument. The credentials are sent as a password message. The password
3535
message must contain one of the following:
36-
1. The password field can contain the JSON payload of a credentials file, for example the contents
36+
1. The password field can contain a valid OAuth2 token. The username must be `oauth2`.
37+
2. The password field can contain the JSON payload of a credentials file, for example the contents
3738
of a service account key file. The username will be ignored in this case.
38-
2. The username field can contain the email address of a service account and the password field can
39+
3. The username field can contain the email address of a service account and the password field can
3940
contain a private key for that service account. Note: The password must include the
4041
`-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----` header and footer.
4142

@@ -45,6 +46,12 @@ message must contain one of the following:
4546
# The -a argument instructs PGAdapter to require authentication.
4647
java -jar pgadapter.jar -p my-project -i my-instance -a
4748

49+
# Set the username to 'oauth2' and the password to a valid OAuth2 token.
50+
# Note that PGAdapter will not be able to refresh the OAuth2 token, which means that the connection
51+
# will expire when the token has expired.
52+
PGPASSWORD=$(gcloud auth application-default print-access-token --quiet) \
53+
psql -U oauth2 -h /tmp -d my-database
54+
4855
# Set the PG password to the contents of a credentials file. This can be both a service account or a
4956
# user account file. The username will be ignored by PGAdapter.
5057
PGPASSWORD=$(cat /path/to/credentials.json) psql -h /tmp -d my-database
@@ -73,3 +80,13 @@ PGPASSWORD=$(cat /path/to/credentials.json) \
7380
psql -h /tmp -d "projects/my-project/instances/my-instance/databases/my-database"
7481
```
7582

83+
`psql` will by default show the name of the connected database on the prompt. You can shorten this
84+
by changing the default prompt of `psql` to for example show `'~'` when connected to the default
85+
database.
86+
87+
```shell
88+
PGPASSWORD=$(gcloud auth application-default print-access-token --quiet) \
89+
PGDATABASE=projects/my-project/instances/my-instance/databases/my-database \
90+
psql -U oauth2 -h /tmp \
91+
-v "PROMPT1=%~%R%x%#" -v "PROMPT2=%~%R%x%#"
92+
```

src/main/java/com/google/cloud/spanner/connection/ConnectionOptionsHelper.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,15 @@
1515
package com.google.cloud.spanner.connection;
1616

1717
import com.google.api.core.InternalApi;
18-
import com.google.auth.oauth2.GoogleCredentials;
18+
import com.google.auth.Credentials;
1919
import com.google.cloud.spanner.Spanner;
2020
import com.google.cloud.spanner.connection.ConnectionOptions.Builder;
2121

2222
/** Simple helper class to get access to a package-private method in the ConnectionOptions. */
2323
@InternalApi
2424
public class ConnectionOptionsHelper {
2525
// TODO: Remove when Builder.setCredentials(..) has been made public.
26-
public static Builder setCredentials(
27-
Builder connectionOptionsBuilder, GoogleCredentials credentials) {
26+
public static Builder setCredentials(Builder connectionOptionsBuilder, Credentials credentials) {
2827
return connectionOptionsBuilder.setCredentials(credentials);
2928
}
3029

src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
package com.google.cloud.spanner.pgadapter;
1616

1717
import com.google.api.core.InternalApi;
18-
import com.google.auth.oauth2.GoogleCredentials;
18+
import com.google.auth.Credentials;
1919
import com.google.cloud.spanner.Database;
2020
import com.google.cloud.spanner.DatabaseAdminClient;
2121
import com.google.cloud.spanner.DatabaseId;
@@ -128,7 +128,7 @@ void createSSLSocket() throws IOException {
128128
}
129129

130130
@InternalApi
131-
public void connectToSpanner(String database, @Nullable GoogleCredentials credentials) {
131+
public void connectToSpanner(String database, @Nullable Credentials credentials) {
132132
OptionsMetadata options = getServer().getOptions();
133133
String uri =
134134
options.hasDefaultConnectionUrl()

src/main/java/com/google/cloud/spanner/pgadapter/error/PGException.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public static class Builder {
2727
private Severity severity;
2828
private SQLState sqlState;
2929
private String message;
30+
private String hints;
3031

3132
private Builder() {}
3233

@@ -45,8 +46,13 @@ public Builder setMessage(String message) {
4546
return this;
4647
}
4748

49+
public Builder setHints(String hints) {
50+
this.hints = Preconditions.checkNotNull(hints);
51+
return this;
52+
}
53+
4854
public PGException build() {
49-
return new PGException(severity, sqlState, message);
55+
return new PGException(severity, sqlState, message, hints);
5056
}
5157
}
5258

@@ -56,11 +62,13 @@ public static Builder newBuilder() {
5662

5763
private final Severity severity;
5864
private final SQLState sqlState;
65+
private final String hints;
5966

60-
private PGException(Severity severity, SQLState sqlState, String message) {
67+
private PGException(Severity severity, SQLState sqlState, String message, String hints) {
6168
super(Preconditions.checkNotNull(message));
6269
this.severity = severity;
6370
this.sqlState = sqlState;
71+
this.hints = hints;
6472
}
6573

6674
public Severity getSeverity() {
@@ -70,4 +78,8 @@ public Severity getSeverity() {
7078
public SQLState getSQLState() {
7179
return sqlState;
7280
}
81+
82+
public String getHints() {
83+
return hints;
84+
}
7385
}

src/main/java/com/google/cloud/spanner/pgadapter/wireoutput/ErrorResponse.java

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import com.google.api.core.InternalApi;
1818
import com.google.cloud.spanner.pgadapter.error.PGException;
19+
import com.google.common.base.Strings;
1920
import java.io.DataOutputStream;
2021
import java.io.IOException;
2122
import java.nio.charset.StandardCharsets;
@@ -32,15 +33,27 @@ public class ErrorResponse extends WireOutput {
3233
private static final byte CODE_FLAG = 'C';
3334
private static final byte MESSAGE_FLAG = 'M';
3435
private static final byte SEVERITY_FLAG = 'S';
36+
private static final byte HINT_FLAG = 'H';
3537
private static final byte NULL_TERMINATOR = 0;
3638

3739
private final byte[] severity;
3840
private final byte[] errorMessage;
3941
private final byte[] errorState;
42+
private final byte[] hints;
4043

4144
public ErrorResponse(DataOutputStream output, PGException pgException) {
42-
super(
43-
output,
45+
super(output, calculateLength(pgException));
46+
this.errorMessage = pgException.getMessage().getBytes(StandardCharsets.UTF_8);
47+
this.errorState = pgException.getSQLState().getBytes();
48+
this.severity = pgException.getSeverity().name().getBytes(StandardCharsets.UTF_8);
49+
this.hints =
50+
Strings.isNullOrEmpty(pgException.getHints())
51+
? null
52+
: pgException.getHints().getBytes(StandardCharsets.UTF_8);
53+
}
54+
55+
static int calculateLength(PGException pgException) {
56+
int length =
4457
HEADER_LENGTH
4558
+ FIELD_IDENTIFIER_LENGTH
4659
+ pgException.getSeverity().name().getBytes(StandardCharsets.UTF_8).length
@@ -51,10 +64,14 @@ public ErrorResponse(DataOutputStream output, PGException pgException) {
5164
+ FIELD_IDENTIFIER_LENGTH
5265
+ pgException.getMessage().getBytes(StandardCharsets.UTF_8).length
5366
+ NULL_TERMINATOR_LENGTH
54-
+ NULL_TERMINATOR_LENGTH);
55-
this.errorMessage = pgException.getMessage().getBytes(StandardCharsets.UTF_8);
56-
this.errorState = pgException.getSQLState().getBytes();
57-
this.severity = pgException.getSeverity().name().getBytes(StandardCharsets.UTF_8);
67+
+ NULL_TERMINATOR_LENGTH;
68+
if (!Strings.isNullOrEmpty(pgException.getHints())) {
69+
length +=
70+
FIELD_IDENTIFIER_LENGTH
71+
+ pgException.getHints().getBytes(StandardCharsets.UTF_8).length
72+
+ NULL_TERMINATOR_LENGTH;
73+
}
74+
return length;
5875
}
5976

6077
@Override
@@ -68,6 +85,11 @@ protected void sendPayload() throws IOException {
6885
this.outputStream.writeByte(MESSAGE_FLAG);
6986
this.outputStream.write(this.errorMessage);
7087
this.outputStream.writeByte(NULL_TERMINATOR);
88+
if (this.hints != null) {
89+
this.outputStream.writeByte(HINT_FLAG);
90+
this.outputStream.write(this.hints);
91+
this.outputStream.writeByte(NULL_TERMINATOR);
92+
}
7193
this.outputStream.writeByte(NULL_TERMINATOR);
7294
}
7395

@@ -83,7 +105,12 @@ protected String getMessageName() {
83105

84106
@Override
85107
protected String getPayloadString() {
86-
return new MessageFormat("Length: {0}, " + "Error Message: {1}")
87-
.format(new Object[] {this.length, new String(this.errorMessage, UTF8)});
108+
return new MessageFormat("Length: {0}, Error Message: {1}, Hints: {2}")
109+
.format(
110+
new Object[] {
111+
this.length,
112+
new String(this.errorMessage, UTF8),
113+
this.hints == null ? "" : new String(this.hints, UTF8)
114+
});
88115
}
89116
}

src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/PasswordMessage.java

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
import com.google.api.client.util.PemReader.Section;
2222
import com.google.api.client.util.Strings;
2323
import com.google.api.core.InternalApi;
24+
import com.google.auth.Credentials;
25+
import com.google.auth.oauth2.AccessToken;
2426
import com.google.auth.oauth2.GoogleCredentials;
27+
import com.google.auth.oauth2.OAuth2Credentials;
2528
import com.google.auth.oauth2.ServiceAccountCredentials;
2629
import com.google.cloud.spanner.pgadapter.ConnectionHandler;
2730
import com.google.cloud.spanner.pgadapter.error.PGException;
@@ -36,8 +39,15 @@
3639
import org.postgresql.util.ReaderInputStream;
3740

3841
/**
39-
* A Password Message takes a username and password and input and supposedly handles auth. Here,
40-
* however, since connections are through localhost, we do not do so.
42+
* PGAdapter will convert a password message into gRPC authentication in the following ways:
43+
*
44+
* <ol>
45+
* <li>If the username is 'oauth2' the password will be interpreted as an OAuth2 token.
46+
* <li>If the username is an email address and the password contains private key section,
47+
* PGAdapter will construct a service account from the email address and private key.
48+
* <li>Otherwise, PGAdapter will try to construct a Google credentials instance from the string in
49+
* the password message. The username will be ignored.
50+
* </ol>
4151
*/
4252
@InternalApi
4353
public class PasswordMessage extends ControlMessage {
@@ -71,16 +81,17 @@ protected void sendPayload() throws Exception {
7181
return;
7282
}
7383

74-
GoogleCredentials credentials = checkCredentials(this.username, this.password);
84+
Credentials credentials = checkCredentials(this.username, this.password);
7585
if (credentials == null) {
7686
new ErrorResponse(
7787
this.outputStream,
7888
PGException.newBuilder()
79-
.setMessage(
80-
"Invalid credentials received. "
81-
+ "PGAdapter expects the password to contain the JSON payload of a credentials file. "
82-
+ "Alternatively, the password may contain only the private key of a service account. "
83-
+ "The user name must in that case contain the service account email address.")
89+
.setMessage("Invalid credentials received.")
90+
.setHints(
91+
"PGAdapter expects credentials to be one of the following:\n"
92+
+ "1. Username contains the fixed string 'oauth2' and the password field contains a valid OAuth2 token.\n"
93+
+ "2. Username contains any string and the password field contains the JSON payload of a credentials file (e.g. a service account file).\n"
94+
+ "3. Username contains the email address of a service account and the password contains the corresponding private key for the service account.")
8495
.setSQLState(SQLState.InvalidPassword)
8596
.setSeverity(Severity.ERROR)
8697
.build())
@@ -96,7 +107,7 @@ private boolean useAuthentication() {
96107
return this.connection.getServer().getOptions().shouldAuthenticate();
97108
}
98109

99-
private GoogleCredentials checkCredentials(String username, String password) {
110+
private Credentials checkCredentials(String username, String password) {
100111
if (Strings.isNullOrEmpty(password)) {
101112
return null;
102113
}
@@ -126,6 +137,11 @@ private GoogleCredentials checkCredentials(String username, String password) {
126137
}
127138
}
128139

140+
if (!Strings.isNullOrEmpty(username) && username.equalsIgnoreCase("oauth2")) {
141+
// Interpret the password as an OAuth2 token.
142+
return OAuth2Credentials.create(new AccessToken(password, null));
143+
}
144+
129145
// Try to parse the password field as a JSON string that contains a credentials object.
130146
try {
131147
return GoogleCredentials.fromStream(new ReaderInputStream(new StringReader(password)));

src/main/java/com/google/cloud/spanner/pgadapter/wireprotocol/StartupMessage.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
package com.google.cloud.spanner.pgadapter.wireprotocol;
1616

1717
import com.google.api.core.InternalApi;
18-
import com.google.auth.oauth2.GoogleCredentials;
18+
import com.google.auth.Credentials;
1919
import com.google.cloud.spanner.pgadapter.ConnectionHandler;
2020
import com.google.cloud.spanner.pgadapter.ConnectionHandler.ConnectionStatus;
2121
import com.google.cloud.spanner.pgadapter.utils.ClientAutoDetector;
@@ -79,7 +79,7 @@ static void createConnectionAndSendStartupMessage(
7979
ConnectionHandler connection,
8080
String database,
8181
Map<String, String> parameters,
82-
@Nullable GoogleCredentials credentials)
82+
@Nullable Credentials credentials)
8383
throws Exception {
8484
connection.connectToSpanner(database, credentials);
8585
for (Entry<String, String> parameter : parameters.entrySet()) {

0 commit comments

Comments
 (0)