Skip to content

Commit

Permalink
Modifies structure and adds support for refresh and id tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
frederic-kneier committed Apr 15, 2024
1 parent 5ec7e51 commit a7b872f
Show file tree
Hide file tree
Showing 29 changed files with 653 additions and 96 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ jobs:
distribution: 'temurin'
java-version: '21'
- name: Build
run: ./gradlew jib -Djib.to.image=ghcr.io/${{ github.repository }} -Djib.to.tags=${{ needs.version.outputs.version }},latest -Djib.to.auth.username=${{ github.actor }} -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} --no-daemon
run: ./gradlew test jib -Djib.to.image=ghcr.io/${{ github.repository }} -Djib.to.tags=${{ needs.version.outputs.version }},latest -Djib.to.auth.username=${{ github.actor }} -Djib.to.auth.password=${{ secrets.GITHUB_TOKEN }} --no-daemon
16 changes: 16 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.bitbucket.b_c:jose4j:0.9.6")

testImplementation(platform("org.junit:junit-bom:5.10.1"))
testImplementation(platform("org.testcontainers:testcontainers-bom:1.19.3"))
testImplementation(platform("io.kotest:kotest-bom:5.8.0"))
testImplementation(platform("io.ktor:ktor-bom:2.3.7"))

testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("io.ktor:ktor-client-java")
testImplementation("io.ktor:ktor-client-content-negotiation")
testImplementation("io.ktor:ktor-serialization-jackson")
testImplementation("io.kotest:kotest-assertions-core")
testImplementation("io.kotest:kotest-assertions-json")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")

}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package de.solugo.oidc.config

