Skip to content

Commit a5c0256

Browse files
authored
Merge pull request #686 from openziti/ha-ctrl
HA: use and authenticate with multiple controllers
2 parents 165c646 + 9ffbeb0 commit a5c0256

File tree

8 files changed

+493
-100
lines changed

8 files changed

+493
-100
lines changed

ziti/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ tasks.register("start-quickstart") {
199199
}
200200
}
201201
}
202-
dependsOn("buildZiti")
202+
dependsOn("integrationTestClasses", "buildZiti")
203203
}
204204

205205
tasks.register("stop-quickstart") {

ziti/src/integrationTest/kotlin/org/openziti/api/ControllerTests.kt

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,11 @@ import kotlinx.coroutines.test.runTest
2020
import org.junit.jupiter.api.Assertions.assertEquals
2121
import org.junit.jupiter.api.BeforeEach
2222
import org.junit.jupiter.api.Test
23-
import org.junit.jupiter.api.assertThrows
24-
import org.openziti.Enrollment
2523
import org.openziti.IdentityConfig
2624
import org.openziti.Ziti
2725
import org.openziti.integ.BaseTest
28-
import org.openziti.integ.ManagementHelper.createIdentity
2926
import org.openziti.integ.ManagementHelper.getIdentity
3027
import org.openziti.util.Version
31-
import java.net.ConnectException
3228

3329

3430
class ControllerTests: BaseTest() {
@@ -61,15 +57,4 @@ class ControllerTests: BaseTest() {
6157
assertEquals(info.displayName, idInfo.appId)
6258
}
6359

64-
@Test
65-
fun testSwitch() = runTest {
66-
val ctrl = Controller(cfg.controller, cfg.sslContext())
67-
val session = ctrl.login()
68-
val controllers = ctrl.listControllers()
69-
println(controllers)
70-
assertThrows<ConnectException> {
71-
ctrl.switchEndpoint("https://localhost:6666")
72-
}
73-
assertEquals(cfg.controller, ctrl.endpoint)
74-
}
7560
}

ziti/src/main/kotlin/org/openziti/ZitiAddress.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ sealed class ZitiAddress: SocketAddress() {
4141
val service: String, val identity: String? = null, val useEdgeId: Boolean = false)
4242
: ZitiAddress()
4343

44-
data class Session internal constructor(
44+
class Session internal constructor(
4545
internal val id: String,
4646
val service: String,
4747
val callerId: String?,
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/*
2+
* Copyright (c) 2018-2025 NetFoundry Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.openziti.api
18+
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.asExecutor
21+
import kotlinx.coroutines.future.await
22+
import kotlinx.serialization.json.Json
23+
import kotlinx.serialization.json.JsonObject
24+
import kotlinx.serialization.json.jsonObject
25+
import kotlinx.serialization.json.jsonPrimitive
26+
import org.openziti.edge.ApiClient
27+
import org.openziti.edge.api.AuthenticationApi
28+
import org.openziti.edge.api.CurrentApiSessionApi
29+
import org.openziti.edge.model.Authenticate
30+
import org.openziti.edge.model.EnvInfo
31+
import org.openziti.edge.model.SdkInfo
32+
import org.openziti.impl.ZitiImpl
33+
import org.openziti.util.Logged
34+
import org.openziti.util.SystemInfoProvider
35+
import org.openziti.util.Version
36+
import org.openziti.util.ZitiLog
37+
import java.net.URI
38+
import java.net.URLEncoder
39+
import java.net.http.HttpClient
40+
import java.net.http.HttpRequest
41+
import java.net.http.HttpResponse
42+
import java.nio.charset.StandardCharsets
43+
import java.security.MessageDigest
44+
import java.time.OffsetDateTime
45+
import java.util.Base64
46+
import java.util.function.Consumer
47+
import javax.net.ssl.SSLContext
48+
import kotlin.random.Random
49+
50+
51+
interface ZitiAuthenticator {
52+
enum class TokenType {
53+
BEARER, API_SESSION
54+
}
55+
56+
data class ZitiAccessToken(
57+
val type: TokenType,
58+
val token: String,
59+
val expiration: OffsetDateTime
60+
)
61+
suspend fun login(): ZitiAccessToken
62+
suspend fun refresh(): ZitiAccessToken
63+
}
64+
65+
internal fun authenticator(ep: String, ssl: SSLContext, oidc: Boolean): ZitiAuthenticator =
66+
if (oidc)
67+
InternalOIDC(ep, ssl)
68+
else
69+
LegacyAuth(ep, ssl)
70+
71+
class LegacyAuth(val ep: String, val ssl: SSLContext) : ZitiAuthenticator, Logged by ZitiLog() {
72+
73+
private val auth = Authenticate().apply {
74+
val info = SystemInfoProvider().getSystemInfo()
75+
sdkInfo = SdkInfo()
76+
.type("ziti-sdk-java")
77+
.version(Version.version)
78+
.branch(Version.branch)
79+
.revision(Version.revision)
80+
.appId(ZitiImpl.appId)
81+
.appVersion(ZitiImpl.appVersion)
82+
envInfo = EnvInfo()
83+
.arch(info.arch)
84+
.os(info.os)
85+
.osRelease(info.osRelease)
86+
.osVersion(info.osVersion)
87+
configTypes = listOf("all")
88+
}
89+
90+
private val http = HttpClient.newBuilder()
91+
.sslContext(ssl)
92+
.executor(Dispatchers.IO.asExecutor())
93+
94+
private val api = ApiClient().apply {
95+
setHttpClientBuilder(http)
96+
updateBaseUri(ep)
97+
}
98+
99+
override suspend fun login(): ZitiAuthenticator.ZitiAccessToken {
100+
val authApi = AuthenticationApi(api)
101+
val session = authApi.authenticate("cert", auth).await()
102+
api.requestInterceptor = Consumer {
103+
req -> req.header("zt-session", session.data.token)
104+
}
105+
return ZitiAuthenticator.ZitiAccessToken(
106+
ZitiAuthenticator.TokenType.API_SESSION,
107+
session.data.token,
108+
session.data.expiresAt
109+
)
110+
}
111+
112+
override suspend fun refresh(): ZitiAuthenticator.ZitiAccessToken {
113+
val currentApiSessionApi = CurrentApiSessionApi(api)
114+
val session = currentApiSessionApi.currentAPISession.await()
115+
return ZitiAuthenticator.ZitiAccessToken(
116+
ZitiAuthenticator.TokenType.API_SESSION,
117+
session.data.token,
118+
session.data.expiresAt
119+
)
120+
}
121+
}
122+
123+
class InternalOIDC(val ep: String, ssl: SSLContext): ZitiAuthenticator, Logged by ZitiLog() {
124+
125+
companion object {
126+
const val CLIENT_ID = "openziti"
127+
const val internalRedirect = "http://localhost:8080/auth/callback"
128+
val Encoder: Base64.Encoder = Base64.getUrlEncoder().withoutPadding()
129+
const val DISCOVERY = "/oidc/.well-known/openid-configuration"
130+
const val TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
131+
}
132+
133+
134+
private val http: HttpClient = HttpClient.newBuilder()
135+
.sslContext(ssl)
136+
.followRedirects(HttpClient.Redirect.NEVER)
137+
.executor(Dispatchers.IO.asExecutor())
138+
.build()
139+
lateinit var tokens: JsonObject
140+
141+
private val config by lazy {
142+
loadConfig()
143+
}
144+
145+
private fun formatForm(params: Map<String, String>): String = params.entries.joinToString("&") {
146+
"${it.key}=${URLEncoder.encode(it.value, StandardCharsets.UTF_8)}"
147+
}
148+
149+
private suspend fun startAuth(authEndpoint: String, challenge: String, state: String): URI {
150+
val form = mapOf(
151+
"response_type" to "code",
152+
"client_id" to CLIENT_ID,
153+
"redirect_uri" to internalRedirect,
154+
"scope" to "openid offline_access",
155+
"state" to state,
156+
"code_challenge" to challenge,
157+
"code_challenge_method" to "S256"
158+
)
159+
160+
val body = formatForm(form)
161+
i("body: $body")
162+
val uri = URI.create(authEndpoint)
163+
val req = HttpRequest.newBuilder(uri)
164+
.header("Accept", "application/json")
165+
.header("Content-Type", "application/x-www-form-urlencoded")
166+
.POST(HttpRequest.BodyPublishers.ofString(body))
167+
.build()
168+
169+
i{"sending auth request $req"}
170+
val resp = http.sendAsync(req, HttpResponse.BodyHandlers.ofString()).await()
171+
172+
if (resp.statusCode() / 100 != 3 && resp.headers().firstValue("Location").isEmpty) {
173+
throw Exception("Unexpected login auth response: ${resp.statusCode()} ${resp.body()}")
174+
}
175+
176+
val path = resp.headers().firstValue("Location").get()
177+
return URI.create(ep).resolve(path)
178+
}
179+
180+
private suspend fun login(loginURI: URI): URI {
181+
val req = HttpRequest.newBuilder()
182+
.header("Accept", "application/json")
183+
.header("Content-Type", "application/x-www-form-urlencoded")
184+
.uri(loginURI).POST(HttpRequest.BodyPublishers.noBody()).build()
185+
186+
val resp = http.sendAsync(req, HttpResponse.BodyHandlers.ofString()).await()
187+
return URI.create(resp.headers().firstValue("Location").get())
188+
}
189+
190+
private suspend fun getCode(codeURI: URI): Pair<String, String> {
191+
val req = HttpRequest.newBuilder()
192+
.header("Accept", "application/json")
193+
.uri(codeURI).GET().build()
194+
195+
val resp = http.sendAsync(req, HttpResponse.BodyHandlers.ofString()).await()
196+
197+
val redirectUri = resp.headers().firstValue("Location").get().run { URI.create(this) }
198+
199+
val query = redirectUri.query.split("&").associate {
200+
val (k, v) = it.split("=", limit = 2)
201+
k to v
202+
}
203+
return query["code"]!! to query["state"]!!
204+
}
205+
206+
private suspend fun getTokens(ep: URI, code: String, codeVerifier: String): JsonObject {
207+
val body = formatForm(
208+
mapOf(
209+
"grant_type" to "authorization_code",
210+
"code" to code,
211+
"client_id" to CLIENT_ID,
212+
"redirect_uri" to internalRedirect,
213+
"code_verifier" to codeVerifier
214+
)
215+
)
216+
val req = HttpRequest.newBuilder().header("Accept", "application/json")
217+
.header("Content-Type", "application/x-www-form-urlencoded")
218+
.uri(ep)
219+
.POST(HttpRequest.BodyPublishers.ofString(body)).build()
220+
221+
val tokenResp = http.sendAsync(req, HttpResponse.BodyHandlers.ofString()).await()
222+
return Json.parseToJsonElement(tokenResp.body()).jsonObject
223+
}
224+
225+
override suspend fun login(): ZitiAuthenticator.ZitiAccessToken {
226+
val codeVerifier = Encoder.encodeToString(Random.Default.nextBytes(40))
227+
val challenge = Encoder.encodeToString(
228+
MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray())
229+
)
230+
val state = Encoder.encodeToString(Random.Default.nextBytes(30))
231+
232+
233+
val authEndpoint = config["authorization_endpoint"]?.jsonPrimitive?.content
234+
?: throw Exception("Missing authorization endpoint in OIDC config")
235+
val tokenEndpoint = config["token_endpoint"]?.jsonPrimitive?.content
236+
?: throw Exception("Missing token endpoint in OIDC config")
237+
238+
val loginURI = startAuth(authEndpoint, challenge, state)
239+
val codeURI = login(loginURI)
240+
val (code, st) = getCode(codeURI)
241+
242+
require(st == state){ "OIDC state mismatch" }
243+
244+
tokens = getTokens(URI.create(tokenEndpoint), code, codeVerifier)
245+
d{ "OIDC tokens: $tokens" }
246+
247+
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
248+
?: throw Exception("Missing access token in OIDC response")
249+
val exp = OffsetDateTime.now().plusSeconds(tokens["expires_in"]?.jsonPrimitive?.content?.toLong() ?: 600)
250+
return ZitiAuthenticator.ZitiAccessToken(ZitiAuthenticator.TokenType.BEARER, accessToken, exp)
251+
}
252+
253+
override suspend fun refresh(): ZitiAuthenticator.ZitiAccessToken {
254+
val refreshToken = tokens.get("refresh_token")?.jsonPrimitive?.content
255+
256+
if (refreshToken == null) return login()
257+
258+
val form = mapOf(
259+
"grant_type" to TOKEN_EXCHANGE_GRANT,
260+
"requested_token_type" to "urn:ietf:params:oauth:token-type:refresh_token",
261+
"subject_token_type" to "urn:ietf:params:oauth:token-type:refresh_token",
262+
"subject_token" to refreshToken,
263+
)
264+
265+
val req = HttpRequest.newBuilder()
266+
.uri(config["token_endpoint"]?.jsonPrimitive?.content?.let { URI.create(it) })
267+
.header("Accept", "application/x-www-form-urlencoded")
268+
.POST(HttpRequest.BodyPublishers.ofString(formatForm(form)))
269+
.build()
270+
271+
val resp = http.sendAsync(req, HttpResponse.BodyHandlers.ofString()).await()
272+
273+
if (resp.statusCode() != 200) {
274+
return login()
275+
}
276+
277+
tokens = Json.parseToJsonElement(resp.body()).jsonObject
278+
val accessToken = tokens["access_token"]?.jsonPrimitive?.content
279+
?: throw Exception("Missing access token in OIDC response")
280+
val exp = OffsetDateTime.now().plusSeconds(tokens["expires_in"]?.jsonPrimitive?.content?.toLong() ?: 600)
281+
return ZitiAuthenticator.ZitiAccessToken(ZitiAuthenticator.TokenType.BEARER, accessToken, exp)
282+
}
283+
284+
private fun loadConfig(): JsonObject {
285+
val url = URI.create(ep).resolve(DISCOVERY)
286+
287+
val request = HttpRequest.newBuilder(url)
288+
.GET()
289+
.build()
290+
291+
val response = http.send(request, HttpResponse.BodyHandlers.ofString())
292+
if (response.statusCode() != 200) {
293+
throw Exception("Failed to get OIDC config: ${response.statusCode()}")
294+
}
295+
296+
i("OIDC config response: ${response.body()}")
297+
return Json.parseToJsonElement(response.body()).jsonObject
298+
}
299+
}

0 commit comments

Comments
 (0)