Skip to content

Commit b6ebdfe

Browse files
author
Gorbasch
committed
Enable three-legged OAuth
1 parent 25914fc commit b6ebdfe

File tree

1 file changed

+197
-47
lines changed

1 file changed

+197
-47
lines changed

nifi-gcp-oauth2-provider/src/main/java/com/dloop/nifi/gcp/oauth2/GCPOauth2AccessTokenProvider.java

Lines changed: 197 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,30 @@
1616
*/
1717
package com.dloop.nifi.gcp.oauth2;
1818

19+
import com.google.api.client.http.ByteArrayContent;
20+
import com.google.api.client.http.GenericUrl;
21+
import com.google.api.client.http.HttpRequest;
22+
import com.google.api.client.http.javanet.NetHttpTransport;
23+
import com.google.api.gax.core.FixedCredentialsProvider;
1924
import com.google.auth.oauth2.GoogleCredentials;
25+
import com.google.auth.oauth2.ImpersonatedCredentials;
26+
import com.google.cloud.iam.credentials.v1.IamCredentialsClient;
27+
import com.google.cloud.iam.credentials.v1.IamCredentialsSettings;
28+
import com.google.cloud.iam.credentials.v1.SignJwtRequest;
29+
import com.google.gson.Gson;
30+
import com.google.gson.JsonObject;
31+
import com.google.gson.JsonParser;
2032
import java.io.IOException;
33+
import java.time.Duration;
34+
import java.time.Instant;
2135
import java.util.ArrayList;
36+
import java.util.Arrays;
2237
import java.util.Collections;
38+
import java.util.Date;
2339
import java.util.List;
40+
import java.util.Map;
41+
import org.apache.http.client.utils.URLEncodedUtils;
42+
import org.apache.http.message.BasicNameValuePair;
2443
import org.apache.nifi.annotation.documentation.CapabilityDescription;
2544
import org.apache.nifi.annotation.documentation.SeeAlso;
2645
import org.apache.nifi.annotation.documentation.Tags;
@@ -36,29 +55,33 @@
3655
import org.apache.nifi.reporting.InitializationException;
3756

