Last updated January 2025 (version-5)
Authentication is a fundamental concept in Lightning Server, and authentication works the same way across all endpoints. Multiple methods can be checked, and the system is highly customizable while providing sensible defaults.
Here's a complete example showing how to set up authentication with email/password and email PIN support:
import com.lightningkite.lightningserver.definition.builder.ServerBuilder
import com.lightningkite.lightningserver.auth.*
import com.lightningkite.lightningserver.sessions.*
import com.lightningkite.lightningserver.sessions.proofs.*
import com.lightningkite.services.database.*
import com.lightningkite.services.email.*
import kotlinx.serialization.*
import kotlin.uuid.Uuid
@Serializable
@GenerateDataClassPaths
data class User(
override val _id: Uuid = Uuid.random(),
override val email: String,
override val hashedPassword: String = "",
val isSuperUser: Boolean = false
) : HasId<Uuid>, HasEmail, HasPassword
object Server : ServerBuilder() {
val database = setting("database", Database.Settings())
val cache = setting("cache", Cache.Settings())
val email = setting("email", EmailService.Settings())
// Define your principal type (user authentication)
object UserAuth : PrincipalType<User, Uuid> {
override val idSerializer = Uuid.serializer()
override val subjectSerializer = User.serializer()
override val name = "User"
context(server: ServerRuntime)
override suspend fun fetch(id: Uuid): User =
database().table<User>().get(id) ?: throw NotFoundException()
context(server: ServerRuntime)
override suspend fun fetchByProperty(property: String, value: String): User? {
return when (property) {
"email" -> {
val existing = database().table<User>()
.findOne(condition { it.email eq value })
existing ?: database().table<User>()
.insertOne(User(email = value))
}
else -> super.fetchByProperty(property, value)
}
}
}
// Set up proof handlers
val pins = PinHandler(cache, "pins")
val proofEmail = path.path("proof").path("email") module EmailProofEndpoints(
pins = pins,
email = email,
emailBuilder = { to, pin ->
Email(
subject = "Your Login Code",
to = listOf(EmailAddressWithName(to)),
plainText = "Your PIN is: $pin"
)
}
)
val proofPassword = path.path("proof").path("password") module
PasswordProofEndpoints(database, cache)
// Set up authentication endpoints
val auth = path.path("auth") module object : AuthEndpoints<User, Uuid>(
principal = UserAuth,
database = database
) {
context(server: ServerRuntime)
override suspend fun requiredProofStrengthFor(subject: User): Int = 5
context(server: ServerRuntime)
override suspend fun sessionExpiration(subject: User): Instant? = null
context(server: ServerRuntime)
override suspend fun sessionStaleAfter(subject: User): Duration? = null
}
}PrincipalType defines how to work with a specific type of authenticated user:
object UserAuth : PrincipalType<User, Uuid> {
override val idSerializer = Uuid.serializer()
override val subjectSerializer = User.serializer()
override val name = "User"
context(server: ServerRuntime)
override suspend fun fetch(id: Uuid): User =
database().table<User>().get(id) ?: throw NotFoundException()
context(server: ServerRuntime)
override suspend fun fetchByProperty(property: String, value: String): User? {
return when (property) {
"email" -> {
// Find or create user by email
database().table<User>().findOne(condition { it.email eq value })
?: database().table<User>().insertOne(User(email = value))
}
else -> super.fetchByProperty(property, value)
}
}
}Lightning Server supports multiple authentication methods ("proofs"):
val pins = PinHandler(cache, "pins")
val proofEmail = path.path("proof").path("email") module EmailProofEndpoints(
pins = pins,
email = email,
emailBuilder = { to, pin ->
Email(
subject = "Your Login Code",
to = listOf(EmailAddressWithName(to)),
plainText = "Your PIN is: $pin",
html = "<h1>Your PIN is: $pin</h1>"
)
}
)val proofSms = path.path("proof").path("sms") module SmsProofEndpoints(
pins = pins,
sms = sms
)val proofPassword = path.path("proof").path("password") module
PasswordProofEndpoints(database, cache)val proofOtp = path.path("proof").path("otp") module
TimeBasedOTPProofEndpoints(database, cache)val proofDevices = path.path("proof").path("devices") module
KnownDeviceProofEndpoints(database, cache)val protectedEndpoint = path.path("protected").get bind ApiHttpHandler(
auth = UserAuth.require(),
summary = "Protected Endpoint",
implementation = { _: Unit ->
val user = auth.fetch()
"Hello, ${user.email}!"
}
)val optionalAuthEndpoint = path.path("optional").get bind ApiHttpHandler(
auth = UserAuth.require() or AuthRequirement.None,
summary = "Optional Auth",
implementation = { _: Unit ->
val user = authOrNull?.fetch()
if (user != null) {
"Hello, ${user.email}!"
} else {
"Hello, guest!"
}
}
)val userInfo = database.modelInfo(
auth = UserAuth.require() or AuthRequirement.None,
permissions = {
val user = authOrNull?.fetch()
val self = condition<User> { it._id eq user?._id }
val admin = if (user?.isSuperUser == true)
condition { it.always }
else
condition { it.never }
ModelPermissions(
create = condition { it.never },
read = self or admin,
update = self or admin,
delete = admin
)
}
)-
Client requests proof:
POST /proof/email/requestwith{"property": "email", "value": "user@example.com"}- Server sends email with PIN
-
Client submits proof:
POST /proof/email/provewith{"property": "email", "value": "user@example.com", "proof": "123456"}- Server returns a proof token
-
Client exchanges proof for session:
POST /auth/login-anonymouswith proof token- Server returns JWT session token
-
Client uses session:
- Include
Authorization: Bearer <token>header in requests
- Include
Control when sessions expire:
object : AuthEndpoints<User, Uuid>(principal = UserAuth, database = database) {
context(server: ServerRuntime)
override suspend fun requiredProofStrengthFor(subject: User): Int = 5
context(server: ServerRuntime)
override suspend fun sessionExpiration(subject: User): Instant? {
// Sessions expire after 30 days
return Clock.System.now() + 30.days
}
context(server: ServerRuntime)
override suspend fun sessionStaleAfter(subject: User): Duration? {
// Require re-authentication after 7 days of inactivity
return 7.days
}
}Different authentication methods have different "strengths". You can require a minimum strength:
- Email PIN: strength 5
- SMS PIN: strength 5
- Password: strength 5
- TOTP: strength 10
- Known Device: strength 15
override suspend fun requiredProofStrengthFor(subject: User): Int {
// Admins need stronger authentication
return if (subject.isSuperUser) 10 else 5
}When writing tests, you often need to create an AuthAccess to simulate an authenticated user without going through the
full authentication flow. Use the testAuth extension function on your PrincipalType:
import com.lightningkite.lightningserver.auth.testAuth
import com.lightningkite.lightningserver.typed.AuthAccess
// Inside your test (within a ServerRuntime context):
@Test
fun testProtectedEndpoint() {
TestHelper.testServer {
runBlocking {
// Create and insert a test user
val user = User(
email = "test@example.com",
hashedPassword = "hashed_password",
isSuperUser = false
)
userInfo.table().insertOne(user)
// Create AuthAccess for testing
val auth = UserAuth.testAuth(user)
val access = AuthAccess(auth)
// Use 'access' wherever AuthAccess is required
// e.g., for AI tools, model permissions, etc.
}
}
}The testAuth function accepts optional parameters:
context(server: ServerRuntime)
fun <SUBJECT : HasId<ID>, ID : Comparable<ID>> PrincipalType<SUBJECT, ID>.testAuth(
subject: SUBJECT,
issuedAt: Instant = server.clock.now(),
scopes: Set<GrantedScope> = setOf(GrantedScope.root)
): Authentication<SUBJECT>- subject: The user object to authenticate as
- issuedAt: When the authentication was issued (defaults to now)
- scopes: The granted scopes (defaults to root/full access)
To test with restricted permissions:
val auth = UserAuth.testAuth(
subject = user,
scopes = setOf(GrantedScope("read"), GrantedScope("posts:write"))
)
val access = AuthAccess(auth)For testing typed endpoints with authentication:
@Test
fun testEndpoint() = runBlocking {
with(TestHelper.testRunner) {
val user = User(email = "test@example.com", hashedPassword = "...")
Server.userInfo.table().insertOne(user)
// Test with authentication header
val response = Server.protectedEndpoint.test(
headers = mapOf("Authorization" to "Bearer ${createTestToken(user)}")
)
assertEquals(HttpStatus.OK, response.status)
}
}NEXT: Typed Endpoints