Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

5.13.0:
- Update to VC-K 5.13.0
- Migrate to Spring Boot 4.0.6 (Spring Framework 7, Spring Security 7, Jakarta EE 11)
- Update Spring Cloud to 2025.1.1 (Oakwood)
- Update Spring Boot Admin client to 4.0.4
- Upgrade JVM toolchain to Java 21
- Enable virtual threads (`spring.threads.virtual.enabled`)
- Switch primary HTTP message converter to kotlinx.serialization backed by `joseCompliantSerializer`


5.12.0:
- Update to VC-K 5.12.0
- Add DC API issuing process
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1

FROM eclipse-temurin:17-jdk-jammy AS build
FROM eclipse-temurin:21-jdk-jammy AS build

WORKDIR /workspace

Expand All @@ -18,7 +18,7 @@ RUN set -eux; \
test -n "$jar"; \
cp "$jar" /workspace/service.jar

FROM eclipse-temurin:17-jre-jammy AS runtime
FROM eclipse-temurin:21-jre-jammy AS runtime

WORKDIR /app

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
#Kotlin
kotlin.code.style=official
jdk.version=17
jdk.version=21
artifactVersion=5.12.0
10 changes: 5 additions & 5 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
eupid = "3.4.0"
eupid = "3.5.0-SNAPSHOT"
eupid-sdjwt = "1.4.0"
mdl = "1.4.0"
taxid = "1.3.0"
Expand All @@ -10,12 +10,12 @@ av = "1.1.0"
qrcode = "4.5.0"
pgsql = "42.7.7"
mockito = "6.2.1"
vck = "5.12.0"
vck = "5.13.0-SNAPSHOT"
nimbus = "10.7"

spring-boot = "3.5.13"
spring-cloud = "2025.0.2"
spring-admin-starter-client = "3.5.8"
spring-boot = "4.0.6"
spring-cloud = "2025.1.1"
spring-admin-starter-client = "4.0.4"

webjars-locator = "0.47"
webjars-bootstrap = "5.3.1"
Expand Down
12 changes: 9 additions & 3 deletions http/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,17 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-web") {
exclude(group = "com.fasterxml.jackson.core")
exclude(group = "com.fasterxml.jackson.module")
exclude(group = "com.fasterxml.jackson.datatype")
}
implementation("org.springframework.session:spring-session-core")
implementation(libs.spring.boot.admin.starter.client)
implementation(libs.spring.cloud.starter.config)

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.apache.httpcomponents.client5:httpclient5")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")

Expand Down Expand Up @@ -84,8 +88,11 @@ dependencies {

runtimeOnly("com.h2database:h2")
runtimeOnly(libs.postgresql)
runtimeOnly("org.springframework.boot:spring-boot-properties-migrator")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-webtestclient")
testImplementation("org.springframework.boot:spring-boot-starter-webflux")
testImplementation("org.springframework.security:spring-security-test")
testImplementation(libs.mockito.kotlin)
Expand All @@ -111,7 +118,6 @@ tasks.getByName<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar
//this is only relevant for a composite build and will nicely print out the warning that
//the VC-K jar from the composite build overwrites the one from the gradle cache, just like we want it
duplicatesStrategy = DuplicatesStrategy.WARN
launchScript()
}

