From b431d7ab5c725d0e7623244dfae27f81d5e62140 Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Fri, 2 Feb 2024 09:33:29 +0100 Subject: [PATCH] OAuth in progress --- oauth.sh | 15 + pom.xml | 3 +- view-webapp/pom.xml | 17 +- .../org/jeudego/pairgoth/oauth/FFGHelper.kt | 29 ++ .../org/jeudego/pairgoth/oauth/OAuthHelper.kt | 29 +- .../pairgoth/oauth/OauthHelperFactory.kt | 1 + .../jeudego/pairgoth/util/AESCryptograph.kt | 57 +++ .../org/jeudego/pairgoth/util/ApiClient.kt | 341 ++++++++++++++++++ .../org/jeudego/pairgoth/util/Cryptograph.kt | 29 ++ .../org/jeudego/pairgoth/web/WebappManager.kt | 10 +- 10 files changed, 509 insertions(+), 22 deletions(-) create mode 100755 oauth.sh create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/FFGHelper.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/ApiClient.kt create mode 100644 view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Cryptograph.kt diff --git a/oauth.sh b/oauth.sh new file mode 100755 index 0000000..6d2bf1f --- /dev/null +++ b/oauth.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +trap 'kill $CSSWATCH; exit' INT +( cd view-webapp; ./csswatch.sh ) & +CSSWATCH=$! + +export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5006" +#mvn --projects view-webapp -Dpairgoth.api.url=http://localhost:8085/api/ package jetty:run +mvn -DskipTests=true \ + -Dpairgoth.auth=oauth \ + -Dpairgoth.oauth.providers=ffg \ + -Dpairgoth.oauth.ffg.secret=43f3a67bffcb5054d2f1b0e2a2374bdc \ + -Dwebapp.external.url=http://localhost:8080 + --projects view-webapp package jetty:run +kill $CSSWATCH diff --git a/pom.xml b/pom.xml index 3e50889..5118b4a 100644 --- a/pom.xml +++ b/pom.xml @@ -80,7 +80,8 @@ file tournamentfiles none - + this_should_be_overriden_with_a_command_line_option + pairtogh 587 diff --git a/view-webapp/pom.xml b/view-webapp/pom.xml index 37f1fd8..7aefcb6 100644 --- a/view-webapp/pom.xml +++ b/view-webapp/pom.xml @@ -232,9 +232,20 @@ --> - com.squareup.okhttp3 - okhttp - 4.8.1 + org.apache.httpcomponents + httpclient + 4.5.14 + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpmime + 4.5.14 diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/FFGHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/FFGHelper.kt new file mode 100644 index 0000000..71310a7 --- /dev/null +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/FFGHelper.kt @@ -0,0 +1,29 @@ +package org.jeudego.pairgoth.oauth + +class FFGHelper : OAuthHelper() { + override val name: String + get() = "facebook" + + private val FFG_HOST = "https://testffg" + + override fun getLoginURL(sessionId: String?): String { + return "$FFG_HOST/oauth2/entry_point.php/authorize?" + + "client_id=" + clientId + + "&redirect_uri=" + redirectURI + + "&scope=email" + } + + override fun getAccessTokenURL(code: String): String? { + return "$FFG_HOST/oauth2/entry_point.php/access_token?" + + "client_id=" + clientId + + "&redirect_uri=" + redirectURI + + "&client_secret=" + secret + + "&code=" + code + } + + override fun getUserInfosURL(accessToken: String): String? { + return "$FFG_HOST/oauth2/entry_point.php/user_info?" + + "field=email" + + "&access_token=" + accessToken + } +} diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt index 2c964c2..4c54b31 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt @@ -7,6 +7,10 @@ import org.jeudego.pairgoth.web.WebappManager //import com.republicate.modality.util.AESCryptograph //import com.republicate.modality.util.Cryptograph import org.apache.commons.codec.binary.Base64 +import org.jeudego.pairgoth.util.AESCryptograph +import org.jeudego.pairgoth.util.ApiClient.JsonApiClient +import org.jeudego.pairgoth.util.Cryptograph +import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.IOException import java.io.UnsupportedEncodingException @@ -21,7 +25,7 @@ abstract class OAuthHelper { protected get() = WebappManager.getMandatoryProperty("oauth." + name + ".secret") protected val redirectURI: String? protected get() = try { - val uri: String = WebappManager.Companion.getProperty("webapp.external.url") + "/oauth.html" + val uri: String = WebappManager.getMandatoryProperty("webapp.external.url") + "/oauth" URLEncoder.encode(uri, "UTF-8") } catch (uee: UnsupportedEncodingException) { logger.error("could not encode redirect URI", uee) @@ -48,30 +52,23 @@ abstract class OAuthHelper { @Throws(IOException::class) fun getUserEmail(accessToken: String): String { - val json: Json.Object = Json.Object() - // TODO - // apiClient.get(getUserInfosURL(accessToken)) - return json.getString("email") ?: throw IOException("could not fetch email") + val json = getUserInfosURL(accessToken)?.let { JsonApiClient.get(it).asObject() } + return json?.getString("email") ?: throw IOException("could not fetch email") } companion object { - protected var logger = LoggerFactory.getLogger("oauth") + protected var logger: Logger = LoggerFactory.getLogger("oauth") private const val salt = "0efd28fb53cbac42" -// private val sessionIdCrypto: Cryptograph = AESCryptograph().apply { -// init(salt) -// } + private val sessionIdCrypto: Cryptograph = AESCryptograph().apply { + init(salt) + } private fun encrypt(input: String): String { - return "TODO" -// return Base64.encodeBase64URLSafeString(sessionIdCrypto.encrypt(input)) + return Base64.encodeBase64URLSafeString(sessionIdCrypto.encrypt(input)) } private fun decrypt(input: String): String { - return "TODO" -// return sessionIdCrypto.decrypt(Base64.decodeBase64(input)) + return sessionIdCrypto.decrypt(Base64.decodeBase64(input)) } - - // TODO - // private val apiClient: ApiClient = ApiClient() } } \ No newline at end of file diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt index 45568da..6415a5b 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OauthHelperFactory.kt @@ -1,6 +1,7 @@ package org.jeudego.pairgoth.oauth object OauthHelperFactory { + private val ffg: OAuthHelper = FFGHelper() private val facebook: OAuthHelper = FacebookHelper() private val google: OAuthHelper = GoogleHelper() private val instagram: OAuthHelper = InstagramHelper() diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt new file mode 100644 index 0000000..691fde9 --- /dev/null +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt @@ -0,0 +1,57 @@ +package org.jeudego.pairgoth.util + +import java.nio.charset.Charset +import javax.crypto.Cipher +import javax.crypto.Cipher.DECRYPT_MODE +import javax.crypto.Cipher.ENCRYPT_MODE +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + + +/** + * Basic AES encryption. Please note that it uses the ECB block mode, which has the advantage + * to not require random bytes, thus providing some *persistence* for the encrypted data, but + * at the expense of some security weaknesses. The purpose here is just to encrypt temporary + * session ids in URLs, not to protect state secrets. + */ +class AESCryptograph : Cryptograph { + + override fun init(key: String) { + val bytes = key.toByteArray(Charset.defaultCharset()) + if (bytes.size < 16) { + throw Error("not enough secret bytes") + } + val secret: SecretKey = SecretKeySpec(bytes, 0, 16, ALGORITHM) + try { + encrypt.init(ENCRYPT_MODE, secret) + decrypt.init(DECRYPT_MODE, secret) + } catch (e: Exception) { + throw RuntimeException("cyptograph initialization failed", e) + } + } + + override fun encrypt(str: String): ByteArray { + return try { + encrypt.doFinal(str.toByteArray(Charset.defaultCharset())) + } catch (e: Exception) { + throw RuntimeException("encryption failed failed", e) + } + } + + override fun decrypt(bytes: ByteArray): String { + return try { + String(decrypt.doFinal(bytes), Charset.defaultCharset()) + } catch (e: Exception) { + throw RuntimeException("encryption failed failed", e) + } + } + + private var encrypt = Cipher.getInstance(CIPHER) + private var decrypt = Cipher.getInstance(CIPHER) + + companion object { + private val CIPHER = "AES/ECB/PKCS5Padding" + private val ALGORITHM = "AES" + + } +} diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/ApiClient.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/ApiClient.kt new file mode 100644 index 0000000..b966219 --- /dev/null +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/ApiClient.kt @@ -0,0 +1,341 @@ +package org.jeudego.pairgoth.util.ApiClient + +import org.jeudego.pairgoth.util.parse + +import com.republicate.kson.Json +import org.apache.http.* +import org.apache.http.client.ClientProtocolException +import org.apache.http.client.config.CookieSpecs +import org.apache.http.client.config.RequestConfig +import org.apache.http.client.entity.UrlEncodedFormEntity +import org.apache.http.client.methods.* +import org.apache.http.config.SocketConfig +import org.apache.http.conn.ssl.SSLConnectionSocketFactory +import org.apache.http.entity.ContentType +import org.apache.http.entity.EntityTemplate +import org.apache.http.entity.StringEntity +import org.apache.http.entity.mime.HttpMultipartMode +import org.apache.http.entity.mime.MultipartEntityBuilder +import org.apache.http.entity.mime.content.StringBody +import org.apache.http.impl.client.HttpClients +import org.apache.http.message.BasicHeader +import org.apache.http.message.BasicNameValuePair +import org.apache.http.ssl.SSLContexts +import org.apache.http.util.EntityUtils +import org.slf4j.LoggerFactory +import org.w3c.dom.Element +import org.xml.sax.InputSource +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.StringReader +import java.net.ProtocolException +import java.net.URLDecoder +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit +import javax.xml.parsers.DocumentBuilderFactory + + +/** + * This class implements a basic API client around Apache HTTP client. + */ + +val API_CLIENT_TIMEOUT = 60000 + +// TODO cookieStore ? credentialsProvider ? +// CookieStore cookieStore = new BasicCookieStore(); +// CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + +private val client = HttpClients.custom() + .setSSLSocketFactory( + SSLConnectionSocketFactory( + SSLContexts.createSystemDefault(), arrayOf("TLSv1.2"), + null, + SSLConnectionSocketFactory.getDefaultHostnameVerifier() + ) + ) + .setConnectionTimeToLive(1, TimeUnit.MINUTES) + .setDefaultSocketConfig( + SocketConfig.custom() + .setSoTimeout(API_CLIENT_TIMEOUT) + .build() + ) + .setDefaultRequestConfig( + RequestConfig.custom() + .setConnectTimeout(API_CLIENT_TIMEOUT) + .setSocketTimeout(API_CLIENT_TIMEOUT) + .setCookieSpec(CookieSpecs.STANDARD) + .build() + ) + .build() + +// CB TODO - this should go elsewhere +fun ByteArray.reader(charset: Charset) = + BufferedReader(InputStreamReader(ByteArrayInputStream(this), charset)) + +fun header(name: String, value: String) = BasicHeader(name, value) + +fun param(name: String, value: String) = BasicNameValuePair(name, value) + +// Incomplete list of binary content types, that we should avoid to log +fun ContentType.isBinary() = mimeType.startsWith("image/") || mimeType.endsWith("pdf") +fun ContentType.isText() = !isBinary() + +data class BinaryData(val contentType: ContentType, val data: ByteArray, val name: String, val filename: String? = null) + +fun buildMultiPartBody(payload: Json.Object, binaryData: BinaryData? = null): HttpEntity { + val builder = MultipartEntityBuilder.create() + builder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE) + for (entry in payload.entries) { + builder.addPart(entry.key, StringBody(entry.value.toString(), ContentType.MULTIPART_FORM_DATA)) + } + binaryData?.let { + builder.addBinaryBody(it.name, it.data, it.contentType, it.filename ?: "file") + } + return builder.build() +} + +abstract class BaseApiClient { + + companion object { + internal var logger = LoggerFactory.getLogger("api") + } + + private fun build(method: String, url: String, body: B? = null, vararg keyValues: NameValuePair): HttpRequestBase { + + val headers = mutableListOf
() + val params = mutableListOf() + for (pair in keyValues) { + when (pair) { + is Header -> headers.add(pair) + else -> params.add(pair) + } + } + + val req: HttpRequestBase = when (method) { + "GET" -> { + val paramsString = params.map { "${it.name}=${it.value}" }.joinToString("&") + val finalURL = url + (if (url.contains('?')) '&' else '?' ) + paramsString + HttpGet(finalURL) + } + "POST" -> { + HttpPost(url).also { req -> + if (params.isNotEmpty() && body != null) { + throw ClientProtocolException("specify POST body or POST parameters but not both") + } + if (params.isNotEmpty()) { + val entity = UrlEncodedFormEntity(params, "UTF-8") + req.entity = entity + } else if (body != null) { + val entity = if (body is HttpEntity) body + else StringEntity(body.toString(), + if (body is Json) ContentType.APPLICATION_JSON + else ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8)) + req.entity = entity + } + } + } + "PATCH" -> { + HttpPatch(url).also { req -> + if (params.isNotEmpty() && body != null) { + throw ClientProtocolException("specify PATCH body or PATCH parameters but not both") + } + if (params.isNotEmpty()) { + val entity = UrlEncodedFormEntity(params, "UTF-8") + req.entity = entity + } else if (body != null) { + val entity = if (body is HttpEntity) body + else EntityTemplate { outputstream: OutputStream -> + outputstream.write(body.toString().toByteArray()) + outputstream.flush() + }.also { + it.setContentType(ContentType.APPLICATION_JSON.toString()) + } + req.entity = entity + } + } + } + "DELETE" -> { + HttpDelete(url).also { req -> + if (params.isNotEmpty() || body != null) { + throw ClientProtocolException("DELETE body or DELETE parameters not supported") + } + } + } + else -> throw ClientProtocolException("unhandled method: $method") + } + headers.addAll(acceptHeaders()) + headers.forEach { + req.addHeader(it) + } + return req + } + + private fun submit(req: HttpRequestBase): Pair { + try { + val resp: HttpResponse = client.execute(req) + val statusLine = resp.statusLine + val status = statusLine.statusCode + when { + status in 200..299 -> { + if (status != 204 && resp.entity == null) throw ClientProtocolException("Response is empty") + val body = resp.entity?.let { EntityUtils.toByteArray(it) } + val contentType = resp.entity?.let { ContentType.get(it) } + if (logger.isTraceEnabled) traceResponse(resp, body?.toString(contentType?.charset ?: StandardCharsets.UTF_8), contentType) + return Pair(body ?: "{}".toByteArray(StandardCharsets.UTF_8), contentType ?: ContentType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8)) + } + else -> { + val body = resp.entity?.let { EntityUtils.toString(it) } + val contentType = resp.entity?.let { ContentType.get(it) } + if (logger.isTraceEnabled) traceResponse(resp, body, contentType) + var message = statusLine.toString() + if (body != null) message = "$message - $body" + throw IOException(message) + } + } + } finally { + req.releaseConnection() + } + } + + private fun traceRequest(req: HttpRequestBase, body: String? = null) { + logger.trace(">> ${req.method} ${req.uri}") + for (header in req.allHeaders) { + logger.trace(">> $header") + } + if (body != null) { + val contentType = req.allHeaders.firstOrNull { it.name == HttpHeaders.CONTENT_TYPE }?.let { + ContentType.parse(it.value) + } + if (contentType?.isText() ?: true) { + for (line in body.split(Regex("[\r\n]"))) { + logger.trace(">> $line") + } + } else { + logger.trace(">> ${contentType?.toString() ?: "unknown mime type"}, ${body.length} bytes") + } + } + } + + private fun traceResponse(resp: HttpResponse, body: String?, contentType: ContentType?) { + val statusLine = resp.statusLine + logger.trace("<< ${statusLine.statusCode} ${statusLine.reasonPhrase}") + for (header in resp.allHeaders) logger.trace("<< $header") + if (body != null) { + val knownContentType = contentType ?: resp.allHeaders.firstOrNull { it.name == HttpHeaders.CONTENT_TYPE }?.let { + ContentType.parse(it.value) + } + if (knownContentType?.isText() ?: true) { + for (line in body.split(Regex("[\r\n]"))) { + logger.trace("<< $line") + } + } else { + logger.trace("<< ${contentType?.toString() ?: "unknown mime type"}, ${body.length} bytes") + } + } + } + + protected abstract fun acceptHeaders(): List
+ + protected abstract fun parseResult(result: Pair): T + + fun get(url: String, vararg with: NameValuePair): T { + val req = build("GET", url, null, *with) + if (logger.isTraceEnabled) traceRequest(req) + val result = submit(req) + return parseResult(result) + } + + fun post(url: String, body: B?, vararg with: NameValuePair): T { + val req = build("POST", url, body, *with) + if (logger.isTraceEnabled) traceRequest(req, body?.toString()) + val result = submit(req) + return parseResult(result) + } + + fun patch(url: String, body: B?, vararg with: NameValuePair): T { + val req = build("PATCH", url, body, *with) + if (logger.isTraceEnabled) traceRequest(req, body?.toString()) + val result = submit(req) + return parseResult(result) + } + + fun delete(url: String, body: B?, vararg with: NameValuePair): T { + val req = build("DELETE", url, body, *with) + if (logger.isTraceEnabled) traceRequest(req, body?.toString()) + val result = submit(req) + return parseResult(result) + } +} + +object AgnosticApiClient: BaseApiClient>() { + override fun acceptHeaders() = listOf(BasicHeader(HttpHeaders.ACCEPT, "*/*")) + override fun parseResult(result: Pair) = result +} + +object JsonApiClient: BaseApiClient() { + + override fun acceptHeaders(): List
{ + return listOf(BasicHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.mimeType)) + } + + override fun parseResult(result: Pair): Json { + when (result.second.mimeType) { + ContentType.APPLICATION_JSON.mimeType, "application/vnd.api+json" -> { + return Json.parse(result.first.reader(result.second.charset ?: StandardCharsets.UTF_8))!! + } + ContentType.APPLICATION_FORM_URLENCODED.mimeType -> { + val json = Json.MutableObject() + val charset = result.second.charset ?: StandardCharsets.UTF_8 + val decoded = URLDecoder.decode(result.first.toString(charset), charset.name()) + decoded.split("&").forEach { + val keyValue = it.split(Regex("="), 2) + if (keyValue.size != 2) throw ProtocolException("expecting a key-value pair: $it") + val prev = json.put(keyValue[0], keyValue[1]) + if (prev != null) throw ClientProtocolException("Unsupported redundant values in response for key: " + keyValue[0]); + } + return json + } + else -> throw ClientProtocolException("invalid content type: ${result.second.mimeType}") + } + } +} + +// CB TODO - needs to factorize XmlUtils with server => needs a common module +// +//object XmlApiClient: BaseApiClient() { +// +// override fun acceptHeaders(): List
{ +// return listOf( +// BasicHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_XML.mimeType), +// BasicHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_SOAP_XML.mimeType) +// ) +// } +// +// override fun parseResult(result: Pair): Element { +// when (result.second.mimeType) { +// ContentType.APPLICATION_XML.mimeType, ContentType.APPLICATION_SOAP_XML.mimeType -> { +// return XmlUtils.parse(result.first.reader(result.second.charset ?: StandardCharsets.UTF_8)) ?: throw ClientProtocolException("empty XML body") +// } +// else -> throw ClientProtocolException("invalid content type: ${result.second.mimeType}") +// } +// } +//} +// +//object SoapTextApiClient: BaseApiClient() { +// +// override fun acceptHeaders() = listOf(BasicHeader(HttpHeaders.ACCEPT, "*/*")) +// +// override fun parseResult(result: Pair): Element { +// when (result.second.mimeType) { +// ContentType.TEXT_XML.mimeType -> { +// return XmlUtils.parse(result.first.reader(result.second.charset ?: StandardCharsets.UTF_8)) ?: throw ClientProtocolException("empty XML body") +// } +// else -> throw ClientProtocolException("invalid content type: ${result.second.mimeType}") +// } +// } +//} +// diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Cryptograph.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Cryptograph.kt new file mode 100644 index 0000000..2a221d0 --- /dev/null +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/Cryptograph.kt @@ -0,0 +1,29 @@ +package org.jeudego.pairgoth.util + +import java.io.Serializable + +/** + * Cryptograph - used to encrypt and decrypt strings. + * + */ +interface Cryptograph : Serializable { + /** + * init. + * @param random random string + */ + fun init(random: String) + + /** + * encrypt. + * @param str string to encrypt + * @return encrypted string + */ + fun encrypt(str: String): ByteArray + + /** + * decrypt. + * @param bytes to decrypt + * @return decrypted string + */ + fun decrypt(bytes: ByteArray): String +} diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt index 57c5ff0..657d41a 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/WebappManager.kt @@ -67,8 +67,14 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H logger.info("pairgoth server ${properties["version"]} with profile ${properties["env"]}") // publish some properties to the webapp context; for easy access from the template - context.setAttribute("env", properties["env"]) - context.setAttribute("version", properties["version"] ?: "?") + context.setAttribute("env", properties.getProperty("env") ?: "dev") + context.setAttribute("version", properties.getProperty("version") ?: "?") + properties.get("auth")?.let { + + } + properties.getProperty("oauth.providers")?.let { + context.setAttribute("oauth.providers", it.split(Regex("\\s*,\\s*"))) + } // set system user agent string to empty string System.setProperty("http.agent", "")