diff --git a/changelog.md b/changelog.md
index d0b5938d8..45c23cc57 100644
--- a/changelog.md
+++ b/changelog.md
@@ -5,6 +5,7 @@
* `ContactDetails`:
* Is now `Identifiable`, and uses `mRID` instead of `id`. `id` is still available as an accessor, but is deprecated and simply gets the `mRID`.
* Now requires an `mRID` on creation - an ID is no longer generated by default.
+* Updated to support Vert.x v5 and its breaking changes.
### New Features
* Adds `compareRunTime` to `NetworkServiceComparatorOptions` to allow the users to ignore variables/references that are only populated during EWB spin up.
diff --git a/pom.xml b/pom.xml
index e3dab7f0b..a7c085577 100755
--- a/pom.xml
+++ b/pom.xml
@@ -12,7 +12,7 @@
com.zepben.maven
evolve-super-pom
- 0.50.0
+ 0.51.0
com.zepben
@@ -75,12 +75,12 @@
com.zepben
protobuf
- 1.8.0b3
+ 1.8.0b4
com.zepben
vertx-utils
- 2.0.0
+ 2.1.0b1
diff --git a/src/main/kotlin/com/zepben/ewb/auth/server/Auth0AuthHandler.kt b/src/main/kotlin/com/zepben/ewb/auth/server/Auth0AuthHandler.kt
index 4d10de498..3d17e7752 100644
--- a/src/main/kotlin/com/zepben/ewb/auth/server/Auth0AuthHandler.kt
+++ b/src/main/kotlin/com/zepben/ewb/auth/server/Auth0AuthHandler.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 Zeppelin Bend Pty Ltd
+ * Copyright 2026 Zeppelin Bend Pty Ltd
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -12,215 +12,117 @@ package com.zepben.ewb.auth.server
import com.auth0.jwt.interfaces.DecodedJWT
import com.zepben.ewb.auth.common.StatusCode
import com.zepben.ewb.auth.server.vertx.JWTAuthProvider
-import io.vertx.core.AsyncResult
import io.vertx.core.Future
-import io.vertx.core.Handler
+import io.vertx.core.Promise
+import io.vertx.core.VertxException
import io.vertx.core.http.HttpHeaders
-import io.vertx.core.http.HttpMethod
-import io.vertx.core.json.JsonObject
import io.vertx.ext.auth.User
+import io.vertx.ext.auth.authentication.Credentials
+import io.vertx.ext.auth.authentication.TokenCredentials
import io.vertx.ext.web.RoutingContext
-import io.vertx.ext.web.handler.AuthenticationHandler
import io.vertx.ext.web.handler.HttpException
+import io.vertx.ext.web.handler.impl.AuthenticationHandlerImpl
+/**
+ * A route handler that supports the Auth0/Entra JWT flow, with a customisable auth provider.
+ */
class Auth0AuthHandler(
- private val authProvider: JWTAuthProvider,
+ authProvider: JWTAuthProvider,
requiredClaims: Set,
private val skip: String? = null,
-) : AuthenticationHandler {
-
- private val authorities = mutableSetOf()
+) : AuthenticationHandlerImpl(authProvider) {
- init {
- addAuthorities(requiredClaims)
- }
+ // NOTE: We take a copy of the required claims to make sure they can't be modified after being passed in.
+ private val requiredClaims = requiredClaims.toSet()
- private fun addAuthorities(authorities: Set): Auth0AuthHandler {
- this.authorities.addAll(authorities)
- return this
- }
+ override fun authenticate(context: RoutingContext): Future {
+ val promise = Promise.promise()
- private fun authorize(user: User?, handler: Handler>) {
- if (authorities.isEmpty()) {
- // No auth required
- handler.handle(Future.succeededFuture())
- return
- }
- if (user == null) {
- handler.handle(Future.failedFuture(HttpException(403, "No user was found, you must authenticate first")))
- return
- }
- for (authority in authorities) {
- val token = user.attributes().getValue("token") as DecodedJWT
- val resp = JWTAuthoriser.authorise(token, authority)
- if (resp.statusCode !== StatusCode.OK) {
- handler.handle(Future.failedFuture(HttpException(403, "Could not authorise all requested permissions. This is likely a bug.")))
- return
- }
+ // Check if this route has been excluded from auth.
+ if (context.shouldSkipRoute()) {
+ context.next()
+ return Future.succeededFuture()
}
- handler.handle(Future.succeededFuture())
- }
- override fun handle(ctx: RoutingContext) {
- if (handlePreflight(ctx)) {
- return
- }
- val user = ctx.user()
- if (user != null) {
- // proceed to AuthZ
- authorizeUser(ctx, user)
- return
- }
// parse the request in order to extract the credentials object
- parseCredentials(ctx) { res: AsyncResult ->
- if (res.failed()) {
- processException(ctx, res.cause())
- return@parseCredentials
- }
- // check if the user has been set
- val updatedUser = ctx.user()
- if (updatedUser != null) {
- val session = ctx.session()
- session?.regenerateId()
- // proceed to AuthZ
- authorizeUser(ctx, updatedUser)
- return@parseCredentials
- }
-
- // proceed to authN
- authProvider.authenticate({ res.result() }) { authN: AsyncResult ->
- if (authN.succeeded()) {
- val authenticated = authN.result()
- ctx.setUser(authenticated)
- val session = ctx.session()
- session?.regenerateId()
- // proceed to AuthZ
- authorizeUser(ctx, authenticated)
+ context.parseCredentials()
+ .onSuccess { credentials ->
+ if (credentials == null) {
+ // A success with no credentials indicates that auth isn't wanted, so just complete with no authenticated user.
+ promise.complete()
} else {
- if (authN.cause() is HttpException) {
- processException(ctx, authN.cause())
- } else {
- processException(ctx, HttpException(401, authN.cause()))
+ // proceed to authN
+ authProvider.authenticate(credentials).onSuccess { authenticated ->
+ promise.complete(authenticated)
+ }.onFailure { cause ->
+ when (cause) {
+ is HttpException -> promise.fail(cause)
+ else -> promise.fail(HttpException(401, cause))
+ }
}
}
+ }.onFailure {
+ promise.fail(it)
}
- }
+
+ return promise.future()
}
- private fun processException(ctx: RoutingContext, exception: Throwable?) {
- if (exception != null) {
- if (exception is HttpException) {
- val statusCode = exception.statusCode
- val payload = exception.payload
- when (statusCode) {
- 302 -> {
- ctx.response()
- .putHeader(HttpHeaders.LOCATION, payload)
- .setStatusCode(302)
- .end("Redirecting to $payload.")
- return
- }
+ override fun postAuthentication(ctx: RoutingContext) {
+ // Check if this route has been excluded from auth.
+ if (ctx.shouldSkipRoute())
+ return
- else -> {
- ctx.response()
- .setStatusCode(exception.statusCode)
- .setStatusMessage(exception.message)
- payload?.let { ctx.response().end(payload) }
- return
- }
+ val user = ctx.user()
+ when {
+ requiredClaims.isEmpty() -> super.postAuthentication(ctx) // No auth required
+ user == null -> ctx.fail(403, VertxException("No user was found, you must authenticate first", true))
+ else -> {
+ val token = user.attributes().getValue("token") as DecodedJWT
+ val resp = JWTAuthoriser.authorise(token, requiredClaims)
+ if (resp.statusCode !== StatusCode.OK) {
+ ctx.fail(resp.statusCode.code, VertxException(resp.message, true))
+ return
}
+ super.postAuthentication(ctx)
}
}
-
- // fallback 500
- ctx.fail(exception)
}
- private fun authorizeUser(ctx: RoutingContext, user: User) {
- authorize(user) { authZ ->
- if (authZ.failed()) {
- processException(ctx, authZ.cause())
- return@authorize
- }
- // success, allowed to continue
- ctx.next()
+ private fun RoutingContext.parseCredentials(): Future {
+ // Check if this route has been excluded from auth.
+ if (shouldSkipRoute()) {
+ next()
+ return Future.succeededFuture()
}
- }
- private fun handlePreflight(ctx: RoutingContext): Boolean {
- val request = ctx.request()
- // See: https://www.w3.org/TR/cors/#cross-origin-request-with-preflight-0
- // Preflight requests should not be subject to security due to the reason UAs will remove the Authorization header
- if (request.method() == HttpMethod.OPTIONS) {
- // check if there is an access control request header
- val accessControlRequestHeader =
- ctx.request().getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS)
- if (accessControlRequestHeader != null) {
- // lookup for the Authorization header
- for (ctrlReq in accessControlRequestHeader.split(",".toRegex()).toTypedArray()) {
- if (ctrlReq.equals("Authorization", ignoreCase = true)) {
- // this request has auth in access control, so we can allow preflights without authentication
- ctx.next()
- return true
- }
- }
- }
- }
- return false
+ val promise = Promise.promise()
+
+ getBearerToken()
+ .onSuccess { promise.complete(TokenCredentials(it)) }
+ .onFailure { promise.fail(it) }
+
+ return promise.future()
}
- private fun parseAuthorization(
- ctx: RoutingContext,
- handler: Handler>,
- ) {
- val request = ctx.request()
- val authorization = request.headers()[HttpHeaders.AUTHORIZATION] ?: run {
- handler.handle(
- Future.failedFuture(
- HttpException(401, "Missing Authorization header"),
- ),
- ); return
- }
+ private fun RoutingContext.getBearerToken(): Future {
+ val request = request()
+ val authorization = request.headers()[HttpHeaders.AUTHORIZATION]
+ ?: return Future.failedFuture(HttpException(401, "Missing Authorization header"))
- try {
+ return try {
val idx = authorization.indexOf(' ')
- if (idx <= 0) {
- handler.handle(Future.failedFuture(HttpException(400, "Badly formed Authorization header")))
- return
- }
- if (authorization.substring(0, idx) != "Bearer") {
- handler.handle(Future.failedFuture(HttpException(401, "Missing Bearer token from Authorization header")))
- return
+ when {
+ idx <= 0 -> Future.failedFuture(HttpException(400, "Badly formed Authorization header"))
+ authorization.substring(0, idx) != "Bearer" -> Future.failedFuture(HttpException(401, "Missing Bearer token from Authorization header"))
+ else -> Future.succeededFuture(authorization.substring(idx + 1))
}
- handler.handle(Future.succeededFuture(authorization.substring(idx + 1)))
} catch (e: RuntimeException) {
- handler.handle(Future.failedFuture(e))
+ Future.failedFuture(e)
}
}
- private fun parseCredentials(context: RoutingContext?, handler: Handler>?) {
-
- if (skip != null && context!!.normalizedPath().startsWith(skip)) {
- context.next()
- return
- }
-
- parseAuthorization(
- context!!,
- Handler { parseAuthorization: AsyncResult ->
- if (parseAuthorization.failed()) {
- handler!!.handle(Future.failedFuture(parseAuthorization.cause()))
- return@Handler
- }
- handler!!.handle(
- Future.succeededFuture(
- JsonObject().put("jwt", parseAuthorization.result()),
- ),
- )
- },
- )
-// context.response().end() TODO: this must not occur on some endpoints. needs to occur if auth fails. maybe we are not
- // failing fast if authN/Z fails? need to make sure permissions are in the web client scope too - token is missing them.
- }
+ private fun RoutingContext.shouldSkipRoute(): Boolean =
+ (skip != null) && normalizedPath().startsWith(skip)
}
diff --git a/src/main/kotlin/com/zepben/ewb/auth/server/JWTAuthoriser.kt b/src/main/kotlin/com/zepben/ewb/auth/server/JWTAuthoriser.kt
index 122ad284f..c7e4ab344 100644
--- a/src/main/kotlin/com/zepben/ewb/auth/server/JWTAuthoriser.kt
+++ b/src/main/kotlin/com/zepben/ewb/auth/server/JWTAuthoriser.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 Zeppelin Bend Pty Ltd
+ * Copyright 2026 Zeppelin Bend Pty Ltd
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -11,9 +11,14 @@ package com.zepben.ewb.auth.server
import com.auth0.jwt.interfaces.DecodedJWT
import com.zepben.ewb.auth.common.StatusCode
+import org.slf4j.LoggerFactory
object JWTAuthoriser {
+ private val logger = LoggerFactory.getLogger(javaClass)
+
+ private val claimChecks = listOf("permissions", "roles")
+
/**
* Authorise a JWT.
* This function will check that a JWT has the required claims. The claims will be extracted from "permissions" (Auth0) or "roles" (EntraID), if "permissions" field is missing.
@@ -22,9 +27,8 @@ object JWTAuthoriser {
* @param requiredClaim The claim to authorise.
*/
@JvmStatic
- fun authorise(token: DecodedJWT, requiredClaim: String): AuthResponse {
- return authorise(token, setOf(requiredClaim))
- }
+ fun authorise(token: DecodedJWT, requiredClaim: String): AuthResponse =
+ authorise(token, setOf(requiredClaim))
/**
* Authorise a JWT.
@@ -37,14 +41,19 @@ object JWTAuthoriser {
fun authorise(token: DecodedJWT, requiredClaims: Set): AuthResponse {
if (requiredClaims.isEmpty())
return AuthResponse(StatusCode.OK)
- val permissions = run {
- token.getClaim("permissions").asList(String::class.java) ?: token.getClaim("roles").asList(String::class.java) ?: emptyList()
- }.toHashSet()
+
+ val permissions = claimChecks.firstNotNullOfOrNull {
+ token.getClaim(it).asList(String::class.java)
+ }.orEmpty().toSet()
+
if (permissions.intersect(requiredClaims).size == requiredClaims.size)
return AuthResponse(StatusCode.OK)
- return AuthResponse(
- StatusCode.UNAUTHENTICATED,
- "Token was missing a required claim. Had [${permissions.joinToString(", ")}] but needed [${requiredClaims.joinToString(", ")}]",
- )
+
+ if (logger.isDebugEnabled)
+ logger.debug("Token was missing a required claim. Had [${permissions.joinToString(", ")}] but needed [${requiredClaims.joinToString(", ")}]")
+
+ // NOTE: We deliberately drop the actual claims from the response to prevent security leaks to the client.
+ return AuthResponse(StatusCode.UNAUTHENTICATED, "Token was missing a required claim.")
}
+
}
diff --git a/src/main/kotlin/com/zepben/ewb/auth/server/vertx/JWTAuthProvider.kt b/src/main/kotlin/com/zepben/ewb/auth/server/vertx/JWTAuthProvider.kt
index 5a71f77c5..bcfc4033c 100644
--- a/src/main/kotlin/com/zepben/ewb/auth/server/vertx/JWTAuthProvider.kt
+++ b/src/main/kotlin/com/zepben/ewb/auth/server/vertx/JWTAuthProvider.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 Zeppelin Bend Pty Ltd
+ * Copyright 2026 Zeppelin Bend Pty Ltd
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -9,45 +9,50 @@
package com.zepben.ewb.auth.server.vertx
+import com.auth0.jwt.interfaces.DecodedJWT
import com.zepben.ewb.auth.common.StatusCode
import com.zepben.ewb.auth.server.TokenAuthenticator
import com.zepben.ewb.auth.server.asHttpException
-import io.vertx.core.AsyncResult
import io.vertx.core.Future
-import io.vertx.core.Handler
import io.vertx.core.json.JsonObject
import io.vertx.ext.auth.User
import io.vertx.ext.auth.authentication.AuthenticationProvider
import io.vertx.ext.auth.authentication.Credentials
+import io.vertx.ext.auth.authentication.TokenCredentials
/**
* An implementation of an [AuthenticationProvider] that performs JWT authentication with the provided [tokenAuthenticator]
*
* @property tokenAuthenticator The Authenticator to use for authentication.
*/
-class JWTAuthProvider(private val tokenAuthenticator: TokenAuthenticator) : AuthenticationProvider {
-
- @Deprecated("Deprecated in Java")
- override fun authenticate(authInfo: JsonObject?, resultHandler: Handler>?) {
- val token: String? = authInfo?.getString("jwt")
- val resp = tokenAuthenticator.authenticate(token)
- if (resp.statusCode !== StatusCode.OK) {
- resultHandler?.handle(Future.failedFuture(resp.asHttpException()))
- return
+class JWTAuthProvider(
+ private val tokenAuthenticator: TokenAuthenticator,
+) : AuthenticationProvider {
+
+ /**
+ * Authenticate a client based on the provided [credentials].
+ * @param credentials A [Credentials] for this client request.
+ * @return A future [User] if the [credentials] were a valid JWT.
+ */
+ override fun authenticate(credentials: Credentials): Future =
+ when (credentials) {
+ is TokenCredentials -> authenticateToken(credentials)
+ else -> Future.failedFuture("Unable to authenticate credentials of type ${credentials::class.simpleName}, only TokenCredentials are supported.")
}
- resp.token?.let {
- val user = User.create(JsonObject().put("access_token", it.token), JsonObject().put("token", it))
- resultHandler?.handle(Future.succeededFuture(user))
- } ?: resultHandler?.handle(
- Future.failedFuture("Token was missing on successful auth - this is a bug."),
- )
+ private fun authenticateToken(credentials: TokenCredentials): Future {
+ val resp = tokenAuthenticator.authenticate(credentials.token)
+ return when {
+ resp.statusCode !== StatusCode.OK -> Future.failedFuture(resp.asHttpException())
+ resp.token != null -> Future.succeededFuture(resp.token.toUser())
+ else -> Future.failedFuture("Token was missing on successful auth - this is a bug.")
+ }
}
- /**
- * Authenticate a client based on the provided [authInfo].
- * @param A [JsonObject] with a "jwt" entry with the JWT for this client.
- */
- override fun authenticate(credentials: Credentials?, resultHandler: Handler>?) = authenticate(credentials?.toJson(), resultHandler)
+ private fun DecodedJWT.toUser(): User =
+ User.create(
+ JsonObject().put("access_token", token),
+ JsonObject().put("token", this),
+ )
}
diff --git a/src/test/kotlin/com/zepben/ewb/auth/server/JWTAuthenticatorTest.kt b/src/test/kotlin/com/zepben/ewb/auth/server/JWTAuthenticatorTest.kt
index 61c68dd45..5c6e6efee 100644
--- a/src/test/kotlin/com/zepben/ewb/auth/server/JWTAuthenticatorTest.kt
+++ b/src/test/kotlin/com/zepben/ewb/auth/server/JWTAuthenticatorTest.kt
@@ -60,10 +60,9 @@ class JWTAuthenticatorTest {
authResp = authorise(successfulToken, "bacon")
assertThat(authResp.statusCode, equalTo(StatusCode.UNAUTHENTICATED))
- assertThat(
- authResp.message,
- equalTo("Token was missing a required claim. Had [read:network, read:ewb, write:metrics, write:network] but needed [bacon]")
- )
+ assertThat(authResp.message, equalTo("Token was missing a required claim."))
+ // If we had debug logging turned on we could check to see if the old message is logged:
+ // "Token was missing a required claim. Had [read:network, read:ewb, write:metrics, write:network] but needed [bacon]"
authResp = ta.authenticate("broken")
assertThat(authResp.statusCode, equalTo(StatusCode.UNAUTHENTICATED))