springBoot {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import at.asitplus.wallet.lib.agent.StatusListIssuer
import at.asitplus.wallet.lib.agent.TimePeriodProvider
import at.asitplus.wallet.lib.data.rfc3986.UniformResourceIdentifier
import at.asitplus.wallet.lib.data.rfc.tokenStatusList.agents.ReferencedTokenStore
import at.asitplus.wallet.lib.data.vckJsonSerializer
import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer
import at.asitplus.wallet.lib.oauth2.SimpleAuthorizationService
import at.asitplus.wallet.lib.oauth2.TokenService
import at.asitplus.wallet.lib.oidvci.CredentialAuthorizationServiceStrategy
Expand Down Expand Up @@ -268,7 +268,7 @@ class BackendConfiguration {

@Bean
fun messageConverter(): KotlinSerializationJsonHttpMessageConverter =
KotlinSerializationJsonHttpMessageConverter(vckJsonSerializer)
KotlinSerializationJsonHttpMessageConverter(joseCompliantSerializer)
}

private class IsoMdocRoutingIssuer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import at.asitplus.wallet.lib.agent.ClaimToBeIssuedArrayElement
import at.asitplus.wallet.lib.agent.CredentialToBeIssued
import at.asitplus.wallet.lib.data.ConstantIndex
import at.asitplus.wallet.lib.data.LocalDateOrInstant
import at.asitplus.wallet.lib.data.vckJsonSerializer
import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer
import at.asitplus.wallet.lib.jws.JwsHeaderModifierFun
import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements
import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme
Expand Down Expand Up @@ -97,23 +97,12 @@ fun OidcUserInfoExtended.buildEuPidCredential(
givenName = userInfo.givenName ?: "N/A",
birthDate = dateOfBirth,
portrait = portrait,
ageOver12 = ageOver12,
ageOver13 = ageOver13,
ageOver14 = ageOver14,
ageOver16 = ageOver16,
ageOver18 = ageOver18,
ageOver21 = ageOver21,
ageOver25 = ageOver25,
ageOver60 = ageOver60,
ageOver62 = ageOver62,
ageOver65 = ageOver65,
ageOver68 = ageOver68,
issuanceDate = LocalDateOrInstant.LocalDate(issueDate),
expiryDate = LocalDateOrInstant.LocalDate(expiryDate),
issuingAuthority = issuingAuthority,
issuingCountry = issuingCountry,
).let {
vckJsonSerializer.encodeToJsonElement(it)
joseCompliantSerializer.encodeToJsonElement(it)
}.also { Napier.v("toEuPidCredential returns $it") },
expiration = exp,
scheme = scheme,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package at.asitplus.wallet.backend.config

import at.asitplus.openid.OpenIdConstants.Errors
import at.asitplus.wallet.backend.Paths
import at.asitplus.wallet.lib.ktor.openid.DPoPNonce
import at.asitplus.wallet.lib.oidvci.OAuth2Error
import at.asitplus.wallet.lib.oidvci.OAuth2Exception
import io.ktor.http.*
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ModelAttribute

/** To export some values globally to Thymeleaf templates. */
Expand All @@ -13,4 +21,20 @@ class GlobalModel {

@ModelAttribute("paths")
fun paths() = Paths

@ExceptionHandler(Exception::class)
fun handle(throwable: Throwable) = when (throwable) {
is OAuth2Exception.UseDpopNonce -> ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.header(HttpHeaders.DPoPNonce, throwable.dpopNonce)
.body(throwable.toOAuth2Error())

is OAuth2Exception -> ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(throwable.toOAuth2Error())

else -> ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(OAuth2Error(error = Errors.INVALID_REQUEST))
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package at.asitplus.wallet.backend.config

import at.asitplus.wallet.backend.Paths
import jakarta.servlet.DispatcherType
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.beans.factory.annotation.Value
Expand Down Expand Up @@ -74,6 +75,9 @@ class WebSecurityConfig(
}.headers {
it.frameOptions { it.sameOrigin() }
}.authorizeHttpRequests {
// Suspend MVC handlers resume through an internal ASYNC dispatch after the initial request was secured.
// Requiring authentication again on that redispatch can replace the handler response with /login.
it.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll()
it.requestMatchers(Paths.AuthorizeUrl).authenticated()
it.anyRequest().permitAll()
}.formLogin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import at.asitplus.wallet.eupid.EuPidScheme
import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme
import at.asitplus.wallet.lib.data.ConstantIndex
import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation
import at.asitplus.wallet.lib.data.vckJsonSerializer
import at.asitplus.wallet.lib.oauth2.SimpleAuthorizationService
import at.asitplus.wallet.lib.oidvci.CredentialIssuer
import at.asitplus.wallet.lib.utils.DefaultMapStore
Expand All @@ -23,15 +22,15 @@ import io.github.aakira.napier.Napier
import io.matthewnelson.encoding.base64.Base64
import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString
import jakarta.servlet.http.HttpSession
import kotlinx.coroutines.runBlocking
import org.springframework.http.MediaType.APPLICATION_JSON_VALUE
import org.springframework.http.ResponseEntity
import org.springframework.http.HttpStatus
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.ui.ModelMap
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.web.servlet.ModelAndView
import org.springframework.web.util.UriComponentsBuilder
import qrcode.QRCode
Expand All @@ -54,34 +53,31 @@ class IndexController(
* Will be called by the Wallet when loading an offer that is presented as a QR Code on the index page
*/
@GetMapping("${Paths.OfferUrl}/{nonce}", produces = [APPLICATION_JSON_VALUE])
fun offerForNonce(@PathVariable nonce: String): ResponseEntity<CredentialOffer> = runBlocking {
suspend fun offerForNonce(@PathVariable nonce: String): CredentialOffer {
Napier.i("${Paths.OfferUrl}/$nonce called")
nonceToOfferMap.get(nonce)?.let {
Napier.d("${Paths.OfferUrl}/$nonce returns $it")
ResponseEntity.ok(it)
} ?: ResponseEntity.notFound().build()
return nonceToOfferMap.get(nonce)
?.also { Napier.d("${Paths.OfferUrl}/$nonce returns $it") }
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

/**
* DC API issuance payload derived from the credential offer, returned as JSON.
*/
@GetMapping("${Paths.DcApiCreateRequestUrl}/{nonce}", produces = [APPLICATION_JSON_VALUE])
fun dcApiCreateRequest(@PathVariable nonce: String): ResponseEntity<String> = runBlocking {
suspend fun dcApiCreateRequest(@PathVariable nonce: String): CredentialCreationOptions {
Napier.i("${Paths.DcApiCreateRequestUrl}/$nonce called")
nonceToOfferMap.get(nonce)?.let { offer ->
require(offer.grants?.authorizationCode?.authorizationServer == null)
val enrichedOffer = offer.copy(
grants = offer.grants?.copy(
preAuthorizedCode = offer.grants?.preAuthorizedCode?.copy(
authorizationServer = null
)
),
authorizationServerMetadata = authorizationService.metadata(),
credentialIssuerMetadata = credentialIssuer.metadata.copy(authorizationServers = null),
)
val options = CredentialCreationOptions.create(enrichedOffer)
ResponseEntity.ok(vckJsonSerializer.encodeToString(options))
} ?: ResponseEntity.notFound().build()
val offer = nonceToOfferMap.get(nonce) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
require(offer.grants?.authorizationCode?.authorizationServer == null)
val enrichedOffer = offer.copy(
grants = offer.grants?.copy(
preAuthorizedCode = offer.grants?.preAuthorizedCode?.copy(
authorizationServer = null
)
),
authorizationServerMetadata = authorizationService.metadata(),
credentialIssuerMetadata = credentialIssuer.metadata.copy(authorizationServers = null),
)
return CredentialCreationOptions.create(enrichedOffer)
}

/**
Expand All @@ -90,11 +86,11 @@ class IndexController(
* as well as offers for pre-authorized flows when the user is logged in.
*/
@GetMapping("/")
fun index(
suspend fun index(
model: ModelMap,
session: HttpSession,
authentication: Authentication?,
): ModelAndView = runBlocking {
): ModelAndView {
Napier.i("/index called with session ${session.id} and $authentication")
val user = SpringSecurityAuthenticationSupplier.toOidcUserInfoExtended(authentication)
?: SecurityContextHolder.getContext().authentication
Expand Down Expand Up @@ -172,7 +168,7 @@ class IndexController(
)
} ?: listOf()
model["tabs"] = authCodeTabs + preAuthTabs
ModelAndView("index")
return ModelAndView("index")
}

private suspend fun buildTabItemPreAuthn(
Expand Down
Loading
Loading