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