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 8f6693e6a0c..462bb391fa6 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 @@ -72,6 +72,7 @@ public final class io/ktor/server/auth/oidc/OidcOAuthConfig { public final fun getFetchUserInfo ()Z public final fun getIdTokenAudience ()Ljava/lang/String; public final fun getLoginUri ()Lkotlin/jvm/functions/Function1; + public final fun getPostLogoutRedirectUri ()Lkotlin/jvm/functions/Function1; public final fun getRedirectUri ()Lkotlin/jvm/functions/Function1; public final fun getResourceIndicators ()Ljava/util/List; public final fun getScopes ()Ljava/util/List; @@ -83,6 +84,7 @@ public final class io/ktor/server/auth/oidc/OidcOAuthConfig { public final fun setFetchUserInfo (Z)V public final fun setIdTokenAudience (Ljava/lang/String;)V public final fun setLoginUri (Lkotlin/jvm/functions/Function1;)V + public final fun setPostLogoutRedirectUri (Lkotlin/jvm/functions/Function1;)V public final fun setRedirectUri (Lkotlin/jvm/functions/Function1;)V public final fun setResourceIndicators (Ljava/util/List;)V public final fun setScopes (Ljava/util/List;)V @@ -104,11 +106,14 @@ public final class io/ktor/server/auth/oidc/OidcPluginConfig { } public final class io/ktor/server/auth/oidc/OidcProvider { + public final fun buildLogoutUrl (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; public final fun currentJwkProvider ()Lcom/auth0/jwk/JwkProvider; public final fun currentMetadata ()Lio/ktor/server/auth/oidc/OpenIdProviderMetadata; public final fun getBearer ()Lio/ktor/server/auth/typesafe/DefaultAuthScheme; public final fun getIssuer ()Ljava/lang/String; public final fun getName ()Ljava/lang/String; + public final fun getSessions ()Lio/ktor/server/auth/typesafe/SessionAuthScheme; + public final fun refreshToken (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class io/ktor/server/auth/oidc/OidcProviderConfig { @@ -123,6 +128,8 @@ public final class io/ktor/server/auth/oidc/OidcProviderConfig { public final fun jwt (Lkotlin/jvm/functions/Function1;)V public final fun oauth (Lkotlin/jvm/functions/Function1;)V public static synthetic fun oauth$default (Lio/ktor/server/auth/oidc/OidcProviderConfig;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public final fun sessions (Lkotlin/jvm/functions/Function1;)V + public static synthetic fun sessions$default (Lio/ktor/server/auth/oidc/OidcProviderConfig;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public final fun setIssuer (Ljava/lang/String;)V public final fun setMetadata (Lio/ktor/server/auth/oidc/OpenIdProviderMetadata;)V } @@ -131,6 +138,34 @@ public abstract interface class io/ktor/server/auth/oidc/OidcProviderContext { public abstract fun provider ()Lio/ktor/server/auth/oidc/OidcProvider; } +public final class io/ktor/server/auth/oidc/OidcSessionConfig { + public final fun cookie (Lkotlin/jvm/functions/Function1;)V + public final fun csrfProtection (Lkotlin/jvm/functions/Function1;)V + public final fun disableCsrfProtection ()V + public final fun getLogoutUri ()Lkotlin/jvm/functions/Function1; + public final fun getName ()Ljava/lang/String; + public final fun getRefreshUri ()Lkotlin/jvm/functions/Function1; + public final fun getStorage ()Lio/ktor/server/sessions/SessionStorage; + public final fun getTokenRefreshStrategy ()Lio/ktor/server/auth/oidc/OidcTokenRefreshStrategy; + public final fun setLogoutUri (Lkotlin/jvm/functions/Function1;)V + public final fun setName (Ljava/lang/String;)V + public final fun setRefreshUri (Lkotlin/jvm/functions/Function1;)V + public final fun setStorage (Lio/ktor/server/sessions/SessionStorage;)V + public final fun setTokenRefreshStrategy (Lio/ktor/server/auth/oidc/OidcTokenRefreshStrategy;)V +} + +public final class io/ktor/server/auth/oidc/OidcSessionsContext : io/ktor/server/auth/oidc/OidcProviderContext, io/ktor/server/auth/typesafe/SessionAuthenticatedContext { + public fun clearSession (Lio/ktor/server/routing/RoutingContext;)V + public fun principal (Lio/ktor/server/routing/RoutingContext;)Ljava/lang/Object; + public fun provider ()Lio/ktor/server/auth/oidc/OidcProvider; + public fun session (Lio/ktor/server/routing/RoutingContext;)Lio/ktor/server/auth/oidc/OidcToken$Id; + public synthetic fun session (Lio/ktor/server/routing/RoutingContext;)Ljava/lang/Object; + public fun setSession (Lio/ktor/server/routing/RoutingContext;Lio/ktor/server/auth/oidc/OidcToken$Id;)V + public synthetic fun setSession (Lio/ktor/server/routing/RoutingContext;Ljava/lang/Object;)V + public fun updateSession (Lio/ktor/server/routing/RoutingContext;Lkotlin/jvm/functions/Function1;)Lio/ktor/server/auth/oidc/OidcToken$Id; + public synthetic fun updateSession (Lio/ktor/server/routing/RoutingContext;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; +} + public final class io/ktor/server/auth/oidc/OidcStateEncryptionKey { public static final field Companion Lio/ktor/server/auth/oidc/OidcStateEncryptionKey$Companion; public static final field KEY_SIZE I @@ -260,6 +295,34 @@ public final class io/ktor/server/auth/oidc/OidcToken$UserInfo$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class io/ktor/server/auth/oidc/OidcTokenRefreshResult { + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lkotlin/time/Duration;Ljava/lang/String;Ljava/lang/String;Lio/ktor/server/auth/oidc/OidcToken$Id;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lkotlin/time/Duration;Ljava/lang/String;Ljava/lang/String;Lio/ktor/server/auth/oidc/OidcToken$Id;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAccessToken ()Ljava/lang/String; + public final fun getExpiresIn-FghU774 ()Lkotlin/time/Duration; + public final fun getIdToken ()Lio/ktor/server/auth/oidc/OidcToken$Id; + public final fun getRefreshToken ()Ljava/lang/String; + public final fun getScope ()Ljava/lang/String; + public final fun getTokenType ()Ljava/lang/String; +} + +public abstract interface class io/ktor/server/auth/oidc/OidcTokenRefreshStrategy { +} + +public final class io/ktor/server/auth/oidc/OidcTokenRefreshStrategy$Auto : io/ktor/server/auth/oidc/OidcTokenRefreshStrategy { + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBeforeExpiry-UwyO8pc ()J +} + +public abstract interface class io/ktor/server/auth/oidc/OidcTokenRefreshStrategy$Custom : io/ktor/server/auth/oidc/OidcTokenRefreshStrategy { + public abstract fun refresh (Lio/ktor/server/auth/oidc/OidcProvider;Lio/ktor/server/auth/oidc/OidcToken$Id;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/ktor/server/auth/oidc/OidcTokenRefreshStrategy$Disabled : io/ktor/server/auth/oidc/OidcTokenRefreshStrategy { + public static final field INSTANCE Lio/ktor/server/auth/oidc/OidcTokenRefreshStrategy$Disabled; +} + public final class io/ktor/server/auth/oidc/OpaqueTokenIntrospection { public static final field Companion Lio/ktor/server/auth/oidc/OpaqueTokenIntrospection$Companion; public fun (ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;)V 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 5f7a7d3b8e1..a78930283d5 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 @@ -11,6 +11,7 @@ import io.ktor.events.EventDefinition import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.config.* +import io.ktor.server.sessions.* import io.ktor.util.* import io.ktor.utils.io.* import kotlinx.coroutines.* @@ -30,8 +31,10 @@ private val ProviderNameRegex = Regex("[a-z0-9]+(?:-[a-z0-9]+)*") * - Bearer token authentication (`bearer { }`) that validates JWT access tokens issued by the provider. * Use [OidcProvider.bearer] with `authenticateWith`. * - **OAuth 2.0 / OIDC login flow** (`oauth { }`) — handles the authorization code flow, - * including login and redirect routes. - * Registered internally as `"$name-oauth"` and used only for the auto-registered routes. + * including login and redirect routes. Session storage is opt-in via `sessions { }`, which also + * enables plugin-managed refresh and logout routes. + * Registered internally as `"$name-oauth"` and used only for the auto-registered routes; + * browser session authentication is exposed as [OidcProvider.sessions]. * * 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. @@ -94,6 +97,13 @@ private val ProviderNameRegex = Regex("[a-z0-9]+(?:-[a-z0-9]+)*") * call.respondRedirect("/dashboard") * } * } + * + * // Browser sessions are opt-in. When enabled, callbacks store the verified + * // ID-token principal in the session and plugin-managed refresh/logout routes + * // are installed. + * sessions { + * name = "GOOGLE_SESSION" + * } * } * * // Protect routes using typed provider capabilities. @@ -111,6 +121,13 @@ private val ProviderNameRegex = Regex("[a-z0-9]+(?:-[a-z0-9]+)*") * call.respond(user.userInfo) * } * } + * + * authenticateWith(google.sessions) { + * get("/me") { + * val user = principal as OidcToken.Id + * call.respond(user.userInfo) + * } + * } * } * ``` * @@ -295,6 +312,15 @@ public class Oidc internal constructor( private fun checkProductionEnvironment(provider: OidcProvider<*>) { val devMode = application.developmentMode + // check production session storage is not in-memory + provider.config.sessionConfig?.let { sessionConfig -> + if (!devMode && sessionConfig.storage is SessionStorageMemory) { + provider.logger.warn( + "OpenID Connect sessions use SessionStorageMemory. Configure shared SessionStorage for clustered production deployments." + ) + } + } + // ensure production stateEncryptionKey is configured val oauthConfig = provider.config.oauthConfig if (oauthConfig == null || oauthConfig.stateEncryptionKey != null) { diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthenticatedContext.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthenticatedContext.kt index 4d2e6eaac28..8c5c6091055 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthenticatedContext.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcAuthenticatedContext.kt @@ -47,6 +47,24 @@ public class OidcBearerContext

internal constructor( override fun provider(): OidcProvider

= provider } +/** + * Route context used by OpenID Connect session authentication. + * + * It exposes the typed authenticated principal, the raw stored [OidcToken.Id] session, and provider-bound helpers + * inside `authenticateWith(provider.sessions)` route bodies. + * + * @param P provider principal type exposed to the route. + */ +@KtorDsl +@OptIn(ExperimentalKtorApi::class) +public class OidcSessionsContext

internal constructor( + default: SessionAuthenticatedContext, + private val provider: OidcProvider

, +) : SessionAuthenticatedContext by default, OidcProviderContext

{ + + override fun provider(): OidcProvider

= provider +} + /** * Typed Bearer authentication scheme for an OpenID Connect provider. * @@ -54,3 +72,11 @@ public class OidcBearerContext

internal constructor( */ @OptIn(ExperimentalKtorApi::class) public typealias OidcBearerScheme

= DefaultAuthScheme> + +/** + * Typed session authentication scheme for an OpenID Connect provider. + * + * @param P provider principal type exposed to the route. + */ +@OptIn(ExperimentalKtorApi::class) +public typealias OidcSessionsScheme

= SessionAuthScheme> 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 33b391da4b2..0d50b31bac1 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 @@ -13,8 +13,11 @@ import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.typesafe.* import io.ktor.server.response.* +import io.ktor.server.sessions.serialization.* +import io.ktor.util.reflect.* import io.ktor.utils.io.* import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json import org.slf4j.Logger private const val AuthorizationHeaderLogLimit: Int = 96 @@ -63,6 +66,69 @@ internal fun

OidcProvider

.createOauthFlow(): OAuth2Flow = } } +internal fun

OidcProvider

.createSessions( + secure: Boolean +): OAuth2SessionFlow> { + val sessionJson = Json { + ignoreUnknownKeys = true + serializersModule = OidcToken.serializersModule + } + + @OptIn(InternalAPI::class) + val sessionFlowConfig = OAuth2SessionConfig>().apply { + sessionConfig.name?.let { sessionName = it } + + storage { scheme -> + cookie(scheme, storage = sessionConfig.storage) { + serializer = KotlinxSessionSerializer( + OidcToken.Id.serializer(), + format = sessionJson, + ) + cookie.httpOnly = true + cookie.secure = secure + cookie.extensions["SameSite"] = "lax" + sessionConfig.cookieConfigure?.invoke(this) + } + } + + sessionCreator = sessionCreator@{ oauthResponse -> + call.validateAuthorizationResponseIssuer(currentMetadata()) + val response = requireNotNull(oauthResponse as? OAuthAccessTokenResponse.OAuth2) { + "Expected OAuth2 token response, but got: ${oauthResponse::class.simpleName}" + } + val oauthState = response.state ?: call.request.queryParameters["state"] + val authorizationTransaction = oauthState?.let { + call.consumeAuthorizationTransaction(stateCodec, it) + } + + val token = buildOAuthToken(response, expectedNonce = authorizationTransaction?.nonce) + if (token !is OidcToken.Id) { + logger.debug("Received non-ID token, skipping session creation") + return@sessionCreator null + } + token + } + + transformSession { refreshSessionIfNeeded(token = it) } + + validate { transformPrincipal(token = it) } + + sessionConfig.csrfConfigurer?.let { configure -> + csrfProtection(configure) + } + + contextFactory = { default -> OidcSessionsContext(default, provider = this@createSessions) } + } + + @OptIn(InternalAPI::class) + return OAuth2SessionFlow.from( + oauth = oauthFlow, + config = sessionFlowConfig, + principalType = principalType, + sessionTypeInfo = typeInfo(), + ) +} + private fun OidcProvider<*>.oauthServerSettings(): OAuthServerSettings.OAuth2ServerSettings { val config = oauthConfig val metadata = currentMetadata() 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 d242ebd03b2..920fd1b00c4 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 @@ -12,9 +12,11 @@ import io.ktor.http.auth.* import io.ktor.server.application.* import io.ktor.server.auth.typesafe.* import io.ktor.server.plugins.* +import io.ktor.server.plugins.csrf.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import io.ktor.server.sessions.* import io.ktor.utils.io.* import kotlin.reflect.KClass import kotlin.time.Duration @@ -109,6 +111,7 @@ public class OidcProviderConfig

internal constructor( internal var accessTokenConfig: OidcAccessTokenConfig? = null internal var bearerConfig: OidcBearerConfig? = null internal var oauthConfig: OidcOAuthConfig

? = null + internal var sessionConfig: OidcSessionConfig

? = null /** * Configures JWT verification shared by ID-token and JWT access-token validation. @@ -137,11 +140,26 @@ public class OidcProviderConfig

internal constructor( /** * Configures access-token acceptance for Bearer authentication and OAuth callbacks without an ID token. + * Access-token-only OAuth callbacks are accepted only when [sessions] is not configured. */ public fun accessToken(configure: OidcAccessTokenConfig.() -> Unit) { accessTokenConfig = (accessTokenConfig ?: OidcAccessTokenConfig()).apply(configure) } + /** + * Configures the OIDC session for this provider, including cookie transport and CSRF protection. + * + * Defaults to a cookie named `"${name.uppercase()}_SESSION"` with secure defaults + * (`httpOnly`, `secure` in production, `SameSite=lax`), and CSRF protection enabled + * with [CSRFConfig.originMatchesHost]. + * + * Used by both the OAuth flow (login/refresh/logout routes) and typed session authentication + * (`authenticateWith(provider.sessions)`) to read/write the verified [OidcToken.Id] session. + */ + public fun sessions(configure: OidcSessionConfig

.() -> Unit = {}) { + sessionConfig = OidcSessionConfig

(name).apply(configure) + } + /** * Enables Bearer token authentication and configures token extraction for this provider. * @@ -452,7 +470,8 @@ public class OidcOAuthConfig

internal constructor( * OAuth scopes requested during authorization. * * The `openid` scope is required unless [OidcProviderConfig.accessToken] is configured, in which case - * access-token-only OAuth callbacks may accept a response without an ID token. + * non-session OAuth callbacks may accept an access-token-only response. Session-backed callbacks always + * require an ID token. */ public var scopes: List = listOf("openid", "profile", "email") @@ -462,7 +481,7 @@ public class OidcOAuthConfig

internal constructor( public var resourceIndicators: List = emptyList() /** - * Expected audience for ID token validation in the callback flow. + * Expected audience for ID token validation in callback/refresh. * Defaults to [clientId] when not specified. */ public var idTokenAudience: String? = null @@ -498,6 +517,14 @@ public class OidcOAuthConfig

internal constructor( */ public var loginUri: URLBuilder.() -> Unit = { path("oidc", providerName, "login") } + /** + * Configures the local redirect URI used after plugin-managed logout. + * + * Defaults to `/`. When the provider supports RP-initiated logout, this URI is also sent as + * `post_logout_redirect_uri` and must be registered with the OpenID Provider. Query parameters are not supported. + */ + public var postLogoutRedirectUri: URLBuilder.() -> Unit = { path("/") } + /** * Called after a successful OAuth login. */ @@ -555,6 +582,96 @@ public class OidcOAuthConfig

internal constructor( } } +/** + * Configuration for OIDC session transport and CSRF protection. + * + * Controls how the OpenID Connect session is stored and transported between client and server, and whether CSRF + * protection is applied to routes authenticated with the enclosing provider. + * + * By default, sessions use cookie transport with secure defaults and CSRF protection is enabled with + * [CSRFConfig.originMatchesHost]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcSessionConfig) + * + * @param P provider principal type exposed to route handlers. + */ +@KtorDsl +public class OidcSessionConfig

internal constructor(private val providerName: String) { + /** + * Cookie / session name. + * + * When `null`, defaults to the provider name in uppercase followed by `_SESSION`. + */ + public var name: String? = null + + /** + * Server-side session storage. Defaults to in-memory storage. + * + * In-memory storage is intended for local development and single-instance deployments. Use shared storage for + * clustered production deployments. + */ + public var storage: SessionStorage = SessionStorageMemory() + + /** + * Strategy used to refresh session token material. + * + * The default disables automatic refresh, but expired ID-token sessions are still rejected on user routes. + * Expiry and refresh timing use [OidcToken.Id.claims] [io.ktor.server.auth.oidc.TokenClaims.expiresAt]; + * when the ID token has no `exp` claim, sessions are never treated as expired and auto-refresh never triggers. + */ + public var tokenRefreshStrategy: OidcTokenRefreshStrategy

= OidcTokenRefreshStrategy.Disabled + + internal var cookieConfigure: (CookieIdSessionBuilder.() -> Unit)? = null + + internal var csrfConfigurer: (CSRFConfig.() -> Unit)? = { originMatchesHost() } + + /** + * Plugin-managed logout route URI. + * + * Defaults to `/oidc/{providerName}/logout`. The route is installed only when both + * [OidcProviderConfig.sessions] and [OidcProviderConfig.oauth] are configured. + * Query parameters are not supported. + */ + public var logoutUri: (URLBuilder.() -> Unit) = { path("oidc", providerName, "logout") } + + /** + * Plugin-managed refresh route URI. + * + * Defaults to `/oidc/{providerName}/refresh`. The route is installed only when both + * [OidcProviderConfig.sessions] and [OidcProviderConfig.oauth] are configured. + * Query parameters are not supported. + */ + public var refreshUri: (URLBuilder.() -> Unit) = { path("oidc", providerName, "refresh") } + + /** + * Configures cookie attributes for the session cookie. + * + * The plugin applies secure defaults before this block runs: `httpOnly = true`, `secure = true` (in production), + * `SameSite = lax`. Values set in this block override those defaults. + */ + public fun cookie(configure: CookieIdSessionBuilder.() -> Unit) { + cookieConfigure = configure + } + + /** + * Configures CSRF protection for routes authenticated with this provider's typed session capability. + * + * By default, CSRF protection is enabled with [CSRFConfig.originMatchesHost]. + * CSRF checks are applied to plugin-managed POST routes (refresh, logout) and user-defined non-safe HTTP methods + * under `authenticateWith(provider.sessions)`. + */ + public fun csrfProtection(configure: CSRFConfig.() -> Unit) { + csrfConfigurer = configure + } + + /** + * Disables CSRF protection for this provider's routes. + */ + public fun disableCsrfProtection() { + csrfConfigurer = null + } +} + internal fun oidcRoutePath(build: URLBuilder.() -> Unit): String { val url = URLBuilder().apply(build).build() require(url.encodedQuery.isEmpty()) { 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 19affa96a07..adfc7d303dd 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 @@ -45,11 +45,63 @@ internal fun

Application.configureOAuthRoute(provider: OidcProvider

call.respondRedirect(authorizeUrl) } + val sessionsDisabled = provider.config.sessionConfig == null + if (sessionsDisabled) { + oauthCallback( + flow = provider.oauthFlow, + path = redirectPath, + onSuccess = { provider.handleOAuthCallbackSuccess(response = principal) } + ) + return@routing + } + oauthCallback( - flow = provider.oauthFlow, + flow = provider.oauthSessionFlow, path = redirectPath, - onSuccess = { provider.handleOAuthCallbackSuccess(response = principal) } + onFailure = config.onFailure, + onSuccess = { config.onSuccess(this, principal) } ) + + authenticateWith(provider.sessions) { + post(provider.sessionRefreshPath) { + val refreshToken = session.refreshToken ?: run { + provider.logger.debug("Session has no refresh token, cannot refresh") + return@post call.respond(HttpStatusCode.Unauthorized) + } + + val refreshResult = runCatching { + provider.refreshToken(refreshToken) + }.onFailure { + if (it is CancellationException) throw it + provider.logger.debug("Failed to refresh token ${it.cause}") + }.getOrNull() + + if (refreshResult == null) { + return@post call.respond(HttpStatusCode.Unauthorized) + } + + val refreshedPrincipal = refreshResult.idToken + ?: return@post call.respond(HttpStatusCode.Unauthorized) + + session = refreshedPrincipal + call.respond(HttpStatusCode.OK) + } + + post(provider.sessionLogoutPath) { + val postLogoutRedirectUri = call.request.oidcRedirectUri(config.postLogoutRedirectUri) + val idTokenHint = session.value + clearSession() + + val logoutUrl = runCatching { + provider.buildLogoutUrl(idTokenHint, postLogoutRedirectUri) + }.onFailure { + provider.logger.debug("Failed to build logout URL: ${it.message}") + }.getOrDefault(postLogoutRedirectUri) + + call.response.headers.append(HttpHeaders.Location, logoutUrl) + call.respond(HttpStatusCode.SeeOther) + } + } } } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProvider.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProvider.kt index 5bd1c5cfaf6..2154f7d8a1a 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProvider.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcProvider.kt @@ -11,6 +11,9 @@ import com.auth0.jwk.JwkProviderBuilder import io.ktor.client.* import io.ktor.server.routing.* import io.ktor.utils.io.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.slf4j.Logger import org.slf4j.LoggerFactory import java.net.URI @@ -23,10 +26,12 @@ import kotlin.time.toJavaDuration * Typed authentication capabilities for one configured OpenID Connect provider. * * [bearer] is available when the provider was configured with `bearer { }`. + * [sessions] is available when the provider was configured with `sessions { }`. * * @param P principal type exposed by this provider's route-facing capabilities. * @property name provider name. It is also used to derive default routes (`/oidc/{name}/...`), the OAuth scheme - * name (`{name}-oauth`), and the Bearer scheme name (`{name}-bearer`). + * name (`{name}-oauth`), the Bearer scheme name (`{name}-bearer`), and the default session cookie root + * (`{NAME}_SESSION`). * * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcProvider) */ @@ -46,6 +51,11 @@ public class OidcProvider

internal constructor( internal val oauthConfig: OidcOAuthConfig

get() = checkNotNull(config.oauthConfig) { "OAuth is not enabled for provider $name" } + internal val sessionConfig: OidcSessionConfig

+ get() = checkNotNull(config.sessionConfig) { + "Sessions are not enabled. Call sessions { } in the provider $name." + } + internal val accessTokenConfig: OidcAccessTokenConfig get() = checkNotNull(config.accessTokenConfig) { "Access token is not enabled for provider $name" } @@ -60,9 +70,17 @@ public class OidcProvider

internal constructor( private var providerState: OidcProviderState? = null internal val oauthFlow by lazy { createOauthFlow() } + internal val oauthSessionFlow by lazy { createSessions(secure = !developmentMode) } internal val stateCodec: OidcStateCodec by lazy { createStateCodec() } + internal val sessionRefreshPath: String by lazy { oidcRoutePath(sessionConfig.refreshUri) } + + internal val sessionLogoutPath: String by lazy { oidcRoutePath(sessionConfig.logoutUri) } + + private val tokenRefreshMutex = Mutex() + private val tokenRefreshes = HashMap>() + internal val canIntrospectOpaqueToken: Boolean = config.accessTokenConfig?.opaqueToken is OpaqueTokenStrategy.Introspect @@ -142,6 +160,53 @@ public class OidcProvider

internal constructor( return OidcStateCodec(encryptionKey) } + /** + * Refreshes token material for this provider using the supplied refresh token. + * + * @param refreshToken Refresh token to send to the provider token endpoint. + * @return Raw token response fields and an optional verified ID-token principal. + * @throws IllegalArgumentException when OAuth is not enabled. + */ + public suspend fun refreshToken(refreshToken: String): OidcTokenRefreshResult { + val (refresh, inProgress) = tokenRefreshMutex.withLock { + tokenRefreshes[refreshToken]?.let { return@withLock it to true } + val pending = CompletableDeferred() + tokenRefreshes[refreshToken] = pending + pending to false + } + + if (inProgress) { + return refresh.await() + } + + try { + val result = refreshTokenInternal(refreshToken) + refresh.complete(result) + return result + } catch (cause: Exception) { + refresh.completeExceptionally(cause) + throw cause + } finally { + tokenRefreshMutex.withLock { + tokenRefreshes.remove(refreshToken) + } + } + } + + /** + * Builds an RP-initiated logout URL for the provider. + * + * Local plugin-managed logout clears the local session before building this URL, so a failure to build or reach + * the provider logout URL does not restore the local session. + * + * @param idTokenHint ID token hint to pass to the provider logout endpoint. + * @param postLogoutRedirectUri Optional absolute URI to receive the user after the provider logout. + * @return Provider logout URL. + * @throws IllegalArgumentException when [idTokenHint] is blank or metadata does not expose an end-session endpoint. + */ + public fun buildLogoutUrl(idTokenHint: String, postLogoutRedirectUri: String?): String = + buildLogoutUrlInternal(idTokenHint, postLogoutRedirectUri) + /** * Typed Bearer authentication scheme. * @@ -150,6 +215,16 @@ public class OidcProvider

internal constructor( * @throws IllegalStateException when the provider was not configured with `bearer { }`. */ public val bearer: OidcBearerScheme

by lazy { createBearerScheme() } + + /** + * Typed browser session authentication scheme. + * + * OpenID Connect stores the raw [OidcToken.Id] in a provider-specific session, then maps that value + * to [P] for routes protected with `authenticateWith(provider.sessions)`. + * + * @throws IllegalStateException when the provider was not configured with `sessions { }`. + */ + public val sessions: OidcSessionsScheme

get() = oauthSessionFlow.sessions } private class OidcProviderState( diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcSessionRefresh.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcSessionRefresh.kt new file mode 100644 index 00000000000..af86fa42a6c --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcSessionRefresh.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalKtorApi::class, ExperimentalTime::class) + +package io.ktor.server.auth.oidc + +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.routing.* +import io.ktor.server.sessions.* +import io.ktor.utils.io.* +import kotlinx.coroutines.CancellationException +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +context(ctx: RoutingContext) +internal suspend fun

OidcProvider

.refreshSessionIfNeeded(token: OidcToken.Id): OidcToken.Id? { + if (managesRoute()) { + return token + } + val now = Clock.System.now() + val sessionConfig = checkNotNull(config.sessionConfig) + return when (val strategy = sessionConfig.tokenRefreshStrategy) { + is OidcTokenRefreshStrategy.Auto -> refreshSessionAutomatically(token, strategy.beforeExpiry, now) + is OidcTokenRefreshStrategy.Disabled -> keepSessionIfNotExpired(token, now) + is OidcTokenRefreshStrategy.Custom -> refreshSession { strategy.refresh(provider = this, token) } + } +} + +context(ctx: RoutingContext) +private fun OidcProvider<*>.keepSessionIfNotExpired(token: OidcToken.Id, now: Instant): OidcToken.Id? { + if (!token.isExpired(now)) { + return token + } + logger.debug("OpenID Connect session expired") + clearOidcSession() + return null +} + +context(ctx: RoutingContext) +private suspend fun

OidcProvider

.refreshSessionAutomatically( + token: OidcToken.Id, + beforeExpiry: Duration, + now: Instant, +): OidcToken.Id? { + if (!token.shouldRefresh(now, beforeExpiry)) { + return token + } + + val refreshTokenValue = token.refreshToken ?: run { + logger.debug("OpenID Connect session has no refresh token") + clearOidcSession() + return null + } + + return refreshSession { refreshToken(refreshTokenValue).idToken } +} + +context(ctx: RoutingContext) +private suspend fun OidcProvider<*>.refreshSession( + refresh: suspend () -> OidcToken.Id? +): OidcToken.Id? { + val newToken = try { + refresh() + } catch (cause: CancellationException) { + throw cause + } catch (cause: Throwable) { + logger.debug("OpenID Connect session refresh failed: {}", cause.message) + clearOidcSession() + return null + } + return newToken ?: run { + logger.debug("OpenID Connect session refresh did not return an ID token") + clearOidcSession() + return@run null + } +} + +private fun OidcToken.Id.isExpired(now: Instant): Boolean = + shouldRefresh(now, beforeExpiry = Duration.ZERO) + +private fun OidcToken.Id.shouldRefresh(now: Instant, beforeExpiry: Duration): Boolean = + claims.expiresAt?.let { it <= now + beforeExpiry } ?: false + +context(ctx: RoutingContext) +private fun OidcProvider<*>.clearOidcSession() { + ctx.call.sessions.clear(oauthSessionFlow.sessions.name) +} + +context(ctx: RoutingContext) +private fun OidcProvider<*>.managesRoute(): Boolean { + val path = ctx.call.request.path() + val isManaged = path == sessionRefreshPath || path == sessionLogoutPath + return ctx.call.request.httpMethod == HttpMethod.Post && isManaged +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokenRefreshStrategy.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokenRefreshStrategy.kt new file mode 100644 index 00000000000..667f6f879e0 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokenRefreshStrategy.kt @@ -0,0 +1,64 @@ +/* + * 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.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Strategy used to refresh OpenID Connect browser sessions. + * + * @param P provider principal type exposed to route handlers. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcTokenRefreshStrategy) + */ +public sealed interface OidcTokenRefreshStrategy { + /** + * Refreshes the session automatically before it expires. + * + * Refresh timing uses [OidcToken.Id.claims] [io.ktor.server.auth.oidc.TokenClaims.expiresAt]. + * When the ID token has no `exp` claim, auto-refresh never triggers. + * + * @property beforeExpiry how long before ID-token expiration the plugin should refresh the session. + */ + public class Auto( + public val beforeExpiry: Duration = 30.seconds, + ) : OidcTokenRefreshStrategy { + init { + require(beforeExpiry.isFinite() && !beforeExpiry.isNegative()) { + "beforeExpiry must be finite and non-negative" + } + } + } + + /** + * Disables automatic refresh. + * + * Expired ID-token sessions are still rejected on user routes. Expiry uses + * [OidcToken.Id.claims] [io.ktor.server.auth.oidc.TokenClaims.expiresAt]; when the ID token has no + * `exp` claim, the session is never treated as expired. + */ + public object Disabled : OidcTokenRefreshStrategy + + /** + * Custom session refresh policy. + * + * The callback is invoked for every session-authenticated user request. + * + * @param P provider principal type exposed to route handlers. + */ + public fun interface Custom

: OidcTokenRefreshStrategy

{ + /** + * Returns the effective ID-token session for this request. + * + * Return the current [token] to keep the session unchanged, a new [OidcToken.Id] to update stored + * session material, or `null` to invalidate the session. + * + * @param provider provider that authenticated the session. + * @param token current ID-token session. + */ + public suspend fun refresh(provider: OidcProvider

, token: OidcToken.Id): OidcToken.Id? + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokens.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokens.kt index ae6583913a8..57f509d18e7 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokens.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/OidcTokens.kt @@ -57,6 +57,45 @@ private enum class JwtTokenType { private val hmacAlgorithms = setOf("HS256", "HS384", "HS512") private const val BEARER_TOKEN_TYPE = "Bearer" +internal suspend fun OidcProvider<*>.refreshTokenInternal(refreshToken: String): OidcTokenRefreshResult { + val config = oauthConfig + val response = client.submitForm( + url = currentMetadata().tokenEndpoint, + formParameters = Parameters.build { + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) + append("client_id", config.clientId) + append("client_secret", config.clientSecret) + config.resourceIndicators.forEach { append("resource", it) } + } + ).body() + + val effectiveRefreshToken = response.refreshToken ?: refreshToken + + val idToken = if (response.idToken != null) { + requireBearerTokenType(response.tokenType) + buildIdToken( + idToken = response.idToken, + accessToken = response.accessToken, + refreshToken = effectiveRefreshToken, + expectedAudience = config.idTokenAudience ?: config.clientId, + requireNonceAbsent = true, + fetchUserInfo = config.fetchUserInfo, + ) + } else { + null + } + + return OidcTokenRefreshResult( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + expiresIn = response.expiresIn?.seconds, + tokenType = response.tokenType, + scope = response.scope, + idToken = idToken, + ) +} + internal suspend fun OidcProvider<*>.buildOAuthToken( response: OAuthAccessTokenResponse.OAuth2, expectedNonce: String?, @@ -383,3 +422,25 @@ private fun DecodedJWT.validateAtHash(accessToken: String?) { "ID token at_hash does not match the access token" } } + +internal fun OidcProvider<*>.buildLogoutUrlInternal( + idTokenHint: String, + postLogoutRedirectUri: String?, +): String { + require(idTokenHint.isNotBlank()) { + "idTokenHint must not be blank" + } + val endSessionEndpoint = requireNotNull(currentMetadata().endSessionEndpoint) { + "endSessionEndpoint is not provided" + } + val builder = URLBuilder(endSessionEndpoint).apply { + parameters.append("id_token_hint", idTokenHint) + config.oauthConfig?.clientId?.let { clientId -> + parameters.append("client_id", clientId) + } + postLogoutRedirectUri?.let { uri -> + parameters.append("post_logout_redirect_uri", uri) + } + } + return builder.buildString() +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/TokenRefreshResponse.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/TokenRefreshResponse.kt new file mode 100644 index 00000000000..23350704dfb --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/src/io/ktor/server/auth/oidc/TokenRefreshResponse.kt @@ -0,0 +1,50 @@ +/* + * 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 kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration + +@Serializable +internal data class TokenRefreshResponse( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("expires_in") + val expiresIn: Int? = null, + @SerialName("refresh_token") + val refreshToken: String? = null, + @SerialName("id_token") + val idToken: String? = null, + val scope: String? = null, +) + +/** + * Token response returned by [refreshToken]. + * + * Contains raw token response fields, so applications that manage their own tokens can decide how to + * persist, rotate, or expose token material. + * + * @property accessToken Access token returned by the token endpoint. + * @property refreshToken Raw refresh token returned by the token endpoint, or `null` when the provider + * did not rotate or return one. Persist [refreshToken] when it is present; otherwise keep the refresh token + * used for the request. + * @property expiresIn Token lifetime as [Duration], or `null` when unavailable. + * @property tokenType Token type returned by the token endpoint. + * @property scope Scope string returned by the token endpoint, or `null` when unavailable. + * @property idToken Verified ID-token when [idToken] is present, otherwise `null`. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.oidc.OidcTokenRefreshResult) + */ +public class OidcTokenRefreshResult( + public val accessToken: String, + public val refreshToken: String? = null, + public val expiresIn: Duration? = null, + public val tokenType: String, + public val scope: String? = null, + public val idToken: OidcToken.Id? = null, +) diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcConfigValidationTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcConfigValidationTest.kt index 70527dc307f..31cc6821cd3 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcConfigValidationTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcConfigValidationTest.kt @@ -10,6 +10,7 @@ import ch.qos.logback.classic.Level import io.ktor.client.request.* import io.ktor.http.* import io.ktor.server.auth.oidc.utils.* +import io.ktor.server.sessions.* import io.ktor.server.testing.* import io.ktor.utils.io.* import kotlin.test.* @@ -125,12 +126,48 @@ class OidcConfigValidationTest { assertContains(resources, "https://mcp.example.com") } + @Test + fun `session config stores routes names storage and csrf settings`() { + val customStorage = SessionStorageMemory() + + OidcProviderConfig("sessions", OidcToken::class).apply { + assertNull(sessionConfig) + sessions { + refreshUri = { path("custom", "refresh") } + logoutUri = { path("custom", "logout") } + name = "CUSTOM" + storage = customStorage + disableCsrfProtection() + } + assertNotNull(sessionConfig!!.refreshUri) + assertNotNull(sessionConfig!!.logoutUri) + assertEquals("CUSTOM", sessionConfig!!.name) + assertSame(customStorage, sessionConfig!!.storage) + assertNull(sessionConfig!!.csrfConfigurer) + } + + OidcProviderConfig("csrf", OidcToken::class).apply { + sessions { + csrfProtection { + allowOrigin("https://example.com") + } + } + assertNotNull(sessionConfig!!.csrfConfigurer) + } + } + @Test fun `bearer token source defaults to authorization header unless customized`() { OidcProviderConfig("default", OidcToken::class).apply { bearer() assertNull(bearerConfig!!.tokenExtractor) } + OidcProviderConfig("session", OidcToken::class).apply { + sessions() + bearer() + assertNull(bearerConfig!!.tokenExtractor) + assertNotNull(sessionConfig!!.csrfConfigurer) + } OidcProviderConfig("custom", OidcToken::class).apply { bearer { tokenExtractor = { call -> call.request.headers["X-Token"] } @@ -139,6 +176,30 @@ class OidcConfigValidationTest { } } + @Test + fun `session storage memory warning is emitted only for production memory storage`() { + val customStorage = object : SessionStorage { + override suspend fun write(id: String, value: String) { + } + + override suspend fun invalidate(id: String) { + } + + override suspend fun read(id: String): String = error("not used") + } + + assertSessionStorageWarning(providerName = "auth0", configure = { sessions() }) { events -> + assertTrue(events.any { it.formattedMessage.contains("SessionStorageMemory") }) + } + assertSessionStorageWarning(providerName = "custom-storage", configure = { + sessions { + storage = customStorage + } + }) { events -> + assertTrue(events.none { it.formattedMessage.contains("SessionStorageMemory") }) + } + } + @Test fun `production oauth requires state encryption key`() { val failure = assertFailsWith { @@ -201,6 +262,34 @@ class OidcConfigValidationTest { } } + private fun assertSessionStorageWarning( + providerName: String, + configure: OidcProviderConfig.() -> Unit, + assertions: (List) -> Unit, + ) { + captureProviderLogs(providerName, Level.WARN).use { logs -> + testApplication { + serverConfig { + developmentMode = false + } + application { + val oidc = openIdConnect { } + oidc.provider(providerName) { + testIssuer() + oauth { + clientId = "client-id" + clientSecret = "client-secret" + stateEncryptionKey = testStateEncryptionKey() + } + configure() + } + } + startApplication() + } + assertions(logs.events) + } + } + private fun testStateEncryptionKey(): OidcStateEncryptionKey = OidcStateEncryptionKey.of(ByteArray(OidcStateEncryptionKey.KEY_SIZE) { it.toByte() }) } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcEnvironmentConfigTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcEnvironmentConfigTest.kt index 22b67795cdc..f179352f454 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcEnvironmentConfigTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcEnvironmentConfigTest.kt @@ -81,6 +81,93 @@ class OidcEnvironmentConfigTest { assertEquals("openid profile", authorizeUrl.parameters["scope"]) } + @Test + fun `typed environment provider can be extended in code`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + + environment { + config = oidcEnvironmentConfig(providerName = "google", withScopes = true) + } + externalServices { + hosts(ISSUER_URL) { + routing { + post("/token") { + respondAuthorizationCodeWithIdToken( + parameters = call.receiveParameters(), + idTokensByState = idTokensByState, + accessToken = "access-token", + ) + } + } + } + } + + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val google = oidc.provider( + name = "google", + transformPrincipal = { principal -> + val idToken = principal as? OidcToken.Id + idToken?.userInfo?.subject?.let(::UserIdPrincipal) + } + ) { + metadata = testOpenIdProviderMetadata(issuer) + jwt(keys) + sessions { + name = OIDC_TEST_SESSION_NAME + cookie { + cookie.secure = false + } + disableCsrfProtection() + } + oauth { + loginUri = { path("google", "login") } + onSuccess { principal -> + call.respondText(principal.name) + } + onFailure = { + } + } + } + + routing { + authenticateWith(google.sessions) { + get("/typed") { + call.respondText(principal.name) + } + } + } + } + + val browser = noRedirectsClient() + val login = browser.prepareOidcLogin("google") { + url { path("google", "login") } + } + assertEquals("/authorize", login.authorizeUrl.encodedPath) + assertEquals("client-id", login.authorizeUrl.parameters["client_id"]) + assertEquals("openid profile", login.authorizeUrl.parameters["scope"]) + idTokensByState[login.state] = keys.idToken(subject = "env-typed-user") { + audience = "client-id" + nonce = login.nonce + } + + val callback = browser.completeOidcCallback(login, providerName = "google") + assertEquals(HttpStatusCode.OK, callback.status) + assertEquals("env-typed-user", callback.bodyAsText()) + val cookie = assertNotNull(callback.oidcSessionCookieHeader()) + + val typed = browser.get("/typed") { + header(HttpHeaders.Cookie, cookie) + } + assertEquals(HttpStatusCode.OK, typed.status) + assertEquals("env-typed-user", typed.bodyAsText()) + } + @Test fun `failed provider configuration does not consume environment provider config`() = testApplication { environment { diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcPluginRegistrationTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcPluginRegistrationTest.kt index 9c80d9e062e..37c2eb8e641 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcPluginRegistrationTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcPluginRegistrationTest.kt @@ -76,6 +76,7 @@ class OidcPluginRegistrationTest { }, ) assertFailsWith { providerWithoutSchemes.bearer } + assertFailsWith { providerWithoutSchemes.sessions } startApplication() } @@ -144,6 +145,52 @@ class OidcPluginRegistrationTest { } } + @Test + fun `typed route registration rejects derived scheme name collisions`() { + val secondIssuer = "https://okta.example.com" + val failure = assertFailsWith { + testApplication { + application { + val oidc = openIdConnect { } + val auth0 = oidc.provider("auth0") { + testIssuer() + accessToken { + audiences = setOf("api") + } + bearer() + } + val okta = oidc.provider("okta") { + testIssuer(secondIssuer) + sessions { + name = "auth0-bearer" + } + oauth { + clientId = "client-id" + clientSecret = "client-secret" + } + } + + routing { + authenticateWith(auth0.bearer) { + get("/auth0") { + call.respondText("auth0") + } + } + authenticateWith(okta.sessions) { + get("/okta") { + call.respondText("okta") + } + } + } + } + startApplication() + } + } + + assertContains(failure.message.orEmpty(), "auth0-bearer") + assertContains(failure.message.orEmpty(), "already registered") + } + private fun assertConcurrentDuplicateRegistrations( providerNames: List, expectedFailureMessage: String, diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcSessionRoutesTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcSessionRoutesTest.kt new file mode 100644 index 00000000000..b6f83045c89 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/OidcSessionRoutesTest.kt @@ -0,0 +1,370 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalKtorApi::class, ExperimentalTime::class) + +package io.ktor.server.auth.oidc + +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.auth.oidc.utils.* +import io.ktor.server.response.* +import io.ktor.server.testing.* +import io.ktor.utils.io.* +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +class OidcSessionRoutesTest { + + @Test + fun `auto refresh updates session before user route handler`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + val refreshCalls = AtomicInteger() + + openIdRefreshProvider(idTokensByState) { + respondRefreshedIdToken(keys, refreshCalls = refreshCalls) + } + installSessionTestApp(keys) { + tokenRefreshStrategy = OidcTokenRefreshStrategy.Auto(beforeExpiry = 30.seconds) + } + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = 10.seconds) + + browser.assertMe(cookie, HttpStatusCode.OK, "refreshed-user") + assertEquals(1, refreshCalls.get()) + } + + @Test + fun `auto refresh shares in flight refresh for same refresh token`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + val refreshCalls = AtomicInteger() + + openIdRefreshProvider(idTokensByState) { + respondRefreshedIdToken(keys, refreshCalls = refreshCalls) + } + installSessionTestApp(keys) { + tokenRefreshStrategy = OidcTokenRefreshStrategy.Auto(beforeExpiry = 30.seconds) + } + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = 10.seconds) + + coroutineScope { + List(2) { + launch { + browser.assertMe(cookie, HttpStatusCode.OK, "refreshed-user") + } + }.joinAll() + } + assertEquals(1, refreshCalls.get()) + } + + @Test + fun `auto refresh keeps session when token is outside refresh window`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + val refreshCalls = AtomicInteger() + + openIdRefreshProvider(idTokensByState) { + refreshCalls.incrementAndGet() + call.respond(HttpStatusCode.InternalServerError) + } + installSessionTestApp(keys) { + tokenRefreshStrategy = OidcTokenRefreshStrategy.Auto(beforeExpiry = 5.seconds) + } + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = 60.seconds) + + browser.assertMe(cookie, HttpStatusCode.OK, "session-user") + assertEquals(0, refreshCalls.get()) + } + + @Test + fun `disabled refresh rejects expired session on user route`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + + openIdRefreshProvider(idTokensByState) { + call.respond(HttpStatusCode.InternalServerError) + } + installSessionTestApp(keys) + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = (-1).seconds) + + browser.assertMe(cookie, HttpStatusCode.Unauthorized) + } + + @Test + fun `logout route clears expired session`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + + openIdRefreshProvider(idTokensByState) { + call.respond(HttpStatusCode.InternalServerError) + } + installSessionTestApp(keys) + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = (-1).seconds) + + val logout = browser.post("/oidc/auth0/logout") { + header(HttpHeaders.Cookie, cookie) + } + assertEquals(HttpStatusCode.SeeOther, logout.status) + + browser.assertMe(cookie, HttpStatusCode.Unauthorized) + } + + @Test + fun `explicit refresh route can refresh expired session`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + val refreshCalls = AtomicInteger() + + openIdRefreshProvider(idTokensByState) { + respondRefreshedIdToken(keys, refreshCalls = refreshCalls) + } + installSessionTestApp(keys) + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = (-1).seconds) + + val refresh = browser.post("/oidc/auth0/refresh") { + header(HttpHeaders.Cookie, cookie) + } + assertEquals(HttpStatusCode.OK, refresh.status) + assertEquals(1, refreshCalls.get()) + + val refreshedCookie = refresh.oidcSessionCookieHeader() ?: cookie + browser.assertMe(refreshedCookie, HttpStatusCode.OK, "refreshed-user") + } + + @Test + fun `explicit refresh route is not auto refreshed`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + val refreshCalls = AtomicInteger() + + openIdRefreshProvider(idTokensByState) { + respondRefreshedIdToken(keys, refreshCalls = refreshCalls) + } + installSessionTestApp(keys) { + tokenRefreshStrategy = OidcTokenRefreshStrategy.Auto(beforeExpiry = 30.seconds) + } + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = 10.seconds) + + val refresh = browser.post("/oidc/auth0/refresh") { + header(HttpHeaders.Cookie, cookie) + } + assertEquals(HttpStatusCode.OK, refresh.status) + assertEquals(1, refreshCalls.get()) + } + + @Test + fun `auto refresh clears session when refreshed response has no id token`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + + openIdRefreshProvider(idTokensByState) { + call.respondText( + openIdTestJson.encodeToString( + TokenRefreshResponse(accessToken = "access-token-only", tokenType = "Bearer") + ), + ContentType.Application.Json, + ) + } + installSessionTestApp(keys) { + tokenRefreshStrategy = OidcTokenRefreshStrategy.Auto(beforeExpiry = 30.seconds) + } + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, expiresIn = 10.seconds) + + browser.assertMe(cookie, HttpStatusCode.Unauthorized) + browser.assertMe(cookie, HttpStatusCode.Unauthorized) + } + + @Test + fun `custom refresh can keep or refresh session`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + val refreshCalls = AtomicInteger() + val keepCurrent = AtomicInteger() + + openIdRefreshProvider(idTokensByState) { + respondRefreshedIdToken(keys, refreshCalls = refreshCalls) + } + installSessionTestApp(keys) { + tokenRefreshStrategy = OidcTokenRefreshStrategy.Custom { provider, token -> + when (keepCurrent.getAndIncrement()) { + 0 -> token + 1 -> provider.refreshToken(checkNotNull(token.refreshToken)).idToken + else -> null + } + } + } + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys) + + browser.assertMe(cookie, HttpStatusCode.OK, "session-user") + assertEquals(0, refreshCalls.get()) + + browser.assertMe(cookie, HttpStatusCode.OK, "refreshed-user") + assertEquals(1, refreshCalls.get()) + + browser.assertMe(cookie, HttpStatusCode.Unauthorized) + } + + @Test + fun `session refresh rejects invalid refreshed principals and keeps existing session`() { + val cases = listOf( + "missing id token" to { _: OpenIdTestKeys -> + TokenRefreshResponse(accessToken = "access-token-only", tokenType = "Bearer") + }, + "id token contains nonce" to { keys: OpenIdTestKeys -> + TokenRefreshResponse( + accessToken = "access-token-2", + tokenType = "Bearer", + idToken = keys.idToken(subject = "refreshed-user") { + audience = "client-id" + nonce = "unexpected-nonce" + }, + ) + }, + ) + + for ((caseName, response) in cases) { + testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + openIdRefreshProvider(idTokensByState) { + call.respondText(openIdTestJson.encodeToString(response(keys)), ContentType.Application.Json) + } + installSessionTestApp(keys) + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys) + val refresh = browser.post("/oidc/auth0/refresh") { + header(HttpHeaders.Cookie, cookie) + } + assertEquals(HttpStatusCode.Unauthorized, refresh.status, caseName) + + browser.assertMe(cookie, HttpStatusCode.OK, "session-user") + } + } + } + + @Test + fun `logout post_logout_redirect_uri reflects incoming request host`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + openIdProvider(keys, idTokensByState) + installSessionTestApp( + keys = keys, + meResponse = { call.respondText("ok") }, + ) + + val browser = noRedirectsClient() + val sessionCookie = browser.signInWithIdToken(idTokensByState, keys) + + val logout = browser.post("/oidc/auth0/logout") { + header(HttpHeaders.Cookie, sessionCookie) + header(HttpHeaders.Host, "foodies.local") + header(HttpHeaders.Origin, "http://foodies.local") + } + assertEquals(HttpStatusCode.SeeOther, logout.status) + val logoutUrl = Url(assertNotNull(logout.headers[HttpHeaders.Location])) + assertEquals("http://foodies.local/", logoutUrl.parameters["post_logout_redirect_uri"]) + } + + @Test + fun `session oauth callback without id token rejects access and opaque tokens`() { + val cases = listOf("access", "opaque") + for (caseName in cases) { + testApplication { + val keys = testRsaKeys + installOAuthExternalProvider(caseName, keys, emptyMap()) + installSessionTestApp( + keys = keys, + endSessionEndpoint = null, + configureProvider = { + when (caseName) { + "access" -> { + jwt(keys) + accessToken { + audiences = setOf("api") + } + } + + "opaque" -> accessToken { + audiences = setOf("api") + opaqueToken = OpaqueTokenStrategy.Introspect( + endpoint = "$ISSUER_URL/introspect", + clientId = "resource-server", + clientSecret = "secret", + ) + } + + else -> jwt(keys) + } + }, + meResponse = { idToken -> + call.respondText("id:${idToken.userInfo.subject}") + }, + ) + + val browser = noRedirectsClient() + val login = browser.prepareOidcLogin() + val callback = browser.completeOidcCallback(login) + assertEquals(HttpStatusCode.Unauthorized, callback.status, caseName) + assertNull(callback.oidcSessionCookieHeader(), caseName) + + val profile = browser.get("/me") + assertEquals(HttpStatusCode.Unauthorized, profile.status, caseName) + } + } + } + + @Test + fun `logout redirects locally for id token session without OP logout`() = testApplication { + val keys = testRsaKeys + val idTokensByState = ConcurrentHashMap() + installOAuthExternalProvider("id-without-end-session", keys, idTokensByState) + installSessionTestApp( + keys = keys, + endSessionEndpoint = null, + meResponse = { idToken -> call.respondText("id:${idToken.userInfo.subject}") }, + ) + + val browser = noRedirectsClient() + val cookie = browser.signInWithIdToken(idTokensByState, keys, subject = "id-token-user") + + browser.assertMe(cookie, HttpStatusCode.OK, "id:id-token-user") + + val logout = browser.post("/oidc/auth0/logout") { + header(HttpHeaders.Cookie, cookie) + } + assertEquals(HttpStatusCode.SeeOther, logout.status) + assertEquals("http://localhost/", logout.headers[HttpHeaders.Location]) + + browser.assertMe(cookie, HttpStatusCode.Unauthorized) + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcSessionTestUtils.kt b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcSessionTestUtils.kt new file mode 100644 index 00000000000..53ebb2f574d --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-auth-oidc/jvm/test/io/ktor/server/auth/oidc/utils/OidcSessionTestUtils.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2014-2026 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:OptIn(ExperimentalKtorApi::class, ExperimentalTime::class) + +package io.ktor.server.auth.oidc.utils + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.auth.oidc.* +import io.ktor.server.auth.typesafe.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import io.ktor.utils.io.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.ExperimentalTime + +internal val openIdTestJson = Json { ignoreUnknownKeys = true } + +internal fun TestApplicationBuilder.openIdRefreshProvider( + idTokensByState: Map, + refreshResponse: suspend RoutingContext.() -> Unit, +) { + externalServices { + hosts(ISSUER_URL) { + routing { + post("/token") { + val parameters = call.receiveParameters() + when (parameters["grant_type"]) { + "authorization_code" -> respondAuthorizationCodeWithIdToken( + parameters = parameters, + idTokensByState = idTokensByState, + accessToken = "access-token-1", + ) + + "refresh_token" -> refreshResponse() + else -> call.respond(HttpStatusCode.BadRequest) + } + } + } + } + } +} + +internal suspend fun RoutingContext.respondRefreshedIdToken( + keys: OpenIdTestKeys, + subject: String = "refreshed-user", + refreshCalls: AtomicInteger? = null, +) { + refreshCalls?.incrementAndGet() + call.respondText( + openIdTestJson.encodeToString( + TokenRefreshResponse( + accessToken = "access-token-2", + tokenType = "Bearer", + refreshToken = "refresh-token-2", + idToken = keys.idToken(subject = subject) { + audience = "client-id" + }, + ) + ), + ContentType.Application.Json, + ) +} + +internal fun ApplicationTestBuilder.installSessionTestApp( + keys: OpenIdTestKeys, + endSessionEndpoint: String? = "$ISSUER_URL/logout", + configureProvider: OidcProviderConfig.() -> Unit = { jwt(keys) }, + meResponse: suspend RoutingContext.(OidcToken.Id) -> Unit = { idToken -> + call.respondText(idToken.userInfo.subject) + }, + configureSessions: OidcSessionConfig.() -> Unit = {}, +) { + val openIdClient = openIdHttpClient() + application { + val oidc = openIdConnect { + httpClient = openIdClient + discoveryRefreshInterval = ZERO + } + val oidcProvider = oidc.provider("auth0") { + testIssuer(metadata = browserFlowMetadata(endSessionEndpoint = endSessionEndpoint)) + sessions { + name = OIDC_TEST_SESSION_NAME + cookie { + cookie.secure = false + cookie.httpOnly = true + } + disableCsrfProtection() + configureSessions() + } + oauth { + clientId = "client-id" + clientSecret = "client-secret" + onSuccess { call.respondText("signed in") } + } + configureProvider() + } + + routing { + authenticateWith(oidcProvider.sessions) { + get("/me") { + val idToken = principal as OidcToken.Id + meResponse(idToken) + } + } + } + } +} + +internal fun TestApplicationBuilder.installOAuthExternalProvider( + caseName: String, + keys: OpenIdTestKeys, + idTokensByState: Map, +) { + externalServices { + hosts(ISSUER_URL) { + routing { + post("/token") { + val parameters = call.receiveParameters() + assertAuthorizationCodeRequest(parameters) + val responseParameters = when (caseName) { + "access" -> listOf( + "access_token" to keys.accessToken { + subject = "access-token-user" + }, + "token_type" to "Bearer", + "expires_in" to "3600", + ) + + "opaque" -> listOf( + "access_token" to "opaque-login-token", + "token_type" to "Bearer", + "expires_in" to "3600", + ) + + else -> { + respondAuthorizationCodeWithIdToken( + parameters = parameters, + idTokensByState = idTokensByState, + accessToken = keys.accessToken { + subject = "token-user" + }, + ) + return@post + } + } + call.respondText(responseParameters.formUrlEncode(), ContentType.Application.FormUrlEncoded) + } + post("/introspect") { + assertEquals("opaque-login-token", call.receiveParameters()["token"]) + call.respondText( + """{"active":true,"sub":"opaque-token-user","aud":["api"],"scope":"openid"}""", + ContentType.Application.Json, + ) + } + } + } + } +} + +internal suspend fun HttpClient.signInWithIdToken( + idTokensByState: MutableMap, + keys: OpenIdTestKeys, + subject: String = "session-user", + expiresIn: Duration? = null, +): String { + val login = prepareOidcLogin() + idTokensByState[login.state] = keys.idToken(subject = subject) { + audience = "client-id" + login.nonce?.let { nonce = it } + expiresIn?.let { expiresAt = Clock.System.now().plus(it) } + } + val callback = completeOidcCallback(login) + assertEquals(HttpStatusCode.OK, callback.status) + return assertNotNull(callback.oidcSessionCookieHeader()) +} + +internal suspend fun HttpClient.assertMe( + cookie: String, + expectedStatus: HttpStatusCode, + expectedBody: String? = null, +) { + val response = get("/me") { + header(HttpHeaders.Cookie, cookie) + } + assertEquals(expectedStatus, response.status) + if (expectedBody != null) { + assertEquals(expectedBody, response.bodyAsText()) + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.api b/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.api index 0d77c500790..a5b7df1df62 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.api +++ b/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.api @@ -897,6 +897,7 @@ public class io/ktor/server/auth/typesafe/TypedSessionAuthConfig { public final fun getContextFactory ()Lkotlin/jvm/functions/Function1; public final fun setContextFactory (Lkotlin/jvm/functions/Function1;)V public final fun storage (Lkotlin/jvm/functions/Function2;)V + public final fun transformSession (Lkotlin/jvm/functions/Function3;)V public final fun validate (Lkotlin/jvm/functions/Function3;)V } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.klib.api b/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.klib.api index f9aac8d7cba..b43203f45b6 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.klib.api +++ b/ktor-server/ktor-server-plugins/ktor-server-auth/api/ktor-server-auth.klib.api @@ -551,6 +551,7 @@ open class <#A: kotlin/Any, #B: kotlin/Any, #C: io.ktor.server.auth.typesafe/Ses final fun buildProvider(kotlin/String, kotlin.reflect/KClass<#A>, io.ktor.util/AttributeKey<#A>): io.ktor.server.auth/SessionAuthenticationProvider<#A> // io.ktor.server.auth.typesafe/TypedSessionAuthConfig.buildProvider|buildProvider(kotlin.String;kotlin.reflect.KClass<1:0>;io.ktor.util.AttributeKey<1:0>){}[0] final fun csrfProtection(kotlin/Function1) // io.ktor.server.auth.typesafe/TypedSessionAuthConfig.csrfProtection|csrfProtection(kotlin.Function1){}[0] final fun storage(kotlin/Function2, kotlin/Unit>) // io.ktor.server.auth.typesafe/TypedSessionAuthConfig.storage|storage(kotlin.Function2,kotlin.Unit>){}[0] + final fun transformSession(kotlin.coroutines/SuspendFunction2) // io.ktor.server.auth.typesafe/TypedSessionAuthConfig.transformSession|transformSession(kotlin.coroutines.SuspendFunction2){}[0] final fun validate(kotlin.coroutines/SuspendFunction2) // io.ktor.server.auth.typesafe/TypedSessionAuthConfig.validate|validate(kotlin.coroutines.SuspendFunction2){}[0] } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth/common/src/io/ktor/server/auth/typesafe/TypedSessionAuthConfig.kt b/ktor-server/ktor-server-plugins/ktor-server-auth/common/src/io/ktor/server/auth/typesafe/TypedSessionAuthConfig.kt index 7eaa5cec700..6532845139b 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth/common/src/io/ktor/server/auth/typesafe/TypedSessionAuthConfig.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth/common/src/io/ktor/server/auth/typesafe/TypedSessionAuthConfig.kt @@ -21,6 +21,14 @@ import kotlin.reflect.KClass */ public typealias SessionPrincipalResolver = suspend RoutingContext.(S) -> P? +/** + * Transforms a session value before principal resolution. + * + * Return the session value that should be validated for the current call, or `null` to reject the session. + */ +@OptIn(ExperimentalKtorApi::class) +public typealias SessionTransformer = suspend RoutingContext.(S) -> S? + /** * Configures the [Sessions] plugin for a typed session authentication scheme. * @@ -74,6 +82,8 @@ public open class TypedSessionAuthConfig< internal var principalResolver: SessionPrincipalResolver? = null + internal var sessionTransformer: SessionTransformer? = null + internal var csrfConfig: (CSRFConfig.() -> Unit)? = null internal var sessionsPluginConfig: SessionsPluginConfig? = null @@ -100,6 +110,24 @@ public open class TypedSessionAuthConfig< principalResolver = body } + /** + * Transforms the session value before [validate] resolves the route principal. + * + * This hook is intended for integrations that need to update or invalidate a stored session as part of + * authentication. Return the effective session value for this request, or `null` to reject the session. + * + * The stored session is rewritten only when the returned value is different instance as the incoming + * session (`!=`). Returning the same object skips [io.ktor.server.sessions.CurrentSession.set]. + * + * @param block transformation function called with the session value read by the + * [io.ktor.server.sessions.Sessions] plugin. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.auth.typesafe.TypedSessionAuthConfig.transformSession) + */ + public fun transformSession(block: SessionTransformer) { + sessionTransformer = block + } + /** * Configures CSRF protection for routes authenticated with this session scheme. * @@ -126,10 +154,21 @@ public open class TypedSessionAuthConfig< ): SessionAuthenticationProvider { val config = SessionAuthenticationProvider.Config(name, description, sessionType) val resolver = requireNotNull(principalResolver) { "Principal resolver cannot be null" } + val transformer = sessionTransformer config.validate { session -> - val principal = toRoutingContext().resolver(session) + val routingContext = toRoutingContext() + val effectiveSession = if (transformer != null) { + val updatedSession = routingContext.transformer(session) ?: return@validate null + if (updatedSession != session) { + sessions.set(name, updatedSession) + } + updatedSession + } else { + session + } + val principal = routingContext.resolver(effectiveSession) if (principal != null) { - attributes.put(sessionKey, session) + attributes.put(sessionKey, effectiveSession) } principal }