Skip to content

Commit 7c9be41

Browse files
committed
OIDC Authentication with Keycloak
1 parent 2df3dce commit 7c9be41

21 files changed

+265
-44
lines changed

application.yml

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
environment: DEVELOPMENT
2+
port: 8080
3+
publicHost: http://localhost:{{port}}

build.gradle.kts

+3
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ dependencies {
8686
testImplementation(tests.mockk)
8787
testImplementation(tests.assertj.core)
8888
testImplementation(tests.json)
89+
90+
// dev
91+
devImplementation(dev.keycloak.adminClient)
8992
}
9093

9194
application {

infra/keycloak/.env.default

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# KEYCLOAK_PORT=8081
2+
KEYCLOAK_ADMIN=admin
3+
KEYCLOAK_ADMIN_PASSWORD=myadminpassword

infra/keycloak/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.env

infra/keycloak/docker-compose.yml

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
services:
2+
keycloak:
3+
image: quay.io/keycloak/keycloak:24.0
4+
user: ${UID:-1000}:${GID:-1000}
5+
env_file:
6+
- path: .env.default
7+
required: true
8+
# overrides the .env file
9+
- path: .env
10+
required: false
11+
ports:
12+
- ${KEYCLOAK_PORT:-8081}:8080
13+
command:
14+
- start-dev

settings.gradle.kts

+5
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,10 @@ dependencyResolutionManagement {
141141
library("json", "org.json", "json")
142142
.version("20240303")
143143
}
144+
145+
create("dev") {
146+
library("keycloak-adminClient", "org.keycloak", "keycloak-admin-client")
147+
.version("24.0.2")
148+
}
144149
}
145150
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package me.snoty.backend.dev
2+
3+
import kotlin.random.Random
4+
5+
private val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
6+
fun randomString(stringLength: Int = 32) =
7+
(1..stringLength)
8+
.map { Random.nextInt(0, charPool.size).let { charPool[it] } }
9+
.joinToString("")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package me.snoty.backend.dev.auth
2+
3+
data class KeycloakConfigurationResult(
4+
val clientId: String,
5+
val clientSecret: String
6+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package me.snoty.backend.dev.auth
2+
3+
import me.snoty.backend.dev.randomString
4+
import org.keycloak.admin.client.resource.RealmsResource
5+
import org.keycloak.representations.idm.ClientRepresentation
6+
import org.keycloak.representations.idm.RealmRepresentation
7+
import org.slf4j.Logger
8+
import org.slf4j.LoggerFactory
9+
import kotlin.time.Duration.Companion.days
10+
11+
class KeycloakConfigurer(private var realmsResource: RealmsResource, private var realmName: String) {
12+
private val logger: Logger = LoggerFactory.getLogger(javaClass)
13+
14+
/**
15+
* Configures the realm, first validates if the realm exists and if none exists, creates the realm.
16+
*/
17+
fun configure(): KeycloakConfigurationResult {
18+
val realms = realmsResource.findAll()
19+
if (realms.stream().noneMatch { realm: RealmRepresentation -> realm.id == realmName }) {
20+
logger.info("Realm {} does not exist yet, creating...", realmName)
21+
createRealm(realmName, realmsResource)
22+
}
23+
return updateRealm()
24+
}
25+
26+
private fun createRealm(realmName: String?, realmsResource: RealmsResource?) {
27+
val realmRepresentation = RealmRepresentation()
28+
realmRepresentation.displayName = realmName
29+
realmRepresentation.id = realmName
30+
realmRepresentation.realm = realmName
31+
realmRepresentation.isEnabled = false
32+
33+
realmsResource!!.create(realmRepresentation)
34+
logger.info("Created realm '{}'", realmName)
35+
}
36+
37+
private fun updateRealm(): KeycloakConfigurationResult {
38+
val realmRepresentation = RealmRepresentation().apply {
39+
isBruteForceProtected = true
40+
isEnabled = true
41+
isRegistrationAllowed = true
42+
isEditUsernameAllowed = true
43+
// avoids browser-side https enforcement
44+
browserSecurityHeaders = mapOf("strictTransportSecurity" to "")
45+
ssoSessionIdleTimeout = 7.days.inWholeSeconds.toInt()
46+
ssoSessionMaxLifespan = 30.days.inWholeSeconds.toInt()
47+
accessTokenLifespan = 1.days.inWholeSeconds.toInt()
48+
}
49+
val realmResource = realmsResource.realm(realmName)
50+
realmResource.update(realmRepresentation)
51+
52+
val clientsResource = realmResource.clients()
53+
return (clientsResource.findByClientId("snoty").firstOrNull() ?: let {
54+
logger.info("Client 'snoty' does not exist yet, creating...")
55+
ClientRepresentation().apply {
56+
clientId = "snoty"
57+
secret = randomString(64)
58+
isDirectAccessGrantsEnabled = true
59+
isServiceAccountsEnabled = true
60+
redirectUris = listOf("http://localhost:8080/*")
61+
clientsResource.create(this)
62+
logger.info("Created client 'snoty'")
63+
}
64+
})
65+
.let {
66+
return@let KeycloakConfigurationResult(it.clientId, it.secret)
67+
}
68+
}
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package me.snoty.backend.dev.auth
2+
3+
import com.sksamuel.hoplite.ConfigAlias
4+
import com.sksamuel.hoplite.Masked
5+
6+
/**
7+
* Configuration that resembles the environment variables for the Keycloak container.
8+
* The same .env file can be used for the keycloak container and the application.
9+
* This reduces the risk of configuration drift between the two.
10+
*/
11+
data class KeycloakContainerConfig(
12+
@ConfigAlias("KEYCLOAK_ADMIN")
13+
val adminUser: String,
14+
@ConfigAlias("KEYCLOAK_ADMIN_PASSWORD")
15+
val adminPassword: Masked,
16+
@ConfigAlias("KEYCLOAK_PORT")
17+
val port: Int = 8081
18+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package me.snoty.backend.dev.auth
2+
3+
import com.sksamuel.hoplite.ConfigLoaderBuilder
4+
import com.sksamuel.hoplite.addFileSource
5+
import com.sksamuel.hoplite.parsers.PropsParser
6+
import me.snoty.backend.spi.DevRunnable
7+
import org.keycloak.admin.client.KeycloakBuilder
8+
import org.slf4j.LoggerFactory
9+
10+
const val REALM_NAME = "snoty"
11+
const val ADMIN_CLI = "admin-cli"
12+
13+
class KeycloakSetup : DevRunnable {
14+
override fun run() {
15+
val containerConfig = ConfigLoaderBuilder.default()
16+
.addParser("env", PropsParser())
17+
// `.env.default` file - WARNING: this assumes all *.default files are .env files
18+
.addParser("default", PropsParser())
19+
// local configuration takes precedence
20+
.addFileSource("infra/keycloak/.env", optional = true, allowEmpty = false)
21+
.addFileSource("infra/keycloak/.env.default", optional = true, allowEmpty = false)
22+
.build()
23+
.loadConfig<KeycloakContainerConfig>()
24+
.onFailure { LoggerFactory.getLogger(javaClass).warn("Failed to load KeycloakContainerConfig: ${it.description()}") }
25+
.also {
26+
LoggerFactory.getLogger(javaClass).info("Loaded KeycloakContainerConfig: $it")
27+
}.getUnsafe()
28+
val serverUrl = "http://localhost:${containerConfig.port}"
29+
30+
val keycloak = KeycloakBuilder.builder()
31+
.serverUrl(serverUrl)
32+
// the realm of the user we're authenticating with
33+
.realm("master")
34+
.username(containerConfig.adminUser)
35+
.password(containerConfig.adminPassword.value)
36+
.clientId(ADMIN_CLI)
37+
.build()
38+
39+
val result = KeycloakConfigurer(keycloak.realms(), REALM_NAME)
40+
.configure()
41+
42+
System.setProperty("config.override.authentication.serverUrl", "$serverUrl/realms/$REALM_NAME")
43+
System.setProperty("config.override.authentication.clientId", result.clientId)
44+
System.setProperty("config.override.authentication.clientSecret", result.clientSecret)
45+
}
46+
}
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
me.snoty.backend.dev.DevNotifier
2+
me.snoty.backend.dev.auth.KeycloakSetup

src/main/kotlin/me/snoty/backend/Application.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import me.snoty.backend.server.KtorServer
66
import me.snoty.backend.spi.DevManager
77

88
fun main() {
9+
// ran pre-config load to allow dev functions to configure the environment
10+
DevManager.runDevFunctions()
11+
912
val configLoader = ConfigLoaderImpl()
1013
val config = configLoader.loadConfig()
1114

@@ -22,8 +25,6 @@ fun main() {
2225
}
2326
}
2427

25-
DevManager.runDevFunctions()
26-
2728
KtorServer(config, buildInfo)
2829
.start(wait = true)
2930
}

src/main/kotlin/me/snoty/backend/config/Config.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ package me.snoty.backend.config
33
data class Config(
44
val database: DatabaseConfig,
55
val port: Short = 8080,
6-
val environment: Environment
6+
val publicHost: String,
7+
val environment: Environment,
8+
val authentication: OidcConfig
79
)

src/main/kotlin/me/snoty/backend/config/ConfigLoaderImpl.kt

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class ConfigLoaderImpl : ConfigLoader {
1616

1717
return ConfigLoaderBuilder.default()
1818
.withResolveTypesCaseInsensitive()
19+
.addDefaultPreprocessors()
1920
.addSource(PropsPropertySource(pgContainerConfig.getOrElse { Properties() }))
2021
.addFileSource("application.local.yml", optional = true)
2122
.addFileSource("application.yml", optional = true)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package me.snoty.backend.config
2+
3+
data class OidcConfig(
4+
val serverUrl: String,
5+
val issuerUrl: String = serverUrl,
6+
val oidcUrl: String = "$serverUrl/protocol/openid-connect",
7+
val clientId: String,
8+
val clientSecret: String
9+
)

src/main/kotlin/me/snoty/backend/server/KtorServer.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class KtorServer(val config: Config, val buildInfo: BuildInfo) {
3131
private fun Application.module() {
3232
configureMonitoring()
3333
configureHTTP()
34-
configureSecurity()
34+
configureSecurity(config)
3535
configureSerialization()
3636
configureDatabases(config.database.value)
3737
configureRouting(config)

src/main/kotlin/me/snoty/backend/server/plugins/Monitoring.kt

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ fun Application.configureMonitoring() {
2727
verify { callId: String ->
2828
callId.isNotEmpty()
2929
}
30+
// generate if not set already
31+
generate(10)
3032
}
3133
routing {
3234
get("/metrics") {
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,95 @@
11
package me.snoty.backend.server.plugins
22

3-
import com.auth0.jwt.JWT
4-
import com.auth0.jwt.algorithms.Algorithm
3+
import com.auth0.jwk.UrlJwkProvider
54
import io.ktor.client.*
65
import io.ktor.client.engine.apache.*
76
import io.ktor.http.*
7+
import io.ktor.http.auth.*
88
import io.ktor.server.application.*
99
import io.ktor.server.auth.*
1010
import io.ktor.server.auth.jwt.*
1111
import io.ktor.server.response.*
1212
import io.ktor.server.routing.*
13-
import io.ktor.server.sessions.*
13+
import kotlinx.serialization.Serializable
14+
import me.snoty.backend.config.Config
15+
import me.snoty.backend.server.handler.UnauthorizedException
16+
import me.snoty.backend.utils.respondStatus
17+
import java.net.URI
1418

15-
fun Application.configureSecurity() {
16-
authentication {
17-
oauth("auth-oauth-google") {
18-
urlProvider = { "http://localhost:8080/callback" }
19+
fun Application.configureSecurity(config: Config) {
20+
val authConfig = config.authentication
21+
install(Authentication) {
22+
oauth("keycloak") {
23+
client = HttpClient(Apache)
1924
providerLookup = {
2025
OAuthServerSettings.OAuth2ServerSettings(
21-
name = "google",
22-
authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
23-
accessTokenUrl = "https://accounts.google.com/o/oauth2/token",
26+
name = "keycloak",
27+
authorizeUrl = "${authConfig.oidcUrl}/auth",
28+
accessTokenUrl = "${authConfig.oidcUrl}/token",
29+
clientId = authConfig.clientId,
30+
clientSecret = authConfig.clientSecret,
31+
accessTokenRequiresBasicAuth = false,
2432
requestMethod = HttpMethod.Post,
25-
clientId = System.getenv("GOOGLE_CLIENT_ID"),
26-
clientSecret = System.getenv("GOOGLE_CLIENT_SECRET"),
27-
defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile")
33+
defaultScopes = listOf("openid")
2834
)
2935
}
30-
client = HttpClient(Apache)
36+
urlProvider = {
37+
"${config.publicHost}/callback"
38+
}
3139
}
32-
}
33-
// Please read the jwt property from the config file if you are using EngineMain
34-
val jwtAudience = "jwt-audience"
35-
val jwtDomain = "https://jwt-provider-domain/"
36-
val jwtRealm = "ktor sample app"
37-
val jwtSecret = "secret"
38-
authentication {
39-
jwt {
40-
realm = jwtRealm
40+
jwt("jwt-auth") {
41+
authHeader { call ->
42+
call.request.parseAuthorizationHeader()
43+
// optionally load from cookies
44+
?: parseAuthorizationHeader("Bearer ${call.request.cookies["access_token"]}")
45+
}
4146
verifier(
42-
JWT
43-
.require(Algorithm.HMAC256(jwtSecret))
44-
.withAudience(jwtAudience)
45-
.withIssuer(jwtDomain)
46-
.build()
47+
UrlJwkProvider(URI("${authConfig.oidcUrl}/certs").toURL()),
48+
authConfig.issuerUrl
4749
)
50+
challenge { _, _ ->
51+
call.respondStatus(UnauthorizedException("JWT is invalid"))
52+
}
4853
validate { credential ->
49-
if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null
54+
JWTPrincipal(credential.payload)
5055
}
5156
}
5257
}
5358
routing {
54-
authenticate("auth-oauth-google") {
55-
get("login") {
56-
call.respondRedirect("/callback")
57-
}
58-
59+
authenticate("keycloak") {
60+
get("/login") {}
5961
get("/callback") {
60-
val principal: OAuthAccessTokenResponse.OAuth2? = call.authentication.principal()
61-
call.sessions.set(UserSession(principal?.accessToken.toString()))
62-
call.respondRedirect("/hello")
62+
val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>()
63+
?: return@get call.respondRedirect("/login")
64+
65+
call.response.cookies.append("access_token", principal.accessToken)
66+
call.respondText(principal.accessToken)
67+
}
68+
}
69+
authenticate("jwt-auth") {
70+
get("/userInfo") {
71+
call.respond(call.getUser())
6372
}
6473
}
6574
}
6675
}
6776

68-
class UserSession(accessToken: String)
77+
fun ApplicationCall.getUser(): User =
78+
getUserOrNull() ?: throw UnauthorizedException("User not authenticated")
79+
80+
fun ApplicationCall.getUserOrNull(): User? {
81+
val principal = authentication.principal<JWTPrincipal>() ?: return null
82+
val claims = principal.payload.claims
83+
return User(
84+
id = claims["sub"]?.asString() ?: "unknown",
85+
name = claims["name"]?.asString() ?: "unknown",
86+
email = claims["email"]?.asString() ?: "unknown"
87+
)
88+
}
89+
90+
@Serializable
91+
data class User(
92+
val id: String,
93+
val name: String,
94+
val email: String,
95+
)

0 commit comments

Comments
 (0)