3857
@Tags({ "gcp", "oauth2", "provider", "authorization", "access token", "http" })
39-
@CapabilityDescription("Provides OAuth 2.0 access tokens for Google APIs.")
58+
@CapabilityDescription("Provides OAuth 2.0 access tokens for Google REST APIs.")
4059
@SeeAlso({ OAuth2AccessTokenProvider.class, GCPCredentialsService.class })
4160
public class GCPOauth2AccessTokenProvider
4261
extends AbstractControllerService
4362
implements OAuth2AccessTokenProvider {
4463

45-
public static final PropertyDescriptor PROJECT_ID =
64+
private static final String DEFAULT_SCOPE =
65+
"https://www.googleapis.com/auth/cloud-platform";
66+
67+
public static final PropertyDescriptor SCOPE =
4668
new PropertyDescriptor.Builder()
47-
.name("project-id")
48-
.displayName("Project ID")
69+
.name("scope")
70+
.displayName("Scope")
4971
.description(
50-
"Creates a credential with the provided quota project."
72+
"Whitespace-delimited, case-sensitive list of scopes of the access request (as per the OAuth 2.0 specification). More information: https://developers.google.com/identity/protocols/oauth2/scopes"
5173
)
5274
.required(false)
5375
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
76+
.defaultValue(DEFAULT_SCOPE)
5477
.build();
5578

56-
public static final PropertyDescriptor SCOPE =
79+
public static final PropertyDescriptor SERVICE_ACCOUNT =
5780
new PropertyDescriptor.Builder()
58-
.name("scope")
59-
.displayName("Scope")
81+
.name("impersonate-service-account")
82+
.displayName("Impersonate Service Account")
6083
.description(
61-
"Space-delimited, case-sensitive list of scopes of the access request (as per the OAuth 2.0 specification)"
84+
"Allow credentials issued to a user or service account to impersonate another service account. The source project must enable the \"IAMCredentials\" API.\nAlso, the target service account must grant the originating principal the \"Service Account Token Creator\" IAM role. More information: https://cloud.google.com/iam/docs/service-account-impersonation"
6285
)
6386
.required(false)
6487
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
@@ -67,29 +90,46 @@ public class GCPOauth2AccessTokenProvider
6790
public static final PropertyDescriptor DELEGATE =
6891
new PropertyDescriptor.Builder()
6992
.name("delegate")
70-
.displayName("Delegate")
93+
.displayName("Domain-wide delegation")
94+
.description(
95+
"If the credentials support domain-wide delegation, creates a copy of the identity so that it impersonates the specified user; otherwise, returns the same instance. To enable domain-wide delegation it is necessary to use Google Workspace in your organization. More information: https://developers.google.com/cloud-search/docs/guides/delegation"
96+
)
97+
.required(false)
98+
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
99+
.build();
100+
101+
public static final PropertyDescriptor PROJECT_ID =
102+
new PropertyDescriptor.Builder()
103+
.name("project-id")
104+
.displayName("Project ID")
71105
.description(
72-
"If the credentials support domain-wide delegation, creates a copy of the identity so that it impersonates the specified user; otherwise, returns the same instance."
106+
"Sets a custom quota/billing project for the JWT sign request from the IAM Credentials API. Important: The calling user or service account must have the serviceusage.services.use IAM permission for a project to be able to designate it as your quota project. This setting does not have an affect on the quota project of the REST API requests that will use the access token of this service. To set a custom quota project for the REST API requests set the custom header 'x-goog-user-project' as a dynamic property on the InvokeHTTP processor. More information: https://cloud.google.com/docs/quotas/set-quota-project#rest_request"
73107
)
74108
.required(false)
75109
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
110+
.dependsOn(SERVICE_ACCOUNT)
111+
.dependsOn(DELEGATE)
76112
.build();
77113

78114
private static final List<PropertyDescriptor> properties;
79115

80116
static {
81117
final List<PropertyDescriptor> props = new ArrayList<>();
82118
props.add(GoogleUtils.GCP_CREDENTIALS_PROVIDER_SERVICE);
83-
props.add(PROJECT_ID);
84119
props.add(SCOPE);
120+
props.add(SERVICE_ACCOUNT);
85121
props.add(DELEGATE);
122+
props.add(PROJECT_ID);
86123
properties = Collections.unmodifiableList(props);
87124
}
88125

89126
private volatile GoogleCredentials googleCredentials;
90-
private volatile String projectId;
91-
private volatile String[] scopes;
127+
private volatile IamCredentialsClient iamCredentialsClient;
128+
private volatile AccessToken accessToken;
129+
private volatile String serviceAccount;
130+
private volatile String scope;
92131
private volatile String delegate;
132+
private volatile String projectId;
93133

94134
@Override
95135
protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
@@ -98,62 +138,172 @@ protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
98138

99139
@OnEnabled
100140
public void onEnabled(final ConfigurationContext context)
101-
throws InitializationException {
141+
throws InitializationException, IOException {
142+
// Reset processor
143+
googleCredentials = null;
144+
iamCredentialsClient = null;
145+
accessToken = null;
146+
102147
// Get GCP credentials service
103148
GCPCredentialsService gcpCredentialsService = context
104149
.getProperty(GoogleUtils.GCP_CREDENTIALS_PROVIDER_SERVICE)
105150
.asControllerService(GCPCredentialsService.class);
106151

107-
// Get GCP credentials
108-
googleCredentials = gcpCredentialsService.getGoogleCredentials();
152+
// Initialize credentials
153+
googleCredentials = gcpCredentialsService
154+
.getGoogleCredentials()
155+
.createScoped(DEFAULT_SCOPE);
109156

110-
// Change quota project if specified
157+
scope = context.getProperty(SCOPE).getValue().replaceAll("\\s+", " ");
158+
serviceAccount = context.getProperty(SERVICE_ACCOUNT).getValue();
159+
delegate = context.getProperty(DELEGATE).getValue();
111160
projectId = context.getProperty(PROJECT_ID).getValue();
112-
if (projectId != null && !projectId.isBlank()) {
113-
googleCredentials = googleCredentials.createWithQuotaProject(
114-
projectId
115-
);
116-
}
117161

118-
// Apply required scope(s)
119-
String scope = context.getProperty(SCOPE).getValue();
120-
if (scope != null) {
121-
scopes = scope.split("\\s+");
122-
if (scopes.length > 0) {
123-
googleCredentials = googleCredentials.createScoped(scopes);
162+
if (serviceAccount != null) {
163+
// Impersonate service account with user delegation
164+
if (delegate != null) {
165+
IamCredentialsSettings.Builder builder =
166+
IamCredentialsSettings.newBuilder()
167+
.setCredentialsProvider(
168+
FixedCredentialsProvider.create(googleCredentials)
169+
);
170+
171+
if (projectId != null) {
172+
builder.setQuotaProjectId(projectId);
173+
}
174+
175+
iamCredentialsClient = IamCredentialsClient.create(
176+
builder.build()
177+
);
178+
179+
return;
124180
}
181+
182+
// Impersonate service account without user delegation
183+
googleCredentials = ImpersonatedCredentials.newBuilder()
184+
.setSourceCredentials(googleCredentials)
185+
.setTargetPrincipal(serviceAccount)
186+
.setScopes(Arrays.asList(scope.split(" ")))
187+
.build();
188+
189+
return;
125190
}
126191

127-
// Impersonate a user account via account wide delegation if specified
128-
delegate = context.getProperty(DELEGATE).getValue();
129-
if (delegate != null && !delegate.isBlank()) {
192+
googleCredentials = googleCredentials.createScoped(scope.split(" "));
193+
194+
if (delegate != null) {
130195
googleCredentials = googleCredentials.createDelegated(delegate);
131196
}
132197
}
133198

134199
@Override
135200
public AccessToken getAccessDetails() {
136-
// Refresh access token if expired
201+
if (accessToken != null && !accessToken.isExpired()) {
202+
return accessToken;
203+
}
204+
205+
com.google.auth.oauth2.AccessToken gcpAccessToken;
206+
if (serviceAccount != null && delegate != null) {
207+
gcpAccessToken = getJwtCredentials();
208+
} else {
209+
gcpAccessToken = getDefaultCredentials();
210+
}
211+
212+
Long expiresIn = Duration.between(
213+
Instant.now(),
214+
gcpAccessToken.getExpirationTime().toInstant()
215+
).getSeconds();
216+
217+
accessToken = new AccessToken(
218+
gcpAccessToken.getTokenValue(),
219+
null,
220+
"OAuth2",
221+
expiresIn,
222+
scope
223+
);
224+
225+
return accessToken;
226+
}
227+
228+
private com.google.auth.oauth2.AccessToken getDefaultCredentials() {
137229
try {
138230
googleCredentials.refreshIfExpired();
231+
232+
return googleCredentials.getAccessToken();
139233
} catch (IOException e) {
140-
getLogger().error(e.getMessage());
234+
getLogger().error(e.getMessage(), e);
235+
141236
return null;
142237
}
238+
}
143239

144-
String accessToken = googleCredentials.getAccessToken().getTokenValue();
145-
String tokenType = googleCredentials.getAuthenticationType();
146-
Long expiresIn =
147-
googleCredentials.getAccessToken().getExpirationTime().getTime() -
148-
System.currentTimeMillis();
240+
private com.google.auth.oauth2.AccessToken getJwtCredentials() {
241+
try {
242+
long iat = Instant.now().getEpochSecond();
243+
long exp = iat + 3600;
244+
String payload = new Gson()
245+
.toJson(
246+
Map.of(
247+
"aud",
248+
"https://oauth2.googleapis.com/token",
249+
"iat",
250+
iat,
251+
"exp",
252+
exp,
253+
"iss",
254+
serviceAccount,
255+
"scope",
256+
scope,
257+
"sub",
258+
delegate
259+
)
260+
);
149261

150-
// Return access token
151-
return new AccessToken(
152-
accessToken,
153-
null,
154-
tokenType,
155-
expiresIn,
156-
String.join(" ", scopes)
157-
);
262+
SignJwtRequest signJwtRequest = SignJwtRequest.newBuilder()
263+
.setName("projects/-/serviceAccounts/" + serviceAccount)
264+
.setPayload(payload)
265+
.build();
266+
267+
String assertion = iamCredentialsClient
268+
.signJwt(signJwtRequest)
269+
.getSignedJwt();
270+
271+
String body = URLEncodedUtils.format(
272+
List.of(
273+
new BasicNameValuePair("assertion", assertion),
274+
new BasicNameValuePair(
275+
"grant_type",
276+
"urn:ietf:params:oauth:grant-type:jwt-bearer"
277+
)
278+
),
279+
"utf-8"
280+
);
281+
282+
HttpRequest httpRequest = new NetHttpTransport()
283+
.createRequestFactory()
284+
.buildPostRequest(
285+
new GenericUrl("https://oauth2.googleapis.com/token"),
286+
ByteArrayContent.fromString(
287+
"application/x-www-form-urlencoded",
288+
body
289+
)
290+
);
291+
292+
JsonObject json = JsonParser.parseString(
293+
httpRequest.execute().parseAsString()
294+
).getAsJsonObject();
295+
296+
return new com.google.auth.oauth2.AccessToken(
297+
json.get("access_token").getAsString(),
298+
Date.from(
299+
Instant.now()
300+
.plusSeconds(json.get("expires_in").getAsLong())
301+
)
302+
);
303+
} catch (IOException e) {
304+
getLogger().error(e.getMessage(), e);
305+
306+
return null;
307+
}
158308
}
159309
}

0 commit comments

Comments
 (0)