Building an Authorization Framework with Armeria - a Case Study

Building an Authorization Framework with Armeria - a Case Study
https://armeria.dev

I have been introduced to Armeria 2 years ago in 2022. Since then, it is my go-to framework for JVM based projects. Recently, I had some experience at work to build some shared authorization code in our system and I wanted to share my experience on how we built our authorization framework using Armeria by applying it to a theoretical scenario.

Case Study: Blog Application

Let's start by describing a theoretical scenario. We have a Blog website, in this website there will be members and authors. Members can subscribe to authors. Authors can write blog posts. Authors can change the visibility of each blog post to public, members-only or subscribers-only.

First issue, authentication. In today's standards, OAuth2 tokens are a pretty common way to authenticate. Let's assume our application uses OAuth2 JWT tokens. Armeria allows us to Decorate our code using Decorators. Let's create a decorator that requires a valid OAuth2 token.

val ACCESS_TOKEN_KEY: AttributeKey<Token> = AttributeKey.valueOf("access_token")

class RequireAccessToken : DecoratingHttpServiceFunction {
    override fun serve(
        delegate: HttpService,
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse {
        val token: String = ctx.request()
                               .headers()
                               .get("Authorization")
                               .removePrefix("Bearer ")

        val claims = MyJWTVerifier.validate(token)
        
        return if (claims.isValid) {
            ctx.setAttr(ACCESS_TOKEN_KEY, claims)
            delegate.serve(ctx, req)
        } else {
            HttpResponse.of(HttpStatus.UNAUTHORIZED)
        }
    }
}

A decorator that mandates an access token

This decorator does two things:

  1. Ensure there is a valid JWT token issued. (Implementation of MyJWTVerifier is up-to-you).
  2. Inject claims parsed from the JWT token to the request context.

Number 1 is obviously required to make an endpoint protected. Number 2 will be used to authorize using actors and relations (ABAC, RBAC...) in upcoming section. So, let's go ahead and apply this decorator.

@Decorator(RequireAccessToken::class)
class BlogPostController {

  @Get("/blog_posts")
  suspend fun listBlogPosts(): List<BlogPost> { ... }  

  @Post("/blog_posts")
  suspend fun createBlogPost(body: CreateBlogPostBody): BlogPost { ... }
}

Now, using the Access Token decorator, we have enforced all requests coming to our controller to have a valid JWT token. Note that this is an annotated service, however the same decorator will work for other HTTP services even including gRPC services.

In this implementation, some requirements are not met. For example, there are blog posts that are publicly visible. To fix it, we need a graceful way to inject token metadata into the request context.

class MaybeAccessToken : DecoratingHttpServiceFunction {
    override fun serve(
        delegate: HttpService,
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse {
        val token: String = ctx.request()
                               .headers()
                               .get("Authorization")
                               .removePrefix("Bearer ")

        val claims = MyJWTVerifier.validate(token)
        
        if (claims.isValid) {
            ctx.setAttr(ACCESS_TOKEN_KEY, claims)
        }

        return delegate.serve(ctx, req)
    }
}

This slight modified decorator will not throw an Unauthorized exception when there is no token present. Let's modify our controller to accommodate this change.

class BlogPostController {

  @Get("/blog_posts")
  @Decorator(MaybeAccessToken::class)
  suspend fun listBlogPosts(): List<BlogPost> {
    val token = ServiceRequestContext.current().getAttr(ACCESS_TOKEN_KEY)

    // Only public endpoints
    if (token == null) {
       return BlogRepository.listPublicBlogPosts()
    }

    val subscribedAuthors = Subscriptions.getForUser(token.userId).map { it.authorId }

    return repository.listAllBlogPosts(subscribedAuthors)
  }  

