Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ module.exports = {
testEnvironment: require.resolve('jest-environment-jsdom'),
testEnvironmentOptions: {
url: 'http://localhost/',
customExportConditions: ['node', 'node-addons'],
},
globals: {
fetch: global.fetch,
Expand Down
3 changes: 3 additions & 0 deletions kotlin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ dependencies {
implementation("com.google.http-client:google-http-client-apache-v2")
implementation("com.google.http-client:google-http-client-gson")

implementation(platform("com.google.cloud:libraries-bom:26.54.0"))
implementation("com.google.cloud:google-cloud-iamcredentials")

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation("com.google.code.gson:gson:2.8.5")

Expand Down
114 changes: 102 additions & 12 deletions kotlin/src/main/com/looker/rtl/AuthSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,27 @@
package com.looker.rtl

import com.google.api.client.http.UrlEncodedContent
import com.google.cloud.iam.credentials.v1.GenerateIdTokenRequest
import com.google.cloud.iam.credentials.v1.IamCredentialsClient
import com.google.cloud.iam.credentials.v1.IamCredentialsSettings
import com.google.cloud.iam.credentials.v1.ServiceAccountName
import java.time.LocalDateTime

open class AuthSession(
open val apiSettings: ConfigurationProvider,
open val transport: Transport = Transport(apiSettings),
) {
companion object {
private const val IAP_TOKEN_CACHE_MINUTES = 50L
}

var authToken: AuthToken = AuthToken()
private var sudoToken: AuthToken = AuthToken()
var sudoId: String = ""

private var cachedIapToken: String? = null
private var iapTokenExpiration: LocalDateTime? = null

/** Abstraction of AuthToken retrieval to support sudo mode */
fun activeToken(): AuthToken {
if (sudoToken.accessToken.isNotEmpty()) {
Expand All @@ -57,13 +69,63 @@ open class AuthSession(
*/
fun authenticate(init: RequestSettings): RequestSettings {
val headers = init.headers.toMutableMap()

// Handles Google IAP
val iapToken = fetchIapToken()
if (iapToken != null) {
headers["Proxy-Authorization"] = "Bearer $iapToken"
}

// Handles Looker Identity
val token = getToken()
if (token.accessToken.isNotBlank()) {
headers["Authorization"] = "token ${token.accessToken}"
}

return init.copy(headers = headers)
}

fun fetchIapToken(): String? {
if (cachedIapToken != null && iapTokenExpiration != null) {
if (LocalDateTime.now().isBefore(iapTokenExpiration)) {
return cachedIapToken
}
}

val config = apiSettings.readConfig()
val audience = config["iap_client_id"]
val serviceAccount = config["iap_service_account_email"]

if (audience.isNullOrBlank() || serviceAccount.isNullOrBlank()) return null

return try {
val settings = IamCredentialsSettings.newBuilder()
.setTransportChannelProvider(
IamCredentialsSettings.defaultHttpJsonTransportProviderBuilder().build(),
)
.build()

IamCredentialsClient.create(settings).use { client ->
val request = GenerateIdTokenRequest.newBuilder()
.setName(ServiceAccountName.of("-", serviceAccount).toString())
.setAudience(audience)
.setIncludeEmail(true)
.build()
val token = client.generateIdToken(request).token
cachedIapToken = token
iapTokenExpiration = LocalDateTime.now().plusMinutes(IAP_TOKEN_CACHE_MINUTES)
token
}
} catch (e: Exception) {
cachedIapToken = null
iapTokenExpiration = null
throw RuntimeException(
"OIDC Token failed for IAP. Please check your IAP Client ID and IAP Service Account Email. Underlying Google Cloud error: ${e.message}",
e,
)
}
}

fun isSudo(): Boolean = sudoId.isNotBlank() && sudoToken.isActive()

/**
Expand All @@ -82,6 +144,9 @@ open class AuthSession(
sudoId = ""
authToken.reset()
sudoToken.reset()

cachedIapToken = null
iapTokenExpiration = null
}

/**
Expand Down Expand Up @@ -136,16 +201,35 @@ open class AuthSession(
)
val params = mapOf(client_id to clientId, client_secret to clientSecret)
val body = UrlEncodedContent(params)
val token =
ok<AuthToken>(

val iapToken = fetchIapToken()

try {
val token = ok<AuthToken>(
transport.request<AuthToken>(
HttpMethod.POST,
"$apiPath/login",
emptyMap(),
body,
),
) { requestSettings ->
val headers = requestSettings.headers.toMutableMap()
iapToken?.let {
headers["Proxy-Authorization"] = "Bearer $it"
}
requestSettings.copy(headers = headers)
},
)
authToken = token
authToken = token
} catch (e: Exception) {
val isUsingIap = !config["iap_client_id"].isNullOrBlank() || !config["iap_service_account_email"].isNullOrBlank()

val errorMessage = if (isUsingIap) {
"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}"
} else {
"Authentication failed during login. \nPlease check your Looker client_id and client_secret.\nDetails: ${e.message}"
}
throw RuntimeException(errorMessage, e)
}
}

if (sudoId.isNotBlank()) {
Expand All @@ -154,7 +238,7 @@ open class AuthSession(
transport.request<AuthToken>(HttpMethod.POST, "/login/$newId") { requestSettings ->
val headers = requestSettings.headers.toMutableMap()
if (token.accessToken.isNotBlank()) {
headers["Authorization"] = "Bearer ${token.accessToken}"
headers["Authorization"] = "token ${token.accessToken}"
}
requestSettings.copy(headers = headers)
}
Expand All @@ -165,15 +249,21 @@ open class AuthSession(

private fun doLogout(): Boolean {
val token = activeToken()
val resp =
transport.request<String>(HttpMethod.DELETE, "/logout") {
val headers = it.headers.toMutableMap()
if (token.accessToken.isNotBlank()) {
headers["Authorization"] = "Bearer ${token.accessToken}"
}
it.copy(headers = headers)
val apiPath = "/api/${apiSettings.apiVersion}"

val resp = transport.request<Any>(HttpMethod.DELETE, "$apiPath/logout") { requestSettings ->
val headers = requestSettings.headers.toMutableMap()

fetchIapToken()?.let { iapToken ->
headers["Proxy-Authorization"] = "Bearer $iapToken"
}

if (token.accessToken.isNotBlank()) {
headers["Authorization"] = "token ${token.accessToken}"
}
requestSettings.copy(headers = headers)
}

val success =
when (resp) {
is SDKResponse.SDKSuccessResponse<*> -> true
Expand Down
14 changes: 9 additions & 5 deletions kotlin/src/main/com/looker/rtl/Transport.kt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ data class RequestSettings(
val method: HttpMethod,
val url: String,
val headers: Map<String, String> = emptyMap(),
val iapToken: String? = null,
)

typealias Authenticator = (init: RequestSettings) -> RequestSettings
Expand Down Expand Up @@ -378,14 +379,17 @@ open class Transport(
queryParams: Values = emptyMap(),
body: Any? = null,
noinline authenticator: Authenticator? = null,
noinline customConfiguration: ((RequestSettings) -> RequestSettings)? = null,
): SDKResponse {
val transport: HttpTransport = initTransport(options)

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

val settingsWithCustom = customConfiguration?.invoke(finalizedRequestSettings) ?: finalizedRequestSettings

val requestInitializer: HttpRequestInitializer =
customInitializer(options, finalizedRequestSettings)
customInitializer(options, settingsWithCustom)
val requestFactory: HttpRequestFactory = transport.createRequestFactory(requestInitializer)

val httpContent: HttpContent? =
Expand All @@ -412,8 +416,8 @@ open class Transport(
val request: HttpRequest =
requestFactory
.buildRequest(
finalizedRequestSettings.method.toString(),
GenericUrl(finalizedRequestSettings.url),
settingsWithCustom.method.toString(),
GenericUrl(settingsWithCustom.url),
httpContent,
).setSuppressUserAgentSuffix(true)

Expand Down Expand Up @@ -451,13 +455,13 @@ open class Transport(
SDKResponse.SDKSuccessResponse(rawResult)
} catch (e: HttpResponseException) {
SDKResponse.SDKErrorResponse(
"$method $path $ERROR_BODY: ${e.content}",
"$method $path $ERROR_BODY: ${e.content ?: ""}",
method,
path,
e.statusCode,
e.statusMessage,
e.headers,
e.content,
e.content ?: "",
)
} catch (e: Exception) {
SDKResponse.SDKError(e.message ?: "Something went wrong", e)
Expand Down
6 changes: 6 additions & 0 deletions kotlin/src/main/com/looker/sdk/ApiSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ fun apiConfig(contents: String): ApiSections {
// memory long term.
open class ApiSettings(val rawReadConfig: () -> Map<String, String>) : ConfigurationProvider {

private val keyIapClientId: String = "iap_client_id"
private val keyIapServiceAccountEmail: String = "iap_service_account_email"

companion object {
@JvmStatic
fun fromIniFile(filename: String = "./looker.ini", section: String = ""): ConfigurationProvider {
Expand Down Expand Up @@ -167,6 +170,9 @@ open class ApiSettings(val rawReadConfig: () -> Map<String, String>) : Configura
addSystemProperty(map, keyVerifySSL)
addSystemProperty(map, keyTimeout)
addSystemProperty(map, keyHttpTransport)
addSystemProperty(map, keyIapClientId)
addSystemProperty(map, keyIapServiceAccountEmail)

return map.toMap()
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,8 @@
"glob-parent": ">= 5.1.2",
"ws": ">= 7.4.6",
"**/url-parse": ">= 1.5.7",
"parse-url": "^8.1.0"
"parse-url": "^8.1.0",
"cheerio": "1.0.0-rc.12"
},
"dependencies": {
"yarn": "^1.22.22",
Expand Down
Loading
Loading