Skip to content

Commit c8a5268

Browse files
authored
Merge pull request #767 from openziti/fix-auth-refresh
fix: internal OIDC refresh was broken
2 parents 68321a2 + e610ea4 commit c8a5268

3 files changed

Lines changed: 102 additions & 63 deletions

File tree

ziti/src/main/kotlin/org/openziti/api/Authentication.kt

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,14 @@ import kotlinx.coroutines.Dispatchers
2323
import kotlinx.coroutines.asExecutor
2424
import kotlinx.coroutines.future.await
2525
import org.openziti.edge.ApiClient
26+
import org.openziti.edge.ApiException
2627
import org.openziti.edge.api.AuthenticationApi
2728
import org.openziti.edge.api.CurrentApiSessionApi
28-
import org.openziti.edge.model.Authenticate
29-
import org.openziti.edge.model.EnvInfo
30-
import org.openziti.edge.model.SdkInfo
29+
import org.openziti.edge.model.CurrentApiSessionDetail
3130
import org.openziti.impl.ZitiImpl
3231
import org.openziti.util.Logged
33-
import org.openziti.util.SystemInfoProvider
34-
import org.openziti.util.Version
3532
import org.openziti.util.ZitiLog
33+
import java.net.HttpURLConnection.HTTP_UNAUTHORIZED
3634
import java.net.URI
3735
import java.net.URLEncoder
3836
import java.net.http.HttpClient
@@ -41,8 +39,7 @@ import java.net.http.HttpResponse
4139
import java.nio.charset.StandardCharsets
4240
import java.security.MessageDigest
4341
import java.time.OffsetDateTime
44-
import java.util.Base64
45-
import java.util.function.Consumer
42+
import java.util.*
4643
import javax.net.ssl.SSLContext
4744
import kotlin.random.Random
4845

@@ -52,6 +49,9 @@ interface ZitiAuthenticator {
5249
BEARER, API_SESSION
5350
}
5451