  @Post("/blog_posts")
  @Decorator(RequireAccessToken::class)
  suspend fun createBlogPost(body: CreateBlogPostBody): BlogPost { ... }
}

Now, let's add a slight twist to this scenario. Let's assume this Blog website was created long ago and it also has a mobile app. In the mobile app, instead of using JWT tokens, we were using username and password header (Yikes!). Even though this is not desired, some real world applications might need to support their legacy code for different reasons. In this example, the application was created long ago and it did not have OTA updates. So even if we migrate to JWT in the mobile app, to keep serving our old users, we need to keep supporting their way of authorizing.

Let's modify the decorator to take the username, password header into account.

val USER_ID_KEY: AttributeKey<UUID> = AttributeKey.valueOf("user_id")

class MaybeUser : DecoratingHttpServiceFunction {
    override fun serve(
        delegate: HttpService,
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse {
        val jwtToken: String = ctx.request()
                               .headers()
                               .get("Authorization")

        if (jwtToken.startsWith("Bearer")) { 
            val claims = MyJWTVerifier.validate(jwtToken.removePrefix("Bearer ")
            ctx.setAttr(USER_ID_KEY, claims.userId)
        }

        if (jwtToken.startsWith("Basic")) {
            val userNameAndPassword = jwtToken.removePrefix("Basic ").split(":")
            val userId: UUID? = Users.check(userNameAndPassword[0], userNameAndPassword[1])
            ctx.setAttr(USER_ID_KEY, claims.userId)
        }
        

        return delegate.serve(ctx, req)
    }
}

Now, with this new decorator, we have delegated the business logic for finding out which user made to call outside the controller. We basically wrap our controller with a single annotation and it magically injects the calling user into the context.

Suspend Calls in Decorators

We most likely need to make suspending calls from decorators to do certain checks such as database calls, network calls etc. This includes user login check and maybe JWT verification. As you might have noticed, it is currently not possible to create suspend decorators (issue to track). So to achieve this, we can use the event loop as our dispatcher.

        val future = CoroutineScope(ctx.eventLoop().asCoroutineDispatcher()).future {
            // Can call suspend functions here
            UserRepository.login(...)
            HttpResponse.of(HttpStatus.OK)
        }
        
        return HttpResponse.of(future)

Note that by using event loop, you should ensure your suspend functions are following the best practice and they can be safely called from the main thread without blocking it. otherwise you should use some other dispatcher, i.e. blocking task executor or Dispatchers.IO.

Handling Dependency Injection

As you might have noticed, our repositories were assumed to be objects for simplicity in the first examples. However, in real world application, dependency injection frameworks such as Koin is being widely adopted. For example with Koin, we can mark a decorator as KoinComponent.

class MaybeAccessToken : DecoratingHttpServiceFunction, KoinComponent {
    private val jwtVerifier by inject<JWTVerifier>()

    override fun serve(
        delegate: HttpService,
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse { ... }
}

Custom Annotations and Parameters

Sometimes a decorator might be generic and it might need to take parameters. For example, let's say MaybeUser annotation can be constrained to only a certain types of users. Such as subscriber, member or visitor. We want something like the following,

@RequireUser(allow = ["subscriber", "member"])
@Post("/blog_post/{id}/like")
fun likeBlogPost(@Param id: String) { ... }

To achieve this functionality, we can't user @Decorator(...) approach because it does not accept parameters. Instead, we should use a @DecoratingFactoryFunction.

@DecoratorFactory(RequireUserDecoratorFactory::class)
annotation class RequireUser(val allow: Array<String> = [])

class RequireUserDecorator(delegate: HttpService, allow: Array<String>): SimpleDecoratingHttpService(delegate) {
    override fun serve(
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse {
        val token = MyJWTVerifier.verify(ctx.request()
                                            .headers()
                                            .get("Authorization"))
                       

        if (token == null || token.groups.containsAll(allow).not()) {
            return HttpResponse.of(HttpStatus.UNAUTHORIZED)
        }

        return unwrap().serve(ctx, req)
    }
}

class RequireUserDecoratorFactory: DecoratorFactoryFunction<RequireUser> {
    override fun newDecorator(parameter: RequireUser): Function<in HttpService, out HttpService> {
        return Function { RequireUserDecorator(it, parameter.allow) }
    }
}

By including this, Armeria automatically detects whenever the @RequireUser annotation is applied to a controller / service and it automatically decorates it with RequireUserDecorator.

Authorized By Default

Let's add an authorization-by-default semantic into our application. Adding auth by default ensures sensitive applications to not leak data by mistake. The challenge with this approach is that the authorization decorator will be the top most decorator however overriding this behavior in method level is though. So, we should slightly modify our decorators to be more aware of each other. Let's define our syntax as the following,

@NeedsAuthentication
class MembersController {

    @PublicEndpoint
    fun getMemberCount(): Int { ... }

    @Get("/members")
    fun getMembers(): List<...> { ... }
}

// Or alternatively...

Server.builder().decorator(NeedsAuthentication.newDecorator())

Here, the decorator @RequireAuth will be applied first. However we should override the behavior there using @Public. So, let's define our annotations.

@DecoratorFactory(NeedsAuthenticationDecoratorFactory::class)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class NeedsAuthentication

@DecoratorFactory(PublicEndpointDecoratorFactory::class)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class PublicEndpoint

Let's define our services. Here, public endpoint service is only a dummy service used as a marker.

class NeedsAuthService(delegate: HttpService): SimpleDecoratingHttpService(delegate) {
    override fun serve(
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse {
        val token = ServiceRequestContextAuthChecker.getAccessToken()

        if (token == null || token.groups.containsAll(allow).not()) {
            return HttpResponse.of(HttpStatus.UNAUTHORIZED)
        }

        return unwrap().serve(ctx, req)
    }
}

// A dummy service used as a marker
class PublicEndpointService(delegate: HttpService): SimpleDecoratingHttpService(delegate) {
    override fun serve(
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse {
        return unwrap().serve(ctx, req)
    }
}

So, why the marker? We basically need to find a way to figure out if a service is annotated using @PublicEndpoint annotation. If so, we should conditionally not apply the auth decorator. This factory function also eliminates the duplicate auth checks by trying to down cast the delegate to NeedsAuthService once more.

class NeedsAuthDecoratorFactory : DecoratorFactoryFunction<NeedsAuth> {
    fun newDecorator(): Function<in HttpService, out HttpService> {
        return Function { delegate ->
            val maybePublic: PublicApiService? = delegate.`as`(PublicApiService::class.java)
            val maybeAuthenticated: NeedsAuthService? = delegate.`as`(NeedsAuthService::class.java)

            if (maybePublic != null || maybeAuthenticated != null) {
                return@Function delegate
            }

            NeedsAuthService(delegate)
        }
    }
}

class PublicEndpointDecoratorFactory : DecoratorFactoryFunction<PublicEndpoint> {
    fun newDecorator(): Function<in HttpService, out HttpService> {
        return Function { delegate -> PublicEndpointService(delegate) }
    }
}

Bonus: Open Policy Agent (OPA)

As a bonus, let's use the popular policy language OPA to authorize our system. Recommended way to authorize using OPA is using the Envoy sidecar with an external authorization filter. However, this scenario might be not sufficient or not available at all if you are not using Envoy.

Source: https://www.openpolicyagent.org/docs/latest/envoy-introduction/

So we can create a decorator that will intercept all requests coming to our service at the top level and checks access.

class Authorize : DecoratingHttpServiceFunction {
    override fun serve(
        delegate: HttpService,
        ctx: ServiceRequestContext,
        req: HttpRequest,
    ): HttpResponse {
        val path = ctx.routingContext().path()
        val method = ctx.routingContext().method()
        val token = ctx.authorizationHeader.removePrefix("Bearer ")

        // More contextual data can be added as desired
        val result = OPAClient.check(mapOf("path" to path, "method" to method, "bearer_token" to token)) 
        
        if (result.authorized) {
            return delegate.serve(ctx, req)
        }

        return HttpResponse.of(HttpStatus.UNAUTHORIZED)
    }
}

Wrap Up

In this blog post, I have covered how Decorators can be a useful building blocks for your application's authorization framework. They are flexible, customizable and allow you to separate concerns for various tasks such as authentication & authorization into a different layer. I hope this was an inspiration for you to use decorators. Please let me know if you liked this article and if so please subscribe to be notified about future articles.

You can comment under this post by signing up to my website.