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 OidcProviderConfig .testIssuer(
internal fun TestApplicationBuilder.openIdProvider(
keys: OpenIdTestKeys,
idTokensByState: Map