Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,15 +473,17 @@ public class OidcOAuthConfig<P : Any> 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.
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcOAuthConfig.stateEncryptionKey)
*/
public var stateEncryptionKey: OidcStateEncryptionKey? = null

internal var pkceEnabled: Boolean = true

/**
* Configures the OAuth callback route URI.
*
Expand All @@ -506,6 +508,17 @@ public class OidcOAuthConfig<P : Any> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ internal fun <P : Any> Application.configureOAuthRoute(provider: OidcProvider<P>
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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -88,5 +89,6 @@ internal class OidcStateCodec(
internal class OidcStateCookiePayload(
val state: String,
val nonce: String,
val codeVerifier: String,
val expiresAt: Instant,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, String>()

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
}
Loading
Loading