From a7b872f937f2b4a95643ba38bfab3f59047ad3c0 Mon Sep 17 00:00:00 2001 From: Frederic Kneier Date: Tue, 16 Apr 2024 01:54:24 +0200 Subject: [PATCH] Modifies structure and adds support for refresh and id tokens --- .github/workflows/build.yml | 2 +- build.gradle.kts | 16 +++ .../oidc/config/SerializationConfiguration.kt | 15 +++ ...{JwksController.kt => KeySetController.kt} | 10 +- .../solugo/oidc/controller/TokenController.kt | 113 ++++++++++++++++-- .../oidc/grant/ClientCredentialsGrant.kt | 22 ---- src/main/kotlin/de/solugo/oidc/grant/Grant.kt | 9 -- .../de/solugo/oidc/grant/PasswordGrant.kt | 23 ---- .../kotlin/de/solugo/oidc/model/Client.kt | 10 ++ src/main/kotlin/de/solugo/oidc/model/User.kt | 6 + .../{JwksService.kt => KeySetService.kt} | 2 +- .../de/solugo/oidc/service/TokenService.kt | 29 +++-- .../de/solugo/oidc/token/TokenAttributes.kt | 44 +++++++ .../de/solugo/oidc/token/TokenContext.kt | 39 ++++++ .../kotlin/de/solugo/oidc/token/TokenError.kt | 13 ++ .../de/solugo/oidc/token/TokenException.kt | 11 ++ .../kotlin/de/solugo/oidc/token/TokenGrant.kt | 6 + .../de/solugo/oidc/token/TokenProcessor.kt | 12 ++ .../token/grant/ClientCredentialsGrant.kt | 23 ++++ .../solugo/oidc/token/grant/PasswordGrant.kt | 24 ++++ .../oidc/token/grant/RefreshTokenGrant.kt | 42 +++++++ .../token/processor/ClientClaimsProcessor.kt | 33 +++++ .../processor/ClientResolutionProcessor.kt | 26 ++++ .../token/processor/UserClaimsProcessor.kt | 21 ++++ .../kotlin/de/solugo/oidc/util/ClaimsUtil.kt | 49 ++++++-- .../kotlin/de/solugo/oidc/util/HttpUtil.kt | 6 - src/main/kotlin/de/solugo/oidc/util/Util.kt | 8 +- src/test/kotlin/IntegrationTest.kt | 39 ++++++ .../oidc/controller/TokenControllerTest.kt | 96 +++++++++++++++ 29 files changed, 653 insertions(+), 96 deletions(-) create mode 100644 src/main/kotlin/de/solugo/oidc/config/SerializationConfiguration.kt rename src/main/kotlin/de/solugo/oidc/controller/{JwksController.kt => KeySetController.kt} (78%) delete mode 100644 src/main/kotlin/de/solugo/oidc/grant/ClientCredentialsGrant.kt delete mode 100644 src/main/kotlin/de/solugo/oidc/grant/Grant.kt delete mode 100644 src/main/kotlin/de/solugo/oidc/grant/PasswordGrant.kt create mode 100644 src/main/kotlin/de/solugo/oidc/model/Client.kt create mode 100644 src/main/kotlin/de/solugo/oidc/model/User.kt rename src/main/kotlin/de/solugo/oidc/service/{JwksService.kt => KeySetService.kt} (94%) create mode 100644 src/main/kotlin/de/solugo/oidc/token/TokenAttributes.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/TokenContext.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/TokenError.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/TokenException.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/TokenGrant.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/TokenProcessor.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/grant/ClientCredentialsGrant.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/grant/PasswordGrant.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/grant/RefreshTokenGrant.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/processor/ClientClaimsProcessor.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/processor/ClientResolutionProcessor.kt create mode 100644 src/main/kotlin/de/solugo/oidc/token/processor/UserClaimsProcessor.kt delete mode 100644 src/main/kotlin/de/solugo/oidc/util/HttpUtil.kt create mode 100644 src/test/kotlin/IntegrationTest.kt create mode 100644 src/test/kotlin/de/solugo/oidc/controller/TokenControllerTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e3c9dc..8543966 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index 54a3fb9..7d12419 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { diff --git a/src/main/kotlin/de/solugo/oidc/config/SerializationConfiguration.kt b/src/main/kotlin/de/solugo/oidc/config/SerializationConfiguration.kt new file mode 100644 index 0000000..cbffb50 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/config/SerializationConfiguration.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/controller/JwksController.kt b/src/main/kotlin/de/solugo/oidc/controller/KeySetController.kt similarity index 78% rename from src/main/kotlin/de/solugo/oidc/controller/JwksController.kt rename to src/main/kotlin/de/solugo/oidc/controller/KeySetController.kt index e157fc9..69562b1 100644 --- a/src/main/kotlin/de/solugo/oidc/controller/JwksController.kt +++ b/src/main/kotlin/de/solugo/oidc/controller/KeySetController.kt @@ -1,7 +1,7 @@ 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 @@ -9,9 +9,9 @@ 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( @@ -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) } ) diff --git a/src/main/kotlin/de/solugo/oidc/controller/TokenController.kt b/src/main/kotlin/de/solugo/oidc/controller/TokenController.kt index 7fe06eb..f82351d 100644 --- a/src/main/kotlin/de/solugo/oidc/controller/TokenController.kt +++ b/src/main/kotlin/de/solugo/oidc/controller/TokenController.kt @@ -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, + private val grants: List, private val tokenService: TokenService, + private val tokenProcessors: List, ) : 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, + ): Map = 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.process(step: TokenProcessor.Step, context: TokenContext) { + this.forEach { + if (it.step == step) { + it.process(context) + } + } } + } \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/grant/ClientCredentialsGrant.kt b/src/main/kotlin/de/solugo/oidc/grant/ClientCredentialsGrant.kt deleted file mode 100644 index 3593325..0000000 --- a/src/main/kotlin/de/solugo/oidc/grant/ClientCredentialsGrant.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.solugo.oidc.grant - -import de.solugo.oidc.util.badRequest -import de.solugo.oidc.util.clientId -import de.solugo.oidc.util.scope -import org.jose4j.jwt.JwtClaims -import org.springframework.stereotype.Component -import org.springframework.util.MultiValueMap - -@Component -class ClientCredentialsGrant : Grant { - override val type: String = "client_credentials" - - override suspend fun process(params: MultiValueMap, claims: JwtClaims) { - val clientId = params.getFirst("client_id") ?: badRequest("Missing client_id") - val scope = params.getFirst("scope") - - claims.subject = clientId - claims.clientId = clientId - claims.scope = scope - } -} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/grant/Grant.kt b/src/main/kotlin/de/solugo/oidc/grant/Grant.kt deleted file mode 100644 index 3c04c32..0000000 --- a/src/main/kotlin/de/solugo/oidc/grant/Grant.kt +++ /dev/null @@ -1,9 +0,0 @@ -package de.solugo.oidc.grant - -import org.jose4j.jwt.JwtClaims -import org.springframework.util.MultiValueMap - -interface Grant { - val type: String - suspend fun process(params: MultiValueMap, claims: JwtClaims) -} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/grant/PasswordGrant.kt b/src/main/kotlin/de/solugo/oidc/grant/PasswordGrant.kt deleted file mode 100644 index 2cf8e4f..0000000 --- a/src/main/kotlin/de/solugo/oidc/grant/PasswordGrant.kt +++ /dev/null @@ -1,23 +0,0 @@ -package de.solugo.oidc.grant - -import de.solugo.oidc.util.badRequest -import de.solugo.oidc.util.clientId -import de.solugo.oidc.util.scope -import org.jose4j.jwt.JwtClaims -import org.springframework.stereotype.Component -import org.springframework.util.MultiValueMap - -@Component -class PasswordGrant : Grant { - override val type: String = "password" - - override suspend fun process(params: MultiValueMap, claims: JwtClaims) { - val clientId = params.getFirst("client_id") ?: badRequest("Missing client_id") - val username = params.getFirst("username") ?: badRequest("Missing username") - val scope = params.getFirst("scope") - - claims.subject = username - claims.clientId = clientId - claims.scope = scope - } -} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/model/Client.kt b/src/main/kotlin/de/solugo/oidc/model/Client.kt new file mode 100644 index 0000000..5ddbaa9 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/model/Client.kt @@ -0,0 +1,10 @@ +package de.solugo.oidc.model + +import java.time.Duration + + +interface Client { + val id: String + val allowedGrants: Set + val accessTokenLifetime: Duration? +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/model/User.kt b/src/main/kotlin/de/solugo/oidc/model/User.kt new file mode 100644 index 0000000..0a273bb --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/model/User.kt @@ -0,0 +1,6 @@ +package de.solugo.oidc.model + +interface User { + val id: String + val username: String? +} diff --git a/src/main/kotlin/de/solugo/oidc/service/JwksService.kt b/src/main/kotlin/de/solugo/oidc/service/KeySetService.kt similarity index 94% rename from src/main/kotlin/de/solugo/oidc/service/JwksService.kt rename to src/main/kotlin/de/solugo/oidc/service/KeySetService.kt index 384e7f9..c3dcb71 100644 --- a/src/main/kotlin/de/solugo/oidc/service/JwksService.kt +++ b/src/main/kotlin/de/solugo/oidc/service/KeySetService.kt @@ -5,7 +5,7 @@ import org.jose4j.jwk.RsaJwkGenerator import org.springframework.stereotype.Service @Service -class JwksService { +class KeySetService { val keys by lazy { listOf( diff --git a/src/main/kotlin/de/solugo/oidc/service/TokenService.kt b/src/main/kotlin/de/solugo/oidc/service/TokenService.kt index ce44d66..fcdf6ab 100644 --- a/src/main/kotlin/de/solugo/oidc/service/TokenService.kt +++ b/src/main/kotlin/de/solugo/oidc/service/TokenService.kt @@ -2,28 +2,39 @@ 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() @@ -31,4 +42,8 @@ class TokenService( compactSerialization } + fun decodeJwt( + jwt: String, + ) = consumer.process(jwt) + } \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/TokenAttributes.kt b/src/main/kotlin/de/solugo/oidc/token/TokenAttributes.kt new file mode 100644 index 0000000..037dc70 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/TokenAttributes.kt @@ -0,0 +1,44 @@ +package de.solugo.oidc.token + +import de.solugo.oidc.model.Client +import de.solugo.oidc.model.User +import org.jose4j.jwt.JwtClaims +import org.springframework.util.MultiValueMap + +val TokenContext.issuer: String by TokenContext.attribute() +var TokenContext.scopes: Set? by TokenContext.attribute() +var TokenContext.client: Client? by TokenContext.attribute() +var TokenContext.user: User? by TokenContext.attribute() +val TokenContext.commonClaims: JwtClaims by TokenContext.attribute { JwtClaims() } +val TokenContext.idClaims: JwtClaims by TokenContext.attribute { JwtClaims() } +val TokenContext.accessClaims: JwtClaims by TokenContext.attribute { JwtClaims() } +val TokenContext.refreshClaims: JwtClaims by TokenContext.attribute { JwtClaims() } +val TokenContext.parameters: MultiValueMap by TokenContext.attribute() +val TokenContext.grantType: String? + get() = run { + parameters.getFirst("grant_type") + } +val TokenContext.clientId: String? + get() = run { + parameters.getFirst("client_id") + } +val TokenContext.clientSecret: String? + get() = run { + parameters.getFirst("client_secret") + } +val TokenContext.username: String? + get() = run { + parameters.getFirst("username") + } +val TokenContext.password: String? + get() = run { + parameters.getFirst("password") + } +val TokenContext.refreshToken: String? + get() = run { + parameters.getFirst("refresh_token") + } +val TokenContext.code: String? + get() = run { + parameters.getFirst("code") + } \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/TokenContext.kt b/src/main/kotlin/de/solugo/oidc/token/TokenContext.kt new file mode 100644 index 0000000..a5b06bb --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/TokenContext.kt @@ -0,0 +1,39 @@ +package de.solugo.oidc.token + +import org.springframework.util.MultiValueMap +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class TokenContext( + issuer: String, + parameters: MultiValueMap, + scopes: Set? = null, +) { + internal val attributes = mutableMapOf( + TokenContext::issuer.name to issuer, + TokenContext::parameters.name to parameters, + TokenContext::scopes.name to scopes, + ) + + companion object { + fun attribute(creator: (() -> T & Any)? = null) = object : ReadWriteProperty { + @Suppress("unchecked_cast") + override fun getValue(thisRef: TokenContext, property: KProperty<*>): T = run { + when { + creator != null -> thisRef.attributes.computeIfAbsent(property.name) { creator() } as T + else -> thisRef.attributes[property.name] as T + } + } + + override fun setValue(thisRef: TokenContext, property: KProperty<*>, value: T) { + when { + value != null -> thisRef.attributes[property.name] = value + else -> thisRef.attributes.remove(property.name) + } + } + + } + } +} + + diff --git a/src/main/kotlin/de/solugo/oidc/token/TokenError.kt b/src/main/kotlin/de/solugo/oidc/token/TokenError.kt new file mode 100644 index 0000000..d32b160 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/TokenError.kt @@ -0,0 +1,13 @@ +package de.solugo.oidc.token + +import com.fasterxml.jackson.annotation.JsonView + +enum class TokenError(@JsonView val value: String) { + InvalidRequest("invalid_request"), + UnauthorizedClient("unauthorized_client"), + AccessDenied("access_denied"), + UnsupportedResponseType("unsupported_response_type"), + InvalidScope("invalid_scope"), + ServerError("server_error"), + TemporarilyUnavailable("temporarily_unavailable"), +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/TokenException.kt b/src/main/kotlin/de/solugo/oidc/token/TokenException.kt new file mode 100644 index 0000000..061bc7e --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/TokenException.kt @@ -0,0 +1,11 @@ +package de.solugo.oidc.token + +class TokenException( + val error: TokenError, + val description: String? = null, + val uri: String? = null, + cause: Throwable? = null, +) : Exception( + "$error${description?.let { ":$it" }}", + cause, +) \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/TokenGrant.kt b/src/main/kotlin/de/solugo/oidc/token/TokenGrant.kt new file mode 100644 index 0000000..719e9f4 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/TokenGrant.kt @@ -0,0 +1,6 @@ +package de.solugo.oidc.token + +interface TokenGrant { + val type: String + suspend fun process(context: TokenContext) +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/TokenProcessor.kt b/src/main/kotlin/de/solugo/oidc/token/TokenProcessor.kt new file mode 100644 index 0000000..0a16f95 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/TokenProcessor.kt @@ -0,0 +1,12 @@ +package de.solugo.oidc.token + +interface TokenProcessor { + + val step: Step + + suspend fun process(context: TokenContext) + + enum class Step { + PRE, POST, VALIDATE, CLAIMS + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/grant/ClientCredentialsGrant.kt b/src/main/kotlin/de/solugo/oidc/token/grant/ClientCredentialsGrant.kt new file mode 100644 index 0000000..ea0fc8c --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/grant/ClientCredentialsGrant.kt @@ -0,0 +1,23 @@ +package de.solugo.oidc.token.grant + +import de.solugo.oidc.model.User +import de.solugo.oidc.token.* +import de.solugo.oidc.util.uuid +import org.springframework.stereotype.Component + +@Component +class ClientCredentialsGrant : TokenGrant { + override val type = "client_credentials" + + override suspend fun process(context: TokenContext) { + val client = context.client ?: throw TokenException( + error = TokenError.InvalidRequest, + description = "Client could not be found", + ) + + context.user = object : User { + override val id = uuid(value = client.id) + override val username = client.id + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/grant/PasswordGrant.kt b/src/main/kotlin/de/solugo/oidc/token/grant/PasswordGrant.kt new file mode 100644 index 0000000..35ab3fc --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/grant/PasswordGrant.kt @@ -0,0 +1,24 @@ +package de.solugo.oidc.token.grant + +import de.solugo.oidc.model.User +import de.solugo.oidc.token.TokenContext +import de.solugo.oidc.token.TokenGrant +import de.solugo.oidc.token.user +import de.solugo.oidc.token.username +import de.solugo.oidc.util.uuid +import org.springframework.stereotype.Component + +@Component +class PasswordGrant : TokenGrant { + override val type = "password" + + override suspend fun process(context: TokenContext) { + val username = context.username ?: return + + context.user = object : User { + override val id = uuid(value = username) + override val username = username + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/grant/RefreshTokenGrant.kt b/src/main/kotlin/de/solugo/oidc/token/grant/RefreshTokenGrant.kt new file mode 100644 index 0000000..505f2d7 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/grant/RefreshTokenGrant.kt @@ -0,0 +1,42 @@ +package de.solugo.oidc.token.grant + +import de.solugo.oidc.service.TokenService +import de.solugo.oidc.token.* +import de.solugo.oidc.util.clientId +import de.solugo.oidc.util.scopes +import org.springframework.stereotype.Component + +@Component +class RefreshTokenGrant( + private val tokenService: TokenService, + + ) : TokenGrant { + override val type: String = "refresh_token" + + override suspend fun process(context: TokenContext) { + val client = context.client ?: throw TokenException( + error = TokenError.UnauthorizedClient, + description = "Client resolution failed", + ) + val refreshToken = context.refreshToken ?: throw TokenException( + error = TokenError.InvalidRequest, + description = "Request is missing refresh_token parameter", + ) + + val refreshContext = tokenService.decodeJwt(refreshToken) + + if (refreshContext.jwtClaims.clientId != client.id) throw TokenException( + error = TokenError.AccessDenied, + description = "Client is not allowed to use this refresh token", + ) + + val refreshScopes = refreshContext.jwtClaims.scopes ?: emptySet() + + context.scopes = context.scopes?.filter { refreshScopes.contains(it) }?.toSet() ?: refreshScopes + + context.commonClaims.apply { + refreshContext.jwtClaims.claimsMap.forEach { (key, value) -> setClaim(key, value) } + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/processor/ClientClaimsProcessor.kt b/src/main/kotlin/de/solugo/oidc/token/processor/ClientClaimsProcessor.kt new file mode 100644 index 0000000..32859f1 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/processor/ClientClaimsProcessor.kt @@ -0,0 +1,33 @@ +package de.solugo.oidc.token.processor + +import de.solugo.oidc.token.* +import de.solugo.oidc.util.clientId +import de.solugo.oidc.util.scopes +import org.jose4j.jwt.NumericDate +import org.springframework.stereotype.Component +import java.time.Instant + +@Component +class ClientClaimsProcessor : TokenProcessor { + + override val step = TokenProcessor.Step.CLAIMS + + override suspend fun process(context: TokenContext) { + val client = context.client ?: return + val allowedGrants = client.allowedGrants + val scopes = context.scopes + + context.commonClaims.also { + it.clientId = client.id + it.scopes = scopes?.filter { allowedGrants.contains(it) || allowedGrants.contains("*") }?.toSet() + } + context.accessClaims.apply { + client.accessTokenLifetime?.also { + val now = Instant.now() + issuedAt = NumericDate.fromSeconds(now.epochSecond) + expirationTime = NumericDate.fromSeconds(now.plus(it).epochSecond) + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/processor/ClientResolutionProcessor.kt b/src/main/kotlin/de/solugo/oidc/token/processor/ClientResolutionProcessor.kt new file mode 100644 index 0000000..f86c551 --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/processor/ClientResolutionProcessor.kt @@ -0,0 +1,26 @@ +package de.solugo.oidc.token.processor + +import de.solugo.oidc.model.Client +import de.solugo.oidc.token.* +import org.springframework.stereotype.Component +import java.time.Duration + +@Component +class ClientResolutionProcessor : TokenProcessor { + + override val step = TokenProcessor.Step.PRE + + override suspend fun process(context: TokenContext) { + val clientId = context.clientId ?: throw TokenException( + error = TokenError.InvalidRequest, + description = "Request is missing client_id parameter", + ) + + context.client = object : Client { + override val id = clientId + override val allowedGrants: Set = setOf("*") + override val accessTokenLifetime: Duration? = Duration.ofHours(1) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/token/processor/UserClaimsProcessor.kt b/src/main/kotlin/de/solugo/oidc/token/processor/UserClaimsProcessor.kt new file mode 100644 index 0000000..10d51bc --- /dev/null +++ b/src/main/kotlin/de/solugo/oidc/token/processor/UserClaimsProcessor.kt @@ -0,0 +1,21 @@ +package de.solugo.oidc.token.processor + +import de.solugo.oidc.token.* +import de.solugo.oidc.util.preferredUsername +import org.springframework.stereotype.Component + +@Component +class UserClaimsProcessor : TokenProcessor { + + override val step = TokenProcessor.Step.CLAIMS + + override suspend fun process(context: TokenContext) { + val user = context.user ?: return + + context.commonClaims.apply { + subject = user.id + preferredUsername = user.username + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/util/ClaimsUtil.kt b/src/main/kotlin/de/solugo/oidc/util/ClaimsUtil.kt index 03e3c0c..25154b3 100644 --- a/src/main/kotlin/de/solugo/oidc/util/ClaimsUtil.kt +++ b/src/main/kotlin/de/solugo/oidc/util/ClaimsUtil.kt @@ -3,16 +3,29 @@ package de.solugo.oidc.util import org.jose4j.jwt.JwtClaims private const val CLAIM_SCOPE = "scope" +private const val CLAIM_SESSION_ID = "sid" private const val CLAIM_CLIENT_ID = "client_id" +private const val CLAIM_PREFERRED_USERNAME = "preferred_username" -var JwtClaims.scope: String? +var JwtClaims.scopes: Set? get() = run { - getStringClaimValue(CLAIM_SCOPE) + getStringClaimValue(CLAIM_SCOPE)?.split(" ")?.toSet() } set(value) { - when (value) { - null -> unsetClaim(CLAIM_SCOPE) - else -> setClaim(CLAIM_SCOPE, value) + when { + value == null -> unsetClaim(CLAIM_SCOPE) + else -> setClaim(CLAIM_SCOPE, value.joinToString(" ")) + } + } + +var JwtClaims.preferredUsername: String? + get() = run { + getStringClaimValue(CLAIM_PREFERRED_USERNAME) + } + set(value) { + when { + value == null -> unsetClaim(CLAIM_PREFERRED_USERNAME) + else -> setClaim(CLAIM_PREFERRED_USERNAME, value) } } @@ -21,8 +34,28 @@ var JwtClaims.clientId: String? getStringClaimValue(CLAIM_CLIENT_ID) } set(value) { - when (value) { - null -> unsetClaim(CLAIM_CLIENT_ID) + when { + value == null -> unsetClaim(CLAIM_CLIENT_ID) else -> setClaim(CLAIM_CLIENT_ID, value) } - } \ No newline at end of file + } + +var JwtClaims.sessionId: String? + get() = run { + getStringClaimValue(CLAIM_SESSION_ID) + } + set(value) { + when { + value == null -> unsetClaim(CLAIM_SESSION_ID) + else -> setClaim(CLAIM_SESSION_ID, value) + } + } + +operator fun JwtClaims.plus(other: JwtClaims) = when { + this.claimsMap.isEmpty() -> other + other.claimsMap.isEmpty() -> this + else -> JwtClaims().also { + this.claimsMap.forEach { (key, value) -> it.setClaim(key, value) } + other.claimsMap.forEach { (key, value) -> it.setClaim(key, value) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/util/HttpUtil.kt b/src/main/kotlin/de/solugo/oidc/util/HttpUtil.kt deleted file mode 100644 index 5a87016..0000000 --- a/src/main/kotlin/de/solugo/oidc/util/HttpUtil.kt +++ /dev/null @@ -1,6 +0,0 @@ -package de.solugo.oidc.util - -import org.springframework.http.HttpStatus -import org.springframework.web.server.ResponseStatusException - -fun badRequest(message: String): Nothing = throw ResponseStatusException(HttpStatus.BAD_REQUEST, message) \ No newline at end of file diff --git a/src/main/kotlin/de/solugo/oidc/util/Util.kt b/src/main/kotlin/de/solugo/oidc/util/Util.kt index 07ff3a9..f7d3234 100644 --- a/src/main/kotlin/de/solugo/oidc/util/Util.kt +++ b/src/main/kotlin/de/solugo/oidc/util/Util.kt @@ -1,5 +1,9 @@ package de.solugo.oidc.util -import java.util.UUID +import java.util.* + +fun uuid(value: String? = null) = when { + value != null -> UUID.nameUUIDFromBytes(value.toByteArray()).toString() + else -> UUID.randomUUID().toString() +} -fun uuid() = UUID.randomUUID().toString() \ No newline at end of file diff --git a/src/test/kotlin/IntegrationTest.kt b/src/test/kotlin/IntegrationTest.kt new file mode 100644 index 0000000..c148627 --- /dev/null +++ b/src/test/kotlin/IntegrationTest.kt @@ -0,0 +1,39 @@ +import de.solugo.oidc.Server +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import org.junit.jupiter.api.TestInstance +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.core.env.Environment +import org.springframework.core.env.get +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@SpringBootTest(classes = [Server::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +abstract class IntegrationTest { + + @Autowired + private lateinit var environment: Environment + + protected val rest = HttpClient { + install(DefaultRequest) { + url("http://localhost:${environment["local.server.port"]}") + contentType(ContentType.Application.Json) + } + install(ContentNegotiation) { + jackson() + } + } + + companion object { + @JvmStatic + @DynamicPropertySource + fun configure(registry: DynamicPropertyRegistry) { + } + } + +} \ No newline at end of file diff --git a/src/test/kotlin/de/solugo/oidc/controller/TokenControllerTest.kt b/src/test/kotlin/de/solugo/oidc/controller/TokenControllerTest.kt new file mode 100644 index 0000000..6a945c9 --- /dev/null +++ b/src/test/kotlin/de/solugo/oidc/controller/TokenControllerTest.kt @@ -0,0 +1,96 @@ +package de.solugo.oidc.controller + +import IntegrationTest +import com.fasterxml.jackson.databind.node.ObjectNode +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class TokenControllerTest : IntegrationTest() { + + @Test + fun `Create token using password grant`() = runTest { + val parameters = parametersOf( + "grant_type" to listOf("password"), + "client_id" to listOf("client_test"), + "username" to listOf("test"), + "scope" to listOf("openid offline_access"), + ) + + rest.post("token") { + setBody(FormDataContent(parameters)) + }.apply { + status shouldBe HttpStatusCode.OK + body().apply { + + at("/token_type").textValue() shouldBe "Bearer" + at("/expires_in").numberValue() shouldBe 3600 + at("/access_token").textValue() shouldNotBe null + at("/id_token").textValue() shouldNotBe null + at("/refresh_token").textValue() shouldNotBe null + } + } + } + + @Test + fun `Create token using client credentials grant`() = runTest { + val parameters = parametersOf( + "grant_type" to listOf("client_credentials"), + "client_id" to listOf("client_test"), + "scope" to listOf("custom"), + ) + + rest.post("token") { + setBody(FormDataContent(parameters)) + }.apply { + status shouldBe HttpStatusCode.OK + body().apply { + at("/token_type").textValue() shouldBe "Bearer" + at("/expires_in").numberValue() shouldBe 3600 + at("/access_token").textValue() shouldNotBe null + at("/id_token").textValue() shouldBe null + at("/refresh_token").textValue() shouldBe null + } + } + } + + @Test + fun `Create token using refresh token`() = runTest { + val passwordParameters = parametersOf( + "grant_type" to listOf("password"), + "client_id" to listOf("client_test"), + "username" to listOf("test"), + "scope" to listOf("openid offline_access"), + ) + + val refreshToken = rest.post("token") { + setBody(FormDataContent(passwordParameters)) + }.run { + body().at("/refresh_token").textValue() + } + + val refreshParameters = parametersOf( + "grant_type" to listOf("refresh_token"), + "client_id" to listOf("client_test"), + "refresh_token" to listOf(refreshToken), + ) + + rest.post("token") { + setBody(FormDataContent(refreshParameters)) + }.apply { + status shouldBe HttpStatusCode.OK + body().apply { + at("/token_type").textValue() shouldBe "Bearer" + at("/expires_in").numberValue() shouldBe 3600 + at("/access_token").textValue() shouldNotBe null + at("/id_token").textValue() shouldNotBe null + at("/refresh_token").textValue() shouldNotBe null + } + } + } +} \ No newline at end of file