Skip to content

Commit 4edea0b

Browse files
committed
chore: move SSO config validation code exchange to backend (#18043)
1 parent 3975901 commit 4edea0b

File tree

10 files changed

+620
-140
lines changed

10 files changed

+620
-140
lines changed

airbyte-api/server-api/src/main/openapi/config.yaml

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7562,23 +7562,28 @@ paths:
75627562
description: "SSO config successfully activated"
75637563
"422":
75647564
$ref: "#/components/responses/InvalidInputResponse"
7565-
/v1/sso_config/validate_token:
7565+
7566+
/v1/sso_config/exchange_code:
75667567
post:
7567-
summary: Validate an access token against an organization's SSO realm
7568+
summary: Exchange an OAuth authorization code for tokens and validate the SSO configuration
75687569
tags:
75697570
- sso_config
7570-
operationId: validateSsoToken
7571+
operationId: exchangeSsoAuthCode
75717572
requestBody:
75727573
content:
75737574
application/json:
75747575
schema:
7575-
$ref: "#/components/schemas/ValidateSSOTokenRequestBody"
7576+
$ref: "#/components/schemas/ExchangeSSOAuthCodeRequestBody"
75767577
required: true
75777578
responses:
7578-
"204":
7579-
description: Token is valid
7580-
"401":
7581-
description: Token is invalid or validation failed
7579+
"200":
7580+
description: Authorization code successfully exchanged and validated
7581+
content:
7582+
application/json:
7583+
schema:
7584+
$ref: "#/components/schemas/ExchangeSSOAuthCodeResponse"
7585+
"400":
7586+
description: Invalid authorization code or exchange failed
75827587
"422":
75837588
$ref: "#/components/responses/InvalidInputResponse"
75847589

@@ -21718,17 +21723,36 @@ components:
2171821723
type: string
2171921724
clientSecret:
2172021725
type: string
21721-
ValidateSSOTokenRequestBody:
21726+
21727+
ExchangeSSOAuthCodeRequestBody:
2172221728
type: object
2172321729
required:
2172421730
- organizationId
21725-
- accessToken
21731+
- authorizationCode
21732+
- codeVerifier
21733+
- redirectUri
2172621734
properties:
2172721735
organizationId:
2172821736
type: string
2172921737
format: uuid
21738+
authorizationCode:
21739+
type: string
21740+
description: The OAuth authorization code returned from the SSO provider
21741+
codeVerifier:
21742+
type: string
21743+
description: The PKCE code verifier used during the authorization request
21744+
redirectUri:
21745+
type: string
21746+
description: The redirect URI used during the authorization request
21747+
21748+
ExchangeSSOAuthCodeResponse:
21749+
type: object
21750+
required:
21751+
- accessToken
21752+
properties:
2173021753
accessToken:
2173121754
type: string
21755+
description: The OAuth access token obtained from the authorization code exchange
2173221756

2173321757
ActivateSSOConfigRequestBody:
2173421758
type: object

airbyte-commons-micronaut/src/main/kotlin/io/airbyte/micronaut/runtime/AirbyteConfig.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,13 @@ data class AirbyteKeycloakConfig(
636636
return "$protocol://$hostWithoutTrailingSlash$basePathWithLeadingSlash/realms/$realm$keycloakUserInfoURI"
637637
}
638638

639+
fun getKeycloakTokenEndpointForRealm(realm: String): String {
640+
val hostWithoutTrailingSlash = if (host.endsWith("/")) host.substring(0, host.length - 1) else host
641+
val basePathWithLeadingSlash = if (basePath.startsWith("/")) basePath else "/$basePath"
642+
val keycloakTokenURI = "/protocol/openid-connect/token"
643+
return "$protocol://$hostWithoutTrailingSlash$basePathWithLeadingSlash/realms/$realm$keycloakTokenURI"
644+
}
645+
639646
fun getServerUrl(): String = "$protocol://$host$basePath"
640647
}
641648

airbyte-data/src/main/kotlin/io/airbyte/data/services/impls/keycloak/AirbyteKeycloakClient.kt

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)