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))