Skip to content

Commit bc4d6c3

Browse files
authored
feat(KotlinSDK): Adding IAP support for the Kotlin SDK (#1662) (#1672)
This PR adds support for routing API requests through Identity-Aware Proxy. This is done by putting an OIDC token in the header as `Proxy-Authorization: Bearer <token>`. The OIDC is generated through IamCredentialsClient. To use, users need to provide their `iap_client_id` and `iap_service_account_email` to the SDK's configuration payload. ``` Map<String, String> lookerConfig = new HashMap<>(); lookerConfig.put("base_url", "<Base_Url>"); lookerConfig.put("kotlin_http_transport", "JAVA_NET"); lookerConfig.put("client_id", "<Client_ID>"); lookerConfig.put("client_secret", "<Client_Secret>"); lookerConfig.put("iap_client_id", "<IAP_Client_ID>"); lookerConfig.put("iap_service_account_email", "<IAP_Service_Account_Email>); ConfigurationProvider settings = ApiSettings.fromMap(lookerConfig); Transport transport = new Transport(settings); AuthSession session = new AuthSession(settings, transport); LookerSDK sdk = new LookerSDK(session); ``` `iap_client_id` is the OAuth client ID that was set-up when configuring the the identity-aware proxy. `iap_service_account_email` is the service account that is authorized to bypass IAP.
1 parent 77fc3b4 commit bc4d6c3

7 files changed

Lines changed: 5286 additions & 4813 deletions

File tree

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ module.exports = {
9191
testEnvironment: require.resolve('jest-environment-jsdom'),
9292
testEnvironmentOptions: {
9393
url: 'http://localhost/',
94+
customExportConditions: ['node', 'node-addons'],
9495
},
9596
globals: {
9697
fetch: global.fetch,

kotlin/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ dependencies {
4242
implementation("com.google.http-client:google-http-client-apache-v2")
4343
implementation("com.google.http-client:google-http-client-gson")
4444

45+
implementation(platform("com.google.cloud:libraries-bom:26.54.0"))
46+
implementation("com.google.cloud:google-cloud-iamcredentials")
47+
4548
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
4649
implementation("com.google.code.gson:gson:2.8.5")
4750

kotlin/src/main/com/looker/rtl/AuthSession.kt

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,27 @@
2525
package com.looker.rtl
2626

2727
import com.google.api.client.http.UrlEncodedContent
28+
import com.google.cloud.iam.credentials.v1.GenerateIdTokenRequest
29+
import com.google.cloud.iam.credentials.v1.IamCredentialsClient
30+
import com.google.cloud.iam.credentials.v1.IamCredentialsSettings
31+
import com.google.cloud.iam.credentials.v1.ServiceAccountName
32+
import java.time.LocalDateTime
2833

2934
open class AuthSession(
3035
open val apiSettings: ConfigurationProvider,
3136
open val transport: Transport = Transport(apiSettings),
3237
) {
38+
companion object {
39+
private const val IAP_TOKEN_CACHE_MINUTES = 50L
40+
}
41+
3342
var authToken: AuthToken = AuthToken()
3443
private var sudoToken: AuthToken = AuthToken()
3544
var sudoId: String = ""
3645

46+
private var cachedIapToken: String? = null
47+
private var iapTokenExpiration: LocalDateTime? = null
48+
3749
/** Abstraction of AuthToken retrieval to support sudo mode */
3850
fun activeToken(): AuthToken {
3951
if (sudoToken.accessToken.isNotEmpty()) {
@@ -57,13 +69,63 @@ open class AuthSession(
5769
*/
5870
fun authenticate(init: RequestSettings): RequestSettings {
5971
val headers = init.headers.toMutableMap()
72+
73+
// Handles Google IAP
74+
val iapToken = fetchIapToken()
75+
if (iapToken != null) {
76+
headers["Proxy-Authorization"] = "Bearer $iapToken"
77+
}
78+
79+
// Handles Looker Identity
6080
val token = getToken()
6181
if (token.accessToken.isNotBlank()) {
6282
headers["Authorization"] = "token ${token.accessToken}"
6383
}
84+
6485
return init.copy(headers = headers)
6586
}
6687

88+
fun fetchIapToken(): String? {
89+
if (cachedIapToken != null && iapTokenExpiration != null) {
90+
if (LocalDateTime.now().isBefore(iapTokenExpiration)) {
91+
return cachedIapToken
92+
}
93+
}
94+
95+
val config = apiSettings.readConfig()
96+
val audience = config["iap_client_id"]
97+
val serviceAccount = config["iap_service_account_email"]
98+
99+
if (audience.isNullOrBlank() || serviceAccount.isNullOrBlank()) return null
100+
101+
return try {
102+
val settings = IamCredentialsSettings.newBuilder()
103+
.setTransportChannelProvider(
104+
IamCredentialsSettings.defaultHttpJsonTransportProviderBuilder().build(),
105+
)
106+
.build()
107+
108+
IamCredentialsClient.create(settings).use { client ->
109+
val request = GenerateIdTokenRequest.newBuilder()
110+
.setName(ServiceAccountName.of("-", serviceAccount).toString())
111+
.setAudience(audience)
112+
.setIncludeEmail(true)
113+
.build()
114+
val token = client.generateIdToken(request).token
115+
cachedIapToken = token
116+
iapTokenExpiration = LocalDateTime.now().plusMinutes(IAP_TOKEN_CACHE_MINUTES)
117+
token
118+
}
119+
} catch (e: Exception) {
120+
cachedIapToken = null
121+
iapTokenExpiration = null
122+
throw RuntimeException(
123+
"OIDC Token failed for IAP. Please check your IAP Client ID and IAP Service Account Email. Underlying Google Cloud error: ${e.message}",
124+
e,
125+
)
126+
}
127+
}
128+
67129
fun isSudo(): Boolean = sudoId.isNotBlank() && sudoToken.isActive()
68130

69131
/**
@@ -82,6 +144,9 @@ open class AuthSession(
82144
sudoId = ""
83145
authToken.reset()
84146
sudoToken.reset()
147+
148+
cachedIapToken = null
149+
iapTokenExpiration = null
85150
}
86151

87152
/**
@@ -136,16 +201,35 @@ open class AuthSession(
136201
)
137202
val params = mapOf(client_id to clientId, client_secret to clientSecret)
138203
val body = UrlEncodedContent(params)
139-
val token =
140-
ok<AuthToken>(
204+
205+
val iapToken = fetchIapToken()
206+
207+
try {
208+
val token = ok<AuthToken>(
141209
transport.request<AuthToken>(
142210
HttpMethod.POST,
143211
"$apiPath/login",
144212
emptyMap(),
145213
body,
146-
),
214+
) { requestSettings ->
215+
val headers = requestSettings.headers.toMutableMap()
216+
iapToken?.let {
217+
headers["Proxy-Authorization"] = "Bearer $it"
218+
}
219+
requestSettings.copy(headers = headers)
220+
},
147221
)
148-
authToken = token
222+
authToken = token
223+
} catch (e: Exception) {
224+
val isUsingIap = !config["iap_client_id"].isNullOrBlank() || !config["iap_service_account_email"].isNullOrBlank()
225+
226+
val errorMessage = if (isUsingIap) {
227+
"Authentication failed during login. \nPlease check your iap_client_id and iap_service_account_email fields, as well as your Looker credentials.\nDetails: ${e.message}"
228+
} else {
229+
"Authentication failed during login. \nPlease check your Looker client_id and client_secret.\nDetails: ${e.message}"
230+
}
231+
throw RuntimeException(errorMessage, e)
232+
}
149233
}
150234

151235
if (sudoId.isNotBlank()) {
@@ -154,7 +238,7 @@ open class AuthSession(
154238
transport.request<AuthToken>(HttpMethod.POST, "/login/$newId") { requestSettings ->
155239
val headers = requestSettings.headers.toMutableMap()
156240
if (token.accessToken.isNotBlank()) {
157-
headers["Authorization"] = "Bearer ${token.accessToken}"
241+
headers["Authorization"] = "token ${token.accessToken}"
158242
}
159243
requestSettings.copy(headers = headers)
160244
}
@@ -165,15 +249,21 @@ open class AuthSession(
165249

166250
private fun doLogout(): Boolean {
167251
val token = activeToken()
168-
val resp =
169-
transport.request<String>(HttpMethod.DELETE, "/logout") {
170-
val headers = it.headers.toMutableMap()
171-
if (token.accessToken.isNotBlank()) {
172-
headers["Authorization"] = "Bearer ${token.accessToken}"
173-
}
174-
it.copy(headers = headers)
252+
val apiPath = "/api/${apiSettings.apiVersion}"
253+
254+
val resp = transport.request<Any>(HttpMethod.DELETE, "$apiPath/logout") { requestSettings ->
255+
val headers = requestSettings.headers.toMutableMap()
256+
257+
fetchIapToken()?.let { iapToken ->
258+
headers["Proxy-Authorization"] = "Bearer $iapToken"
175259
}
176260

261+
if (token.accessToken.isNotBlank()) {
262+
headers["Authorization"] = "token ${token.accessToken}"
263+
}
264+
requestSettings.copy(headers = headers)
265+
}
266+
177267
val success =
178268
when (resp) {
179269
is SDKResponse.SDKSuccessResponse<*> -> true

kotlin/src/main/com/looker/rtl/Transport.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ data class RequestSettings(
176176
val method: HttpMethod,
177177
val url: String,
178178
val headers: Map<String, String> = emptyMap(),
179+
val iapToken: String? = null,
179180
)
180181

181182
typealias Authenticator = (init: RequestSettings) -> RequestSettings
@@ -378,14 +379,17 @@ open class Transport(
378379
queryParams: Values = emptyMap(),
379380
body: Any? = null,
380381
noinline authenticator: Authenticator? = null,
382+
noinline customConfiguration: ((RequestSettings) -> RequestSettings)? = null,
381383
): SDKResponse {
382384
val transport: HttpTransport = initTransport(options)
383385

384386
val finalizedRequestSettings: RequestSettings =
385387
finalizeRequest(method, path, queryParams, authenticator)
386388

389+
val settingsWithCustom = customConfiguration?.invoke(finalizedRequestSettings) ?: finalizedRequestSettings
390+
387391
val requestInitializer: HttpRequestInitializer =
388-
customInitializer(options, finalizedRequestSettings)
392+
customInitializer(options, settingsWithCustom)
389393
val requestFactory: HttpRequestFactory = transport.createRequestFactory(requestInitializer)
390394

391395
val httpContent: HttpContent? =
@@ -412,8 +416,8 @@ open class Transport(
412416
val request: HttpRequest =
413417
requestFactory
414418
.buildRequest(
415-
finalizedRequestSettings.method.toString(),
416-
GenericUrl(finalizedRequestSettings.url),
419+
settingsWithCustom.method.toString(),
420+
GenericUrl(settingsWithCustom.url),
417421
httpContent,
418422
).setSuppressUserAgentSuffix(true)
419423

@@ -451,13 +455,13 @@ open class Transport(
451455
SDKResponse.SDKSuccessResponse(rawResult)
452456
} catch (e: HttpResponseException) {
453457
SDKResponse.SDKErrorResponse(
454-
"$method $path $ERROR_BODY: ${e.content}",
458+
"$method $path $ERROR_BODY: ${e.content ?: ""}",
455459
method,
456460
path,
457461
e.statusCode,
458462
e.statusMessage,
459463
e.headers,
460-
e.content,
464+
e.content ?: "",
461465
)
462466
} catch (e: Exception) {
463467
SDKResponse.SDKError(e.message ?: "Something went wrong", e)

kotlin/src/main/com/looker/sdk/ApiSettings.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ fun apiConfig(contents: String): ApiSections {
6262
// memory long term.
6363
open class ApiSettings(val rawReadConfig: () -> Map<String, String>) : ConfigurationProvider {
6464

65+
private val keyIapClientId: String = "iap_client_id"
66+
private val keyIapServiceAccountEmail: String = "iap_service_account_email"
67+
6568
companion object {
6669
@JvmStatic
6770
fun fromIniFile(filename: String = "./looker.ini", section: String = ""): ConfigurationProvider {
@@ -167,6 +170,9 @@ open class ApiSettings(val rawReadConfig: () -> Map<String, String>) : Configura
167170
addSystemProperty(map, keyVerifySSL)
168171
addSystemProperty(map, keyTimeout)
169172
addSystemProperty(map, keyHttpTransport)
173+
addSystemProperty(map, keyIapClientId)
174+
addSystemProperty(map, keyIapServiceAccountEmail)
175+
170176
return map.toMap()
171177
}
172178
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,8 @@
346346
"glob-parent": ">= 5.1.2",
347347
"ws": ">= 7.4.6",
348348
"**/url-parse": ">= 1.5.7",
349-
"parse-url": "^8.1.0"
349+
"parse-url": "^8.1.0",
350+
"cheerio": "1.0.0-rc.12"
350351
},
351352
"dependencies": {
352353
"yarn": "^1.22.22",

0 commit comments

Comments
 (0)