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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<parent>
<groupId>com.zepben.maven</groupId>
<artifactId>evolve-super-pom</artifactId>
<version>0.50.0</version>
<version>0.51.0</version>
</parent>

<groupId>com.zepben</groupId>
Expand Down Expand Up @@ -75,12 +75,12 @@
<dependency>
<groupId>com.zepben</groupId>
<artifactId>protobuf</artifactId>
<version>1.8.0b3</version>
<version>1.8.0b4</version>
</dependency>
<dependency>
<groupId>com.zepben</groupId>
<artifactId>vertx-utils</artifactId>
<version>2.0.0</version>
<version>2.1.0b1</version>
</dependency>

<!-- Kotlin -->
Expand Down
248 changes: 75 additions & 173 deletions src/main/kotlin/com/zepben/ewb/auth/server/Auth0AuthHandler.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String>,
private val skip: String? = null,
) : AuthenticationHandler {

private val authorities = mutableSetOf<String>()
) : AuthenticationHandlerImpl<JWTAuthProvider>(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<String>): Auth0AuthHandler {
this.authorities.addAll(authorities)
return this
}
override fun authenticate(context: RoutingContext): Future<User> {
val promise = Promise.promise<User>()

private fun authorize(user: User?, handler: Handler<AsyncResult<Void?>>) {
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<JsonObject> ->
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<User> ->
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<Credentials?> {
// 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<Credentials>()

getBearerToken()
.onSuccess { promise.complete(TokenCredentials(it)) }
.onFailure { promise.fail(it) }

return promise.future()
}

private fun parseAuthorization(
ctx: RoutingContext,
handler: Handler<AsyncResult<String?>>,
) {
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<String> {
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<AsyncResult<JsonObject>>?) {

if (skip != null && context!!.normalizedPath().startsWith(skip)) {
context.next()
return
}

parseAuthorization(
context!!,
Handler { parseAuthorization: AsyncResult<String?> ->
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)

}
31 changes: 20 additions & 11 deletions src/main/kotlin/com/zepben/ewb/auth/server/JWTAuthoriser.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -37,14 +41,19 @@ object JWTAuthoriser {
fun authorise(token: DecodedJWT, requiredClaims: Set<String>): 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.")
}

}
Loading
Loading