diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api index 973a85065a4..8f6693e6a0c 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/api/ktor-server-auth-oidc.api @@ -66,6 +66,7 @@ public final class io/ktor/server/auth/oidc/OidcMetadataRefreshFailure { public final class io/ktor/server/auth/oidc/OidcOAuthConfig { public field clientId Ljava/lang/String; public field clientSecret Ljava/lang/String; + public final fun disablePkce ()V public final fun getClientId ()Ljava/lang/String; public final fun getClientSecret ()Ljava/lang/String; public final fun getFetchUserInfo ()Z diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/Oidc.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/Oidc.kt index 6f087adfc49..5f7a7d3b8e1 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/Oidc.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/Oidc.kt @@ -33,7 +33,7 @@ private val ProviderNameRegex = Regex("[a-z0-9]+(?:-[a-z0-9]+)*") * including login and redirect routes. * Registered internally as `"$name-oauth"` and used only for the auto-registered routes. * - * This plugin implements the Authorization Code Flow (RFC 6749 §4.1, OIDC Core §3.1) and resource-server + * This plugin implements the Authorization Code Flow with PKCE (RFC 6749 §4.1, OIDC Core §3.1) and resource-server * Bearer / RFC 7662 introspection. Implicit and Hybrid flows are not supported. * * Provider metadata is fetched automatically from the issuer's discovery document diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthorizationTransaction.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthorizationTransaction.kt index 1576b817fcb..404db00ae97 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthorizationTransaction.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthorizationTransaction.kt @@ -5,10 +5,15 @@ package io.ktor.server.auth.oidc import io.ktor.http.* +import io.ktor.http.auth.* import io.ktor.server.application.* import io.ktor.server.plugins.origin import io.ktor.server.sessions.SameSite import io.ktor.util.* +import kotlin.io.encoding.Base64 + +private const val PkceCodeVerifierLength: Int = 64 +internal const val PkceCodeChallengeMethod: String = "S256" private val CookieMaxAgeSeconds = AuthorizationTransactionTtl.inWholeSeconds.toInt() internal const val OidcStateCookieName = "KTOR_OIDC_STATE" @@ -26,7 +31,14 @@ internal fun String.toOidcStateCookie(secure: Boolean, maxAge: Int = CookieMaxAg internal class OidcAuthorizationTransaction( val nonce: String, -) + val codeVerifier: String, +) { + fun codeChallenge(): String { + val digester = DigestAlgorithm.SHA_256.toDigester() + val digest = digester.digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(digest) + } +} private val ApplicationCall.secureCookie: Boolean get() = request.origin.scheme == "https" @@ -36,7 +48,8 @@ internal suspend fun ApplicationCall.createAuthorizationTransaction( state: String, ): OidcAuthorizationTransaction { val nonce = generateNonceSuspend() - val transaction = OidcAuthorizationTransaction(nonce) + val codeVerifier = generateNonceSuspend(length = PkceCodeVerifierLength) + val transaction = OidcAuthorizationTransaction(nonce, codeVerifier) val cookie = stateCodec.encode(state, transaction).toOidcStateCookie(secureCookie) response.cookies.append(cookie) return transaction diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt index 1dccaaad69b..33b391da4b2 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcBearer.kt @@ -82,12 +82,22 @@ private fun OidcProvider<*>.oauthServerSettings(): OAuthServerSettings.OAuth2Ser request.call.readAuthorizationTransaction(stateCodec, it) } ?: return@authorize parameters.append("nonce", transaction.nonce) + if (config.pkceEnabled) { + parameters.append("code_challenge", transaction.codeChallenge()) + parameters.append("code_challenge_method", PkceCodeChallengeMethod) + } }, verifyState = { call, state -> call.validateAuthorizationResponseIssuer(currentMetadata()) state != null && call.readAuthorizationTransaction(stateCodec, state) != null }, - extraTokenParametersProvider = { _, _ -> emptyList() }, + extraTokenParametersProvider = provider@{ call, callback -> + if (!config.pkceEnabled) { + return@provider emptyList() + } + val transaction = call.readAuthorizationTransaction(stateCodec, callback.state) + transaction?.let { listOf("code_verifier" to it.codeVerifier) }.orEmpty() + }, onStateCreated = { call, state -> call.createAuthorizationTransaction(stateCodec, state) }, ) } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcConfig.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcConfig.kt index 04e2522964e..d242ebd03b2 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcConfig.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcConfig.kt @@ -473,8 +473,8 @@ public class OidcOAuthConfig

internal constructor( public var fetchUserInfo: Boolean = false /** - * Symmetric key used to encrypt the in-flight OAuth state cookie carrying `state` and `nonce` - * between the login redirect and the callback. + * Symmetric key used to encrypt the in-flight OAuth state cookie carrying `state`, `nonce`, and the PKCE code + * verifier between the login redirect and the callback. * * Required in production. In development mode an ephemeral key is generated when not set. * @@ -482,6 +482,8 @@ public class OidcOAuthConfig

internal constructor( */ public var stateEncryptionKey: OidcStateEncryptionKey? = null + internal var pkceEnabled: Boolean = true + /** * Configures the OAuth callback route URI. * @@ -506,6 +508,17 @@ public class OidcOAuthConfig

internal constructor( */ internal var onFailure: UnauthorizedHandler = { call.respond(HttpStatusCode.Unauthorized) } + /** + * Disables PKCE (RFC 7636). + * + * Use only with legacy OpenID Providers that reject PKCE parameters. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcOAuthConfig.disablePkce) + */ + public fun disablePkce() { + pkceEnabled = false + } + /** * Sets the handler called after a successful OAuth/OIDC login. * diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcOAuth.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcOAuth.kt index 2e936ce2f56..19affa96a07 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcOAuth.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcOAuth.kt @@ -35,6 +35,10 @@ internal fun

Application.configureOAuthRoute(provider: OidcProvider

parameters.append("scope", config.scopes.joinToString(" ")) parameters.append("state", oauthState) parameters.append("nonce", authorizationTransaction.nonce) + if (config.pkceEnabled) { + parameters.append("code_challenge", authorizationTransaction.codeChallenge()) + parameters.append("code_challenge_method", PkceCodeChallengeMethod) + } config.resourceIndicators.forEach { parameters.append("resource", it) } }.buildString() diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateCodec.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateCodec.kt index a4f823774a1..00207bbe7b5 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateCodec.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateCodec.kt @@ -28,6 +28,7 @@ internal class OidcStateCodec( val payload = OidcStateCookiePayload( state = state, nonce = transaction.nonce, + codeVerifier = transaction.codeVerifier, expiresAt = clock.now() + AuthorizationTransactionTtl, ) return encrypt(payload) @@ -38,7 +39,7 @@ internal class OidcStateCodec( if (payload.state != state || payload.expiresAt <= clock.now()) { return null } - return OidcAuthorizationTransaction(payload.nonce) + return OidcAuthorizationTransaction(payload.nonce, payload.codeVerifier) } private fun encrypt(payload: OidcStateCookiePayload): String { @@ -88,5 +89,6 @@ internal class OidcStateCodec( internal class OidcStateCookiePayload( val state: String, val nonce: String, + val codeVerifier: String, val expiresAt: Instant, ) diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateEncryptionKey.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateEncryptionKey.kt index 803e76f124d..ac98a49480e 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateEncryptionKey.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcStateEncryptionKey.kt @@ -10,7 +10,7 @@ import java.security.SecureRandom * Symmetric key material used to encrypt the temporary OAuth state cookie. * * The key protects the in-flight OAuth state between the login redirect and the callback, including the OIDC - * `state` and `nonce`. Configure the same key on every node in a cluster. Use [rotating] to + * `state`, `nonce`, and PKCE code verifier. Configure the same key on every node in a cluster. Use [rotating] to * accept cookies encrypted by the previous key while new cookies are encrypted by the current key. * * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcStateEncryptionKey) diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcOAuthCallbackTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcOAuthCallbackTest.kt index dc31334ff29..76ed9ffd575 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcOAuthCallbackTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcOAuthCallbackTest.kt @@ -60,6 +60,8 @@ class OidcOAuthCallbackTest { val stateCookie = assertNotNull(login.oidcStateCookieHeader()) val state = assertNotNull(authorizeUrl.parameters["state"]) val nonce = assertNotNull(authorizeUrl.parameters["nonce"]) + assertNotNull(authorizeUrl.parameters["code_challenge"]) + assertEquals("S256", authorizeUrl.parameters["code_challenge_method"]) idTokensByState[state] = keys.idToken(subject = "callback-user") { audience = "client-id" this.nonce = nonce @@ -75,6 +77,55 @@ class OidcOAuthCallbackTest { assertEquals(HttpStatusCode.NotFound, browser.post("/oidc/auth0/logout").status) } + @Test + fun `oauth login can disable pkce parameters`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + + openIdProvider(keys, idTokensByState, expectPkce = false) + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + oidc.provider("auth0") { + testIssuer() + jwt(keys) + oauth { + clientId = "client-id" + clientSecret = "client-secret" + disablePkce() + onSuccess { principal -> + val idToken = principal as OidcToken.Id + call.respondText("signed in ${idToken.userInfo.subject}") + } + } + } + } + + val browser = noRedirectsClient() + val login = browser.get("/oidc/auth0/login") + assertEquals(HttpStatusCode.Found, login.status) + val authorizeUrl = Url(assertNotNull(login.headers[HttpHeaders.Location])) + val stateCookie = assertNotNull(login.oidcStateCookieHeader()) + val state = assertNotNull(authorizeUrl.parameters["state"]) + val nonce = assertNotNull(authorizeUrl.parameters["nonce"]) + assertNull(authorizeUrl.parameters["code_challenge"]) + assertNull(authorizeUrl.parameters["code_challenge_method"]) + idTokensByState[state] = keys.idToken(subject = "callback-user") { + audience = "client-id" + this.nonce = nonce + } + + val callback = browser.get("/oidc/auth0/callback?code=login-code&state=$state") { + header(HttpHeaders.Cookie, stateCookie) + } + assertEquals(HttpStatusCode.OK, callback.status) + assertEquals("signed in callback-user", callback.bodyAsText()) + } + @Test fun `oauth state cookie works across provider instances sharing key`() { val keys = testRsaKeys diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcStateCodecTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcStateCodecTest.kt new file mode 100644 index 00000000000..146f7ba06f7 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcStateCodecTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.auth.oidc + +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +class OidcStateCodecTest { + + @Test + fun `round trips with fixed key`() { + val codec = codec() + val transaction = OidcAuthorizationTransaction(nonce = "nonce-1", codeVerifier = "verifier-1") + + val encoded = codec.encode("state-1", transaction) + val decoded = codec.decode(encoded, "state-1") + + assertEquals("nonce-1", decoded?.nonce) + assertEquals("verifier-1", decoded?.codeVerifier) + } + + @Test + fun `tampered iv ciphertext and tag are treated as absent`() { + val codec = codec() + val encoded = codec.encode( + state = "state-1", + transaction = OidcAuthorizationTransaction(nonce = "nonce-1", codeVerifier = "verifier-1"), + ) + + listOf( + tamperEncoded(encoded, index = 0), + tamperEncoded(encoded, index = 12), + tamperEncoded(encoded, index = -1), + ).forEach { tampered -> + assertNull(codec.decode(tampered, "state-1")) + } + } + + @Test + fun `wrong state is treated as absent`() { + val codec = codec() + val encoded = codec.encode( + state = "state-1", + transaction = OidcAuthorizationTransaction(nonce = "nonce-1", codeVerifier = "verifier-1"), + ) + + assertNull(codec.decode(encoded, "state-2")) + } + + @Test + fun `expired payload is treated as absent`() { + val clock = TestClock(1_000L) + val codec = codec(clock = clock) + val encoded = codec.encode( + state = "state-1", + transaction = OidcAuthorizationTransaction(nonce = "nonce-1", codeVerifier = "verifier-1"), + ) + + clock.epochMs += AuthorizationTransactionTtl.inWholeMilliseconds + 1 + + assertNull(codec.decode(encoded, "state-1")) + } + + @Test + fun `rotating key accepts cookie encrypted with previous key`() { + val previous = fixedKey(2) + val current = fixedKey(3) + val writer = codec(key = OidcStateEncryptionKey.of(previous)) + val reader = codec(key = OidcStateEncryptionKey.rotating(current, previous)) + val transaction = OidcAuthorizationTransaction(nonce = "nonce-1", codeVerifier = "verifier-1") + + val encoded = writer.encode("state-1", transaction) + val decoded = reader.decode(encoded, "state-1") + + assertEquals("nonce-1", decoded?.nonce) + assertEquals("verifier-1", decoded?.codeVerifier) + } + + @Test + fun `encryption uses fresh iv`() { + val codec = codec() + val transaction = OidcAuthorizationTransaction(nonce = "nonce-1", codeVerifier = "verifier-1") + + val first = codec.encode("state-1", transaction) + val second = codec.encode("state-1", transaction) + + assertNotEquals(first, second) + } + + private fun codec( + key: OidcStateEncryptionKey = OidcStateEncryptionKey.of(fixedKey(1)), + clock: Clock = TestClock(1_000L), + ) = OidcStateCodec( + encryptionKey = key, + clock = clock, + ) + + private fun tamperEncoded(value: String, index: Int): String { + val bytes = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).decode(value) + val tampered = bytes.copyOf() + val resolvedIndex = if (index < 0) tampered.lastIndex else index + tampered[resolvedIndex] = (tampered[resolvedIndex].toInt() xor 1).toByte() + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(tampered) + } + + private fun fixedKey(seed: Int): ByteArray = + ByteArray(OidcStateEncryptionKey.KEY_SIZE) { index -> (seed + index).toByte() } + + private class TestClock(var epochMs: Long) : Clock { + override fun now(): Instant = Instant.fromEpochMilliseconds(epochMs) + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestOAuthFlow.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestOAuthFlow.kt index 271e391fcf8..3c4d2c21707 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestOAuthFlow.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestOAuthFlow.kt @@ -44,3 +44,14 @@ internal suspend fun HttpClient.completeOidcCallback( configureRequest() } } + +internal fun assertValidPkceCodeVerifier(codeVerifier: String) { + assertTrue( + codeVerifier.length in 43..128, + "PKCE code verifier must contain 43 to 128 characters", + ) + assertTrue( + codeVerifier.all { it.isLetterOrDigit() || it in "-._~" }, + "PKCE code verifier must use RFC 7636 unreserved characters", + ) +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestProviders.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestProviders.kt index b65355a5312..3e2836d8573 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestProviders.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcTestProviders.kt @@ -12,6 +12,7 @@ import io.ktor.server.routing.* import io.ktor.server.testing.* import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull internal val openIdProviderMetadata: OpenIdProviderMetadata = OpenIdProviderMetadata( issuer = ISSUER_URL, @@ -42,6 +43,7 @@ internal fun

OidcProviderConfig

.testIssuer( internal fun TestApplicationBuilder.openIdProvider( keys: OpenIdTestKeys, idTokensByState: Map, + expectPkce: Boolean = true, tokenType: String? = "Bearer", ) { externalServices { @@ -55,6 +57,7 @@ internal fun TestApplicationBuilder.openIdProvider( accessToken = keys.accessToken { subject = "token-user" }, + expectPkce = expectPkce, tokenType = tokenType, ) } @@ -68,9 +71,10 @@ internal suspend fun RoutingContext.respondAuthorizationCodeWithIdToken( idTokensByState: Map, accessToken: String, refreshToken: String? = "refresh-token-1", + expectPkce: Boolean = true, tokenType: String? = "Bearer", ) { - assertAuthorizationCodeRequest(parameters) + assertAuthorizationCodeRequest(parameters, expectPkce) val state = assertNotNull(parameters["state"]) val responseParameters = buildList { @@ -83,10 +87,15 @@ internal suspend fun RoutingContext.respondAuthorizationCodeWithIdToken( call.respondText(responseParameters.formUrlEncode(), ContentType.Application.FormUrlEncoded) } -internal fun assertAuthorizationCodeRequest(parameters: Parameters) { +internal fun assertAuthorizationCodeRequest(parameters: Parameters, expectPkce: Boolean = true) { assertEquals("authorization_code", parameters["grant_type"]) assertEquals("login-code", parameters["code"]) assertEquals("client-id", parameters["client_id"]) assertEquals("client-secret", parameters["client_secret"]) + if (expectPkce) { + assertValidPkceCodeVerifier(assertNotNull(parameters["code_verifier"])) + } else { + assertNull(parameters["code_verifier"]) + } assertNotNull(parameters["state"]) }