import com.fasterxml.jackson.databind.PropertyNamingStrategies
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class SerializationConfiguration {

@Bean
fun jacksonCustomizer() = Jackson2ObjectMapperBuilderCustomizer {
it.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
package de.solugo.oidc.controller

import de.solugo.oidc.ConfigurationProvider
import de.solugo.oidc.service.JwksService
import de.solugo.oidc.service.KeySetService
import org.jose4j.jwk.JsonWebKey
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.util.UriComponentsBuilder

@RestController
@ConditionalOnBean(JwksService::class)
class JwksController(
private val jwksService: JwksService,
@ConditionalOnBean(KeySetService::class)
class KeySetController(
private val keySetService: KeySetService,
) : ConfigurationProvider {

override fun provide(builder: UriComponentsBuilder) = mapOf(
Expand All @@ -20,7 +20,7 @@ class JwksController(

@GetMapping(".well-known/jwks.json")
fun getJwks() = mapOf(
"keys" to jwksService.keys.map {
"keys" to keySetService.keys.map {
it.toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY)
}
)
Expand Down
113 changes: 101 additions & 12 deletions src/main/kotlin/de/solugo/oidc/controller/TokenController.kt
Original file line number Diff line number Diff line change
@@ -1,40 +1,129 @@
package de.solugo.oidc.controller


import de.solugo.oidc.ConfigurationProvider
import de.solugo.oidc.grant.Grant
import de.solugo.oidc.service.TokenService
import de.solugo.oidc.util.badRequest
import de.solugo.oidc.token.*
import de.solugo.oidc.util.plus
import de.solugo.oidc.util.scopes
import de.solugo.oidc.util.sessionId
import de.solugo.oidc.util.uuid
import kotlinx.coroutines.reactor.awaitSingle
import org.jose4j.jwt.JwtClaims
import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.util.MultiValueMap
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.util.UriComponentsBuilder
import java.time.Instant

@RestController
class TokenController(
private val grants: List<Grant>,
private val grants: List<TokenGrant>,
private val tokenService: TokenService,
private val tokenProcessors: List<TokenProcessor>,
) : ConfigurationProvider {

private val logger = LoggerFactory.getLogger(javaClass)

override fun provide(builder: UriComponentsBuilder) = mapOf(
"token_endpoint" to builder.replacePath("/token").toUriString(),
)

@PostMapping("/token")
@PostMapping("/token", consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE])
suspend fun createToken(
exchange: ServerWebExchange,
uriBuilder: UriComponentsBuilder,
) = run {
val params = exchange.formData.awaitSingle()
val type = params.getFirst("grant_type") ?: badRequest("Missing grant_type")
val provider = grants.firstOrNull { it.type == type } ?: badRequest("Grant type '$type' is not supported")
val claims = JwtClaims()
val parameters = exchange.formData.awaitSingle()

try {
process(
issuer = uriBuilder.replacePath("/").toUriString(),
parameters = parameters,
)
} catch (ex: TokenException) {
mapOf(
"error" to ex.error,
"errorDescription" to ex.description,
"errorUri" to ex.uri,
)
}
}

private suspend fun process(
issuer: String,
parameters: MultiValueMap<String, String>,
): Map<String, Any> = try {
val type = parameters.getFirst("grant_type") ?: throw TokenException(
error = TokenError.InvalidRequest,
description = "Request is missing grant_type parameter"
)
val grant = grants.firstOrNull { it.type == type } ?: throw TokenException(
error = TokenError.InvalidRequest,
description = "Grant type '$type' is not supported"
)

val context = TokenContext(
issuer = issuer,
parameters = parameters,
scopes = parameters.getFirst("scope")?.split(" ")?.toSet(),
)

tokenProcessors.process(TokenProcessor.Step.PRE, context)

grant.process(context)

tokenProcessors.process(TokenProcessor.Step.POST, context)
tokenProcessors.process(TokenProcessor.Step.VALIDATE, context)
tokenProcessors.process(TokenProcessor.Step.CLAIMS, context)

val commonClaims = context.commonClaims
val idClaims = commonClaims + context.idClaims
val accessClaims = commonClaims + context.accessClaims
val refreshClaims = commonClaims + context.refreshClaims

provider.process(params, claims)
listOf(idClaims, accessClaims, refreshClaims).forEach { claims ->
claims.issuer = issuer
claims.jwtId = uuid()
claims.sessionId = claims.sessionId ?: uuid()
}

mapOf(
"access_token" to tokenService.createToken(uriBuilder.replacePath("/"), claims)
buildMap {
put("token_type", "Bearer")

accessClaims.takeIf { it.claimsMap.isNotEmpty() }?.also { claims ->
put("access_token", tokenService.encodeJwt(context.issuer, claims))

claims.expirationTime?.also {
put("expires_in", it.value - (claims.issuedAt?.value ?: Instant.now().epochSecond))
}
}
refreshClaims.takeIf { it.claimsMap.isNotEmpty() }?.also { claims ->
if (claims.scopes?.contains("offline_access") != true) return@also
put("refresh_token", tokenService.encodeJwt(context.issuer, claims))
}
idClaims.takeIf { it.claimsMap.isNotEmpty() }?.also { claims ->
if (claims.scopes?.contains("openid") != true) return@also
put("id_token", tokenService.encodeJwt(context.issuer, claims))
}
}
} catch (ex: Exception) {
logger.error("Error processing token request", ex)
throw TokenException(
error = TokenError.ServerError,
description = ex.message,
cause = ex,
)
}

private suspend fun List<TokenProcessor>.process(step: TokenProcessor.Step, context: TokenContext) {
this.forEach {
if (it.step == step) {
it.process(context)
}
}
}

}
22 changes: 0 additions & 22 deletions src/main/kotlin/de/solugo/oidc/grant/ClientCredentialsGrant.kt

This file was deleted.

9 changes: 0 additions & 9 deletions src/main/kotlin/de/solugo/oidc/grant/Grant.kt

This file was deleted.

23 changes: 0 additions & 23 deletions src/main/kotlin/de/solugo/oidc/grant/PasswordGrant.kt

This file was deleted.

10 changes: 10 additions & 0 deletions src/main/kotlin/de/solugo/oidc/model/Client.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package de.solugo.oidc.model

import java.time.Duration


interface Client {
val id: String
val allowedGrants: Set<String>
val accessTokenLifetime: Duration?
}
6 changes: 6 additions & 0 deletions src/main/kotlin/de/solugo/oidc/model/User.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.solugo.oidc.model

interface User {
val id: String
val username: String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.jose4j.jwk.RsaJwkGenerator
import org.springframework.stereotype.Service

@Service
class JwksService {
class KeySetService {

val keys by lazy {
listOf(
Expand Down
29 changes: 22 additions & 7 deletions src/main/kotlin/de/solugo/oidc/service/TokenService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,48 @@ package de.solugo.oidc.service

import de.solugo.oidc.ServerProperties
import de.solugo.oidc.util.uuid
import org.jose4j.jwk.JsonWebKey
import org.jose4j.jwk.PublicJsonWebKey
import org.jose4j.jws.JsonWebSignature
import org.jose4j.jwt.JwtClaims
import org.jose4j.jwt.consumer.JwtConsumerBuilder
import org.springframework.stereotype.Service
import org.springframework.web.util.UriComponentsBuilder

@Service
class TokenService(
private val jwksService: JwksService,
private val keySetService: KeySetService,
private val properties: ServerProperties,
) {

fun createToken(
issuerUri: UriComponentsBuilder,
private val consumer = JwtConsumerBuilder().run {
setVerificationKeyResolver { signature, _ ->
keySetService.keys.first { it.keyId == signature.keyIdHeaderValue }.key
}
build()
}

fun encodeJwt(
issuer: String,
claims: JwtClaims,
webKey: JsonWebKey = keySetService.keys.first(),
) = JsonWebSignature().run {
val webKey = jwksService.keys.first()
key = webKey.privateKey
key = when (webKey) {
is PublicJsonWebKey -> webKey.privateKey
else -> error("Webkey $webKey not supported for jwts")
}
keyIdHeaderValue = webKey.keyId
algorithmHeaderValue = webKey.algorithm
payload = claims.run {
jwtId = uuid()
issuer = issuerUri.toUriString()
properties.claims.forEach { (key, value) -> setClaim(key, value) }
setIssuedAtToNow()
toJson()
}
compactSerialization
}

fun decodeJwt(
jwt: String,
) = consumer.process(jwt)

}
Loading

0 comments on commit a7b872f

Please sign in to comment.