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 @@ -249,6 +249,7 @@ object Server : ServerBuilder() {
val proofDevices = path.path("proof").path("devices") module KnownDeviceProofEndpoints(database, cache)
val proofOauth = path.path("proof").path("github") module OauthProofEndpoints(
provider = OauthProviderInfo.github,
cache = cache,
credentials = githubOauth,
continueUiAuthUrl = { autosignIn.location.path.resolved().fullUrl() + "?proof=" + serverRuntime.externalSerialization.json.encodeToString(Proof.serializer(), it).encodeURLQueryComponent() + "&backend=" + generalSettings().publicUrl.encodeURLQueryComponent() }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public data class OauthTokenRequest(
val client_secret: String,
val redirect_uri: String? = null,
val grant_type: String = "authorization_code",
/** PKCE (RFC 7636) code verifier proving this client started the flow. Omitted when PKCE is disabled. */
val code_verifier: String? = null,
)

@Serializable
Expand All @@ -101,6 +103,10 @@ public data class OauthCodeRequest(
val prompt: OauthPromptType? = null,
val login_hint: String? = null,
val sessionExpiration: Instant? = null,
/** PKCE (RFC 7636) code challenge = BASE64URL-NOPAD(SHA256(code_verifier)). Omitted when PKCE is disabled. */
val code_challenge: String? = null,
/** PKCE transformation method; always "S256" when [code_challenge] is present. */
val code_challenge_method: String? = null,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.lightningkite.lightningserver.typed.ApiHttpHandler
import com.lightningkite.lightningserver.typed.sdk.SdkModule
import com.lightningkite.lightningserver.typed.sdk.SdkModule.Companion.defaultInfo
import com.lightningkite.lightningserver.typed.sdk.sdkSettings
import com.lightningkite.services.cache.Cache
import com.lightningkite.services.database.HasId
import io.ktor.http.*
import kotlin.time.Duration
Expand Down Expand Up @@ -51,8 +52,13 @@ import kotlin.uuid.Uuid
* )
* ```
*
* **Security:** The flow is protected against CSRF via a single-use `state` nonce and against
* authorization-code interception via PKCE (RFC 7636). Both are held in [cache] for the duration of
* the redirect round-trip and validated/consumed on the callback.
*
* @param proofSigner The signer used to create cryptographic proofs (defaults to derived from secretBasis)
* @param provider The OAuth provider configuration (Google, Apple, Microsoft, GitHub, or custom)
* @param cache Cache holding the transient CSRF `state` and PKCE verifier between redirect and callback
* @param credentials Function that returns the OAuth client credentials (ID and secret)
* @param continueUiAuthUrl Function that returns the UI URL to redirect to after successful authentication
*
Expand All @@ -61,6 +67,7 @@ import kotlin.uuid.Uuid
*/
public class OauthProofEndpoints(
private val provider: OauthProviderInfo,
private val cache: Runtime<Cache>,
override val proofSigner: RuntimeDeferred<Signer> = secretBasis.signer("proof"),
override val proofExpiration: Duration = 1.hours,
private val credentials: Runtime<OauthProviderCredentials>,
Expand Down Expand Up @@ -88,9 +95,14 @@ public class OauthProofEndpoints(
stateSerializer = serializerOrContextual<Uuid>(),
oauthProviderInfo = provider,
credentials = credentials,
cache = cache,
) { response: OauthResponse, _: Uuid ->
val profile = provider.getProfile(response, credentials())
val email = profile.email ?: throw BadRequestException("No email was found for this profile.")
// Open-redirect note: the final destination is produced entirely by the app-supplied
// `continueUiAuthUrl` from a server-generated, signed Proof. No user- or attacker-controllable
// value (query param or `state`) feeds into it, so the redirect target is app-controlled and
// does not require redirect-URI whitelisting here.
HttpResponse.redirectToGet(
continueUiAuthUrl(
proofSigner.await().makeProof(
Expand Down Expand Up @@ -161,7 +173,4 @@ public class OauthProofEndpoints(
*
* 5. Consider adding telemetry/metrics for OAuth login attempts, successes, and failures
* to help diagnose provider-specific issues.
*
* 6. The UUID state parameter in callback is generated but not validated. Consider using the
* state parameter for CSRF protection by storing and validating it.
*/
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.lightningkite.lightningserver.sessions.proofs.oauth

import com.lightningkite.lightningserver.BadRequestException
import com.lightningkite.lightningserver.definition.Runtime
import com.lightningkite.lightningserver.definition.builder.ServerBuilder
import com.lightningkite.lightningserver.http.*
Expand All @@ -10,44 +11,115 @@ import com.lightningkite.lightningserver.runtime.ServerRuntime
import com.lightningkite.lightningserver.runtime.location
import com.lightningkite.lightningserver.serialization.*
import com.lightningkite.lightningserver.sessions.proofs.oauth.path
import com.lightningkite.services.cache.Cache
import com.lightningkite.services.cache.getAndRemove
import com.lightningkite.services.cache.set
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes

public class OauthCallbackEndpoint<STATE>(
path: PathSpec0,
public val stateSerializer: KSerializer<STATE>,
public val oauthProviderInfo: OauthProviderInfo,
public val credentials: Runtime<OauthProviderCredentials>,
/**
* Cache used to hold transient per-flow data (the CSRF `state` marker and the PKCE code verifier)
* between the authorization redirect and the callback. Entries are single-use and short-lived.
*
* In multi-instance or serverless deployments this MUST be a shared cache (e.g. Redis, DynamoDB),
* not the in-memory `"ram"` cache: the callback can land on a different instance than the one that
* started the flow, and an unshared cache would fail to find the flow record, breaking login.
*/
public val cache: Runtime<Cache>,
public val defaultScope: String = oauthProviderInfo.scopeForProfile,
public val defaultAccessType: OauthAccessType = OauthAccessType.online,
/**
* How long an in-progress OAuth flow may sit in the cache before its `state`/verifier expire.
* Must comfortably cover the user's time at the provider (login, consent, MFA).
*/
public val flowExpiration: Duration = 10.minutes,
/**
* Invoked when the provider redirects back with an error. NOTE: this runs before `state` is
* validated, so the [OauthCode] passed here is unauthenticated and fully attacker-controllable —
* do not trust its fields for anything security-sensitive. The default simply throws.
*/
public val onError: suspend context(ServerRuntime) (OauthCode) -> HttpResponse = {
throw Exception("Got Oauth error from ${oauthProviderInfo.niceName}: ${it}")
},
public val onAccess: suspend context(ServerRuntime) (OauthResponse, STATE) -> HttpResponse,
) : ServerBuilder() {

/**
* Transient record for a single in-progress OAuth flow, keyed in the cache by the opaque `state`
* nonce that the provider echoes back. Storing both values together means one cache lookup on the
* callback validates the CSRF `state` and retrieves the PKCE verifier and caller state.
*
* `internal` (not `public`) so in-module tests can inspect stored flows; not part of the public API.
*/
@Serializable
internal data class FlowRecord(
/** The caller's `STATE` value, serialized with [stateSerializer], preserved across the round-trip. */
val state: String,
/** The PKCE code verifier for this flow, or null when the provider does not support PKCE. */
val codeVerifier: String?,
)

internal fun flowKey(nonce: String): String = "oauth-flow-${oauthProviderInfo.identifierName}-$nonce"

context(runtime: ServerRuntime)
public suspend fun handle(code: OauthCode): HttpResponse {
code.error?.let { onError(code) }
val response = oauthProviderInfo.accessToken(credentials, callback.location.path.resolved().fullUrl(), code)
return onAccess(response, runtime.externalSerialization.json.decodeFromString(stateSerializer, code.state!!))
code.error?.let { return onError(code) }
// CSRF protection (and PKCE): the `state` the provider echoed back must match a stored,
// unconsumed flow we issued. getAndRemove enforces single use, so a replayed callback fails.
// Limitation: the nonce is stored server-side but not bound to the initiating browser (this is
// a cookie-less proof design). That still stops the classic forged-callback CSRF, but not
// "login CSRF" where an attacker completes their own flow and hands the victim the resulting
// callback URL. Bind the flow to a browser cookie if that threat is in scope.
val nonce = code.state ?: throw BadRequestException("Missing OAuth state parameter.")
val record = cache().getAndRemove<FlowRecord>(flowKey(nonce))
?: throw BadRequestException("Invalid, expired, or already-used OAuth state.")
val response = oauthProviderInfo.accessToken(
credentials,
callback.location.path.resolved().fullUrl(),
code,
codeVerifier = record.codeVerifier,
)
return onAccess(response, runtime.externalSerialization.json.decodeFromString(stateSerializer, record.state))
}

context(runtime: ServerRuntime)
public fun loginUrl(
public suspend fun loginUrl(
state: STATE,
scope: String = defaultScope,
accessType: OauthAccessType = defaultAccessType,
loginHint: String? = null,
): String = oauthProviderInfo.loginUrl(
credentials = credentials,
redirectUri = callback.location.path.resolved().fullUrl(),
scope = scope,
state = runtime.externalSerialization.json.encodeToString(stateSerializer, state),
accessType = accessType,
loginHint = loginHint,
prompt = OauthPromptType.select_account
)
): String {
// The `state` we send to the provider is an opaque, single-use nonce (not the caller state),
// so nothing sensitive leaks through the provider and callbacks can be validated for CSRF.
val nonce = randomUrlToken()
val codeVerifier = if (oauthProviderInfo.supportsPkce) generatePkceCodeVerifier() else null
cache().set(
flowKey(nonce),
FlowRecord(
state = runtime.externalSerialization.json.encodeToString(stateSerializer, state),
codeVerifier = codeVerifier,
),
flowExpiration,
)
return oauthProviderInfo.loginUrl(
credentials = credentials,
redirectUri = callback.location.path.resolved().fullUrl(),
scope = scope,
state = nonce,
accessType = accessType,
loginHint = loginHint,
prompt = OauthPromptType.select_account,
codeChallenge = codeVerifier?.let { pkceCodeChallengeS256(it) },
)
}

public val callback: HttpHandler<PathSpec0> = when (oauthProviderInfo.mode) {
OauthResponseMode.form_post -> {
Expand All @@ -67,4 +139,3 @@ public class OauthCallbackEndpoint<STATE>(
public suspend fun accessToken(refreshToken: String): OauthResponse =
oauthProviderInfo.accessToken(credentials, refreshToken)
}

Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import kotlin.uuid.Uuid
* @property mode How the OAuth provider sends the authorization code (form_post or query)
* @property settings Configuration for credentials serialization (standard or provider-specific)
* @property scopeForProfile OAuth scopes required to retrieve user profile information
* @property supportsPkce Whether this provider accepts PKCE (RFC 7636) parameters on the authorization
* and token requests. Defaults to `true`; all major providers support (and recommend) PKCE. Set to
* `false` only for a non-compliant provider that rejects unknown `code_challenge`/`code_verifier` params.
* @property getProfile Async function that retrieves user profile from the provider
*/
public class OauthProviderInfo(
Expand All @@ -73,6 +76,7 @@ public class OauthProviderInfo(
public val mode: OauthResponseMode = OauthResponseMode.form_post,
public val settings: SettingInfo<*> = SettingInfo.standard,
public val scopeForProfile: String,
public val supportsPkce: Boolean = true,
public val getProfile: suspend context(ServerRuntime) (OauthResponse, OauthProviderCredentials?) -> ExternalProfile,
) {
public data class SettingInfo<T : Any>(
Expand All @@ -96,6 +100,7 @@ public class OauthProviderInfo(
accessType: OauthAccessType = OauthAccessType.online,
prompt: OauthPromptType? = if (accessType == OauthAccessType.offline) OauthPromptType.consent else null,
loginHint: String? = null,
codeChallenge: String? = null,
): String {
val params = OauthCodeRequest(
response_type = "code",
Expand All @@ -107,6 +112,8 @@ public class OauthProviderInfo(
access_type = accessType,
prompt = prompt,
login_hint = loginHint,
code_challenge = codeChallenge,
code_challenge_method = codeChallenge?.let { "S256" },
).let { FormDataFormat(EmptySerializersModule()).encodeToString(OauthCodeRequest.serializer(), it) }
return "$loginUrl?$params"
}
Expand All @@ -116,11 +123,12 @@ public class OauthProviderInfo(
credentials: Runtime<OauthProviderCredentials>,
redirectUri: String,
oauth: OauthCode,
codeVerifier: String? = null,
): OauthResponse {
oauth.error?.let {
throw BadRequestException("Got error code '${it}' from $niceName.")
} ?: oauth.code?.let { code ->
return client.post(tokenUrl) {
val httpResponse = client.post(tokenUrl) {
setBody(
FormDataFormat(EmptySerializersModule()).encodeToString(
OauthTokenRequest.serializer(),
Expand All @@ -130,12 +138,28 @@ public class OauthProviderInfo(
client_secret = credentials().secret,
redirect_uri = redirectUri,
grant_type = OauthGrantTypes.authorizationCode,
code_verifier = codeVerifier,
)
)
)
contentType(ContentType.Application.FormUrlEncoded)
accept(ContentType.Application.Json)
}.internalBody<OauthResponse>()
}
// Providers report token-exchange failures (expired/invalid code, PKCE mismatch, etc.)
// as a non-2xx status with an RFC 6749 §5.2 error body, e.g. {"error":"invalid_grant"}.
// That body doesn't satisfy OauthResponse's required fields, so decoding it directly would
// surface a raw MissingFieldException instead of a clean, expected BadRequestException.
// Check the status first and surface only the standardized `error` code - the provider's
// free-text `error_description` (or any other response content) is not relayed, since it's
// not meant for the end user and could contain provider-internal detail.
val bodyText = httpResponse.bodyAsText()
if (!httpResponse.status.isSuccess()) {
val errorCode = runCatching {
runtime.externalSerialization.json.parseToJsonElement(bodyText).jsonObject["error"]?.jsonPrimitive?.content
}.getOrNull()
throw BadRequestException("Token exchange with $niceName failed" + (errorCode?.let { " ($it)" } ?: "."))
}
return runtime.externalSerialization.json.decodeFromString(OauthResponse.serializer(), bodyText)
}
throw BadRequestException("Code is empty")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.lightningkite.lightningserver.sessions.proofs.oauth

import java.security.MessageDigest
import java.security.SecureRandom
import kotlin.io.encoding.Base64

/**
* Helpers implementing PKCE (Proof Key for Code Exchange, RFC 7636) and the CSRF `state` nonce
* used to secure the OAuth authorization-code flow.
*
* All values are drawn from a cryptographically secure random source and encoded with the
* URL-safe, unpadded BASE64URL alphabet (which is a subset of the RFC 3986 unreserved charset),
* so they are safe to place directly in URLs.
*/
private val secureRandom = SecureRandom()

/** BASE64URL encoding without padding, per RFC 7636 Appendix A. */
private fun base64UrlNoPad(bytes: ByteArray): String = Base64.UrlSafe.encode(bytes).trimEnd('=')

/** Generates an opaque, high-entropy token (43 chars) suitable for a `state` nonce or cache key. */
internal fun randomUrlToken(): String = base64UrlNoPad(ByteArray(32).also(secureRandom::nextBytes))

/**
* Generates a PKCE `code_verifier`: a 43-character high-entropy string from the unreserved
* charset, satisfying RFC 7636's 43-128 character requirement.
*/
internal fun generatePkceCodeVerifier(): String = randomUrlToken()

/** Computes the PKCE S256 `code_challenge` = BASE64URL-NOPAD(SHA256(verifier)). */
internal fun pkceCodeChallengeS256(verifier: String): String =
base64UrlNoPad(MessageDigest.getInstance("SHA-256").digest(verifier.encodeToByteArray()))
Loading
Loading