@@ -359,11 +359,12 @@ class AirbyteKeycloakClient(
359359 }
360360
361361 /* *
362- * Validates a token by extracting its realm and calling the userinfo endpoint.
362+ * Validates an access token by extracting its realm and calling the userinfo endpoint.
363+ * Used by KeycloakTokenValidator for general application authentication.
363364 * @param token The JWT token to validate
364- * @throws InvalidTokenException if the token has no realm claim
365+ * @throws InvalidTokenException if the token does not contain a realm claim
365366 * @throws TokenExpiredException if the token is expired or invalid
366- * @throws MalformedTokenResponseException if the response is malformed
367+ * @throws MalformedTokenResponseException if the response is malformed or missing required claims
367368 * @throws KeycloakServiceException if there's an error communicating with Keycloak
368369 */
369370 @Throws(InvalidTokenException ::class , TokenExpiredException ::class , MalformedTokenResponseException ::class , KeycloakServiceException ::class )
@@ -375,7 +376,7 @@ class AirbyteKeycloakClient(
375376 }
376377
377378 /* *
378- * Validates a token against a specific Keycloak realm.
379+ * Validates a token against a specific Keycloak realm by calling the userinfo endpoint .
379380 * @param token The JWT token to validate
380381 * @param realm The Keycloak realm to validate against
381382 * @throws TokenExpiredException if the token is expired or invalid (401 response)
@@ -449,6 +450,143 @@ class AirbyteKeycloakClient(
449450 null
450451 }
451452
453+ /* *
454+ * Exchanges an OAuth authorization code for an access token by making a server-side request to
455+ * Keycloak's token endpoint for the provided realm.
456+ * @param realm The Keycloak realm
457+ * @param authorizationCode The authorization code from the OAuth callback
458+ * @param codeVerifier The PKCE code verifier
459+ * @param redirectUri The redirect URI used during authorization
460+ * @return The validated access token
461+ */
462+ fun exchangeAuthorizationCode (
463+ realm : String ,
464+ authorizationCode : String ,
465+ codeVerifier : String ,
466+ redirectUri : String ,
467+ ): String {
468+ val tokenEndpoint = keycloakConfiguration.getKeycloakTokenEndpointForRealm(realm)
469+ logger.debug { " Exchanging authorization code with token endpoint: $tokenEndpoint " }
470+
471+ val formBody =
472+ okhttp3.FormBody
473+ .Builder ()
474+ .add(" grant_type" , " authorization_code" )
475+ .add(" code" , authorizationCode)
476+ .add(" redirect_uri" , redirectUri)
477+ .add(" client_id" , AIRBYTE_WEBAPP_CLIENT_ID )
478+ .add(" code_verifier" , codeVerifier)
479+ .build()
480+
481+ val request =
482+ Request
483+ .Builder ()
484+ .addHeader(HttpHeaders .CONTENT_TYPE , " application/x-www-form-urlencoded" )
485+ .url(tokenEndpoint)
486+ .post(formBody)
487+ .build()
488+
489+ try {
490+ httpClient.newCall(request).execute().use { response ->
491+ val responseBody = response.body?.string()
492+
493+ if (! response.isSuccessful) {
494+ logger.error { " Token exchange failed with status ${response.code} : $responseBody " }
495+ throw InvalidTokenException (" Token exchange failed with status ${response.code} : $responseBody " )
496+ }
497+
498+ if (responseBody.isNullOrEmpty()) {
499+ throw MalformedTokenResponseException (" Empty response from token endpoint" )
500+ }
501+
502+ val tokenResponse = objectMapper.readTree(responseBody)
503+ val accessToken = tokenResponse.path(" access_token" ).asText()
504+
505+ if (accessToken.isNullOrEmpty()) {
506+ throw MalformedTokenResponseException (" No access_token in token response" )
507+ }
508+
509+ logger.debug { " Successfully exchanged authorization code for access token" }
510+ return accessToken
511+ }
512+ } catch (e: TokenValidationException ) {
513+ throw e
514+ } catch (e: Exception ) {
515+ logger.error(e) { " Failed to exchange authorization code" }
516+ throw KeycloakServiceException (" Failed to exchange authorization code" , e)
517+ }
518+ }
519+
520+ /* *
521+ * Retrieves user info from the userinfo endpoint using an access token.
522+ * @param token The access token
523+ * @param realm The Keycloak realm
524+ * @return Map of user info claims (including email, sub, etc.)
525+ * @throws TokenExpiredException if the token is expired or invalid
526+ * @throws InvalidTokenException if the token validation failed
527+ * @throws KeycloakServiceException if there's an error communicating with Keycloak
528+ */
529+ fun getUserInfo (
530+ token : String ,
531+ realm : String ,
532+ ): Map <String , Any > {
533+ val userInfoEndpoint = keycloakConfiguration.getKeycloakUserInfoEndpointForRealm(realm)
534+ logger.debug { " Fetching user info from Keycloak userinfo endpoint: $userInfoEndpoint " }
535+
536+ val request =
537+ Request
538+ .Builder ()
539+ .addHeader(HttpHeaders .CONTENT_TYPE , " application/json" )
540+ .addHeader(HttpHeaders .AUTHORIZATION , " Bearer $token " )
541+ .url(userInfoEndpoint)
542+ .get()
543+ .build()
544+
545+ try {
546+ httpClient.newCall(request).execute().use { response ->
547+ when {
548+ response.code == 401 -> {
549+ logger.debug { " Token is invalid or expired (401 response)" }
550+ throw TokenExpiredException (" Token is invalid or expired" )
551+ }
552+ ! response.isSuccessful -> {
553+ logger.debug { " Non-200 response from userinfo endpoint: ${response.code} " }
554+ throw InvalidTokenException (" Failed to get user info with status ${response.code} " )
555+ }
556+ else -> {
557+ val responseBody = response.body?.string()
558+ if (responseBody.isNullOrEmpty()) {
559+ logger.debug { " Received null or empty userinfo response" }
560+ throw MalformedTokenResponseException (" Empty response from Keycloak userinfo endpoint" )
561+ }
562+ logger.debug { " Received userinfo response (${responseBody.length} bytes)" }
563+
564+ val userInfo = objectMapper.readTree(responseBody)
565+ val result = mutableMapOf<String , Any >()
566+
567+ userInfo.fields().forEach { (key, value) ->
568+ result[key] =
569+ when {
570+ value.isTextual -> value.asText()
571+ value.isNumber -> value.asLong()
572+ value.isBoolean -> value.asBoolean()
573+ else -> value.toString()
574+ }
575+ }
576+
577+ logger.debug { " Parsed user info with claims: ${result.keys} " }
578+ return result
579+ }
580+ }
581+ }
582+ } catch (e: TokenValidationException ) {
583+ throw e
584+ } catch (e: Exception ) {
585+ logger.error(e) { " Failed to get user info" }
586+ throw KeycloakServiceException (" Failed to communicate with Keycloak" , e)
587+ }
588+ }
589+
452590 /* *
453591 * Validates the userinfo response from Keycloak.
454592 * @param responseBody The JSON response from the userinfo endpoint
0 commit comments