52+
class AuthException(cause: Throwable? = null, msg: String = "not authorized")
53+
: RuntimeException(cause?.message ?: msg, cause)
54+
5555
data class ZitiAccessToken(
5656
val type: TokenType,
5757
val token: String,
@@ -67,25 +67,9 @@ internal fun authenticator(ep: String, ssl: SSLContext, oidc: Boolean): ZitiAuth
6767
else
6868
LegacyAuth(ep, ssl)
6969

70-
class LegacyAuth(val ep: String, val ssl: SSLContext) : ZitiAuthenticator, Logged by ZitiLog() {
71-
72-
private val auth = Authenticate().apply {
73-
val info = SystemInfoProvider().getSystemInfo()
74-
sdkInfo = SdkInfo()
75-
.type("ziti-sdk-java")
76-
.version(Version.version)
77-
.branch(Version.branch)
78-
.revision(Version.revision)
79-
.appId(ZitiImpl.appId)
80-
.appVersion(ZitiImpl.appVersion)
81-
envInfo = EnvInfo()
82-
.arch(info.arch)
83-
.os(info.os)
84-
.osRelease(info.osRelease)
85-
.osVersion(info.osVersion)
86-
configTypes = listOf("all")
87-
}
70+
class LegacyAuth(val ep: String, val ssl: SSLContext) : ZitiAuthenticator, Logged by ZitiLog("legacyAuth[$ep]") {
8871

72+
lateinit var apiSession: CurrentApiSessionDetail
8973
private val http = HttpClient.newBuilder()
9074
.sslContext(ssl)
9175
.executor(Dispatchers.IO.asExecutor())
@@ -95,31 +79,43 @@ class LegacyAuth(val ep: String, val ssl: SSLContext) : ZitiAuthenticator, Logge
9579
updateBaseUri(ep)
9680
}
9781

98-
override suspend fun login(): ZitiAuthenticator.ZitiAccessToken {
82+
override suspend fun login(): ZitiAuthenticator.ZitiAccessToken = runCatching {
83+
d { "starting authentication" }
9984
val authApi = AuthenticationApi(api)
100-
val session = authApi.authenticate("cert", auth).await()
101-
api.requestInterceptor = Consumer {
102-
req -> req.header("zt-session", session.data.token)
103-
}
104-
return ZitiAuthenticator.ZitiAccessToken(
85+
apiSession = authApi.authenticate("cert", ZitiImpl.loginInfo).await().data
86+
d { "authenticated successfully session.expiresAt[${apiSession.expiresAt}]" }
87+
ZitiAuthenticator.ZitiAccessToken(
10588
ZitiAuthenticator.TokenType.API_SESSION,
106-
session.data.token,
107-
session.data.expiresAt
89+
apiSession.token,
90+
apiSession.expiresAt
10891
)
92+
}.getOrElse { ex ->
93+
e(ex) { "failed to authenticate with error: ${ex.message}" }
94+
if (ex is ApiException && ex.code == HTTP_UNAUTHORIZED) {
95+
throw ZitiAuthenticator.AuthException(ex)
96+
}
97+
throw ex
10998
}
11099

111-
override suspend fun refresh(): ZitiAuthenticator.ZitiAccessToken {
100+
override suspend fun refresh(): ZitiAuthenticator.ZitiAccessToken = runCatching {
101+
d { "refreshing API session" }
112102
val currentApiSessionApi = CurrentApiSessionApi(api)
113-
val session = currentApiSessionApi.currentAPISession.await()
114-
return ZitiAuthenticator.ZitiAccessToken(
103+
apiSession = currentApiSessionApi.getCurrentAPISession(mapOf("zt-session" to apiSession.token)).await().data
104+
ZitiAuthenticator.ZitiAccessToken(
115105
ZitiAuthenticator.TokenType.API_SESSION,
116-
session.data.token,
117-
session.data.expiresAt
106+
apiSession.token,
107+
apiSession.expiresAt
118108
)
109+
}.getOrElse { ex ->
110+
e(ex) { "failed to authenticate with error: ${ex.message}" }
111+
if (ex is ApiException && ex.code == HTTP_UNAUTHORIZED) {
112+
throw ZitiAuthenticator.AuthException(ex)
113+
}
114+
throw ex
119115
}
120116
}
121117

122-
class InternalOIDC(val ep: String, ssl: SSLContext): ZitiAuthenticator, Logged by ZitiLog() {
118+
class InternalOIDC(val ep: String, ssl: SSLContext): ZitiAuthenticator, Logged by ZitiLog("oidc[$ep]") {
123119

124120
companion object {
125121
const val CLIENT_ID = "openziti"
@@ -128,6 +124,7 @@ class InternalOIDC(val ep: String, ssl: SSLContext): ZitiAuthenticator, Logged b
128124
const val DISCOVERY = "/oidc/.well-known/openid-configuration"
129125
const val TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
130126
val json: ObjectMapper = ObjectMapper().registerModule(kotlinModule())
127+
val CLIENT_ID_BASIC_AUTH = "Basic ${Encoder.encodeToString("$CLIENT_ID:".toByteArray())}"
131128
}
132129

133130

@@ -147,6 +144,7 @@ class InternalOIDC(val ep: String, ssl: SSLContext): ZitiAuthenticator, Logged b
147144
}
148145

149146
private suspend fun startAuth(authEndpoint: String, challenge: String, state: String): URI {
147+
d { "starting auth" }
150148
val form = mapOf(
151149
"response_type" to "code",
152150
"client_id" to CLIENT_ID,
@@ -219,6 +217,7 @@ class InternalOIDC(val ep: String, ssl: SSLContext): ZitiAuthenticator, Logged b
219217
.POST(HttpRequest.BodyPublishers.ofString(body)).build()
220218

221219
val tokenResp = http.sendAsync(req, HttpResponse.BodyHandlers.ofString()).await()
220+
tokenResp.sslSession().get().peerCertificates[0].publicKey
222221
return json.readTree(tokenResp.body())
223222
}
224223

@@ -242,36 +241,40 @@ class InternalOIDC(val ep: String, ssl: SSLContext): ZitiAuthenticator, Logged b
242241
require(st == state){ "OIDC state mismatch" }
243242

244243
tokens = getTokens(URI.create(tokenEndpoint), code, codeVerifier)
245-
d{ "OIDC tokens: $tokens" }
246244

247245
val accessToken = tokens["access_token"]?.textValue()
248246
?: throw Exception("Missing access token in OIDC response")
247+
249248
val exp = OffsetDateTime.now().plusSeconds(tokens["expires_in"]?.longValue() ?: 600)
250249
return ZitiAuthenticator.ZitiAccessToken(ZitiAuthenticator.TokenType.BEARER, accessToken, exp)
251250
}
252251

253252
override suspend fun refresh(): ZitiAuthenticator.ZitiAccessToken {
253+
d { "starting refresh" }
254254
val refreshToken = tokens.get("refresh_token")?.textValue()
255255

256-
if (refreshToken == null) return login()
256+
refreshToken ?: throw ZitiAuthenticator.AuthException(msg = "no refresh_token")
257257

258258
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,
259+
"client_id" to CLIENT_ID,
260+
"grant_type" to "refresh_token",
261+
"refresh_token" to refreshToken,
263262
)
264263

265264
val req = HttpRequest.newBuilder()
266265
.uri(config["token_endpoint"]?.textValue()?.let { URI.create(it) })
267-
.header("Accept", "application/x-www-form-urlencoded")
266+
.header("Content-Type", "application/x-www-form-urlencoded")
267+
.header("Authorization", CLIENT_ID_BASIC_AUTH)
268268
.POST(HttpRequest.BodyPublishers.ofString(formatForm(form)))
269269
.build()
270270

271271
val resp = http.sendAsync(req, HttpResponse.BodyHandlers.ofString()).await()
272272

273+
if (resp.statusCode() == HTTP_UNAUTHORIZED) {
274+
throw ZitiAuthenticator.AuthException()
275+
}
273276
if (resp.statusCode() != 200) {
274-
return login()
277+
throw Exception("unexpected refresh response $resp")
275278
}
276279

277280
tokens = json.readTree(resp.body())

ziti/src/main/kotlin/org/openziti/impl/ZitiContextImpl.kt

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ package org.openziti.impl
1818

1919
import kotlinx.coroutines.*
2020
import kotlinx.coroutines.channels.BufferOverflow
21+
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
2122
import kotlinx.coroutines.flow.*
2223
import kotlinx.coroutines.flow.Flow
2324
import kotlinx.coroutines.future.asCompletableFuture
25+
import kotlinx.coroutines.selects.onTimeout
2426
import kotlinx.coroutines.selects.select
2527
import org.openziti.*
2628
import org.openziti.Identity
@@ -50,8 +52,7 @@ import kotlin.coroutines.CoroutineContext
5052
import kotlin.coroutines.cancellation.CancellationException
5153
import kotlin.properties.Delegates
5254
import kotlin.random.Random
53-
import kotlin.time.DurationUnit
54-
import kotlin.time.toDuration
55+
import kotlin.time.Duration.Companion.seconds
5556

5657
/**
5758
* Object maintaining current Ziti session.
@@ -75,7 +76,7 @@ internal class ZitiContextImpl(internal val id: Identity, enabled: Boolean) : Zi
7576
override val coroutineContext: CoroutineContext
7677
get() = Dispatchers.IO + supervisor
7778

78-
79+
private val forceAuthRefresh = kotlinx.coroutines.channels.Channel<Any>(CONFLATED)
7980
// active controller
8081
private val ctrl = MutableStateFlow<Controller?>(null)
8182
private val controller: Controller
@@ -245,6 +246,7 @@ internal class ZitiContextImpl(internal val id: Identity, enabled: Boolean) : Zi
245246
ctrl.value = c
246247

247248
val oidc = controller.capabilities.contains(Capabilities.OIDC_AUTH)
249+
val ha = controller.capabilities.contains(Capabilities.HA_CONTROLLER)
248250
authenticator = authenticator(controller.endpoint, id.sslContext(), oidc)
249251

250252
val currentSession = runCatching {
@@ -254,7 +256,6 @@ internal class ZitiContextImpl(internal val id: Identity, enabled: Boolean) : Zi
254256
null
255257
}.getOrNull()
256258

257-
258259
val session = if (currentSession != null)
259260
currentSession
260261
else {
@@ -282,13 +283,16 @@ internal class ZitiContextImpl(internal val id: Identity, enabled: Boolean) : Zi
282283

283284
if (!success) continue
284285
}
285-
val ctrls = controller.listControllers().mapNotNull {
286-
it.apiAddresses?.get("edge-client")?.first { addr -> addr.version == "v1" }?.url
287-
}.toSet()
288286

289-
if (ctrls != ctrlEndpoints) {
290-
ctrlEndpoints.clear()
291-
ctrlEndpoints.addAll(ctrls)
287+
if (ha) {
288+
val ctrls = controller.listControllers().mapNotNull {
289+
it.apiAddresses?.get("edge-client")?.first { addr -> addr.version == "v1" }?.url
290+
}.toSet()
291+
292+
if (ctrls != ctrlEndpoints) {
293+
ctrlEndpoints.clear()
294+
ctrlEndpoints.addAll(ctrls)
295+
}
292296
}
293297

294298
val services = controller.getServices().toList()
@@ -368,7 +372,8 @@ internal class ZitiContextImpl(internal val id: Identity, enabled: Boolean) : Zi
368372
}
369373
}
370374

371-
private fun maintainApiSession(authenticator: ZitiAuthenticator) = async {
375+
@OptIn(ExperimentalCoroutinesApi::class)
376+
private fun maintainApiSession(authenticator: ZitiAuthenticator) = async(CoroutineName("maintainApiSession")) {
372377
var retries = 5
373378
while(true) {
374379
val token = accessToken.value ?: break
@@ -380,18 +385,26 @@ internal class ZitiContextImpl(internal val id: Identity, enabled: Boolean) : Zi
380385
break
381386
}
382387

383-
val delay = (token.expiration.toEpochSecond() - now.toEpochSecond()) * 2 / 3
388+
val delaySeconds = token.expiration.toEpochSecond() - now.toEpochSecond()
389+
val delay = delaySeconds / 2 + Random.nextLong(delaySeconds / 6)
384390

385-
d("[${name()}] sleeping for $delay seconds")
386-
delay(delay.toDuration(DurationUnit.SECONDS))
387-
d("[${name()}] refreshing access token")
391+
d {"[${name()}] ssleeping for $delay seconds" }
392+
val reason = select {
393+
onTimeout(delay.seconds) { "delay" }
394+
forceAuthRefresh.onReceive { "forced refresh" }
395+
}
396+
d("[${name()}] refreshing access token [$reason]")
388397
runCatching {
389398
val newToken = authenticator.refresh()
390399
accessToken.value = newToken
391400
retries = 5
392401
}.onFailure {
393-
retries--
394402
w{ "failed to refresh access token: ${it.message}" }
403+
if (it is ZitiAuthenticator.AuthException) {
404+
accessToken.value = null
405+
continue
406+
}
407+
retries--
395408
}
396409
}
397410
}

ziti/src/main/kotlin/org/openziti/impl/ZitiImpl.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@ import kotlinx.coroutines.launch
2121
import kotlinx.coroutines.runBlocking
2222
import org.openziti.*
2323
import org.openziti.api.Service
24+
import org.openziti.edge.model.Authenticate
25+
import org.openziti.edge.model.EnvInfo
26+
import org.openziti.edge.model.SdkInfo
2427
import org.openziti.identity.KeyStoreIdentity
2528
import org.openziti.identity.findIdentityAlias
2629
import org.openziti.identity.loadKeystore
2730
import org.openziti.net.dns.ZitiDNSManager
2831
import org.openziti.net.internal.Sockets
2932
import org.openziti.util.Logged
33+
import org.openziti.util.SystemInfoProvider
3034
import org.openziti.util.Version
3135
import org.openziti.util.ZitiLog
3236
import java.io.File
@@ -43,6 +47,25 @@ internal object ZitiImpl : Logged by ZitiLog() {
4347

4448
internal val serviceEvents = MutableSharedFlow<Pair<ZitiContext, ZitiContext.ServiceEvent>>()
4549

50+
internal val loginInfo by lazy {
51+
Authenticate().apply {
52+
val info = SystemInfoProvider().getSystemInfo()
53+
sdkInfo = SdkInfo()
54+
.type("ziti-sdk-java")
55+
.version(Version.version)
56+
.branch(Version.branch)
57+
.revision(Version.revision)
58+
.appId(appId)
59+
.appVersion(appVersion)
60+
envInfo = EnvInfo()
61+
.arch(info.arch)
62+
.os(info.os)
63+
.osRelease(info.osRelease)
64+
.osVersion(info.osVersion)
65+
configTypes = listOf("all")
66+
}
67+
}
68+
4669
internal val onAndroid: Boolean by lazy {
4770
try {
4871
Class.forName("android.util.Log")

0 commit comments

Comments
 (0)