DEV Community

Pipat Sampaokit
Pipat Sampaokit

Posted on

Why your API should explicitly take resource owner id as a parameter instead of inferring from authentication.

Imagine your are designing a REST API to process a resource (e.g. an order). The API should be restricted to be available only when the caller user is authenticated and he is the owner of the resource.

Suppose that we are using JWT token for authentication. The token has the userId in the claim payload.

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "userId": "..."
}
Enter fullscreen mode Exit fullscreen mode

The classic approach

You then have an option to infer the userId from the token and use it to check for the authorization.

@GetMapping("/orders/{orderId}")
public Order getOrderDetail(String orderId) {
   String userId = getUserIdFromAuthMiddlewareSomehow();
   Order order = getOrderFromDatabase(orderId);
   if (!order.getUserId().equals(userId)) {
      throw new RuntimeException("Not authorized");
   }
   return order;
}
Enter fullscreen mode Exit fullscreen mode

If you choose this approach, your API call might look like this.

jwt_token=...
curl -H "Authorization: Bearer ${jwt_token}" https://your-api.example.com/orders/12345
Enter fullscreen mode Exit fullscreen mode

This approach has 2 drawbacks.

  1. Your API will need to run some, if not full, business logic and access the database even though the caller user is not authorized. This will be more serious if this API consumes great server's resource and performance.

  2. You mix the authentication concern in your business logic where it can be separated. Imagine your API has a complex implementation, and you want to reuse it for not only when the caller is the resource owner itself, but also for an administrator user as well, and so on. The complex logic to check for the "who" will be scattered all over the place, eventually it will be harder to understand "what" your API intends to do.

The better approach

Another option is to put the userId explicitly in the API parameter, and move the authorization logic to AOP middleware. For example, you can write a custom annotation @CheckTokenPayload to tell the middleware which field to check.

@CheckTokenPayload(
   userId = "#userId"
)
@GetMapping("/get-order")
public Order getOrderDetail(@Param String orderId, @Param  String userId) {
   Order order = getOrderFromDatabase(orderId, userId);
   return order;
}
Enter fullscreen mode Exit fullscreen mode

With this approach, your business logic is clean, and your API does not need to execute any resource consumption logic if the user is not authorized.

But you must be careful to use the userId together with the orderId when query from the database, otherwise the order will be accessible to authenticated user if he know the orderId.

Multi-dimensional ownership

In real world, it will not be as simple as checking the resource owner's userId because a resource can has many kinds of "owner".

For example, a merchant can also be an owner of an order, which makes some of the merchant's staffs eligible to see the order.

And, there will administrator staffs, support (call center) staffs, etc.

Fortunately, the user types of the system is the aspect that is not dynamic. You can always design your access token and the middleware to handle every type of the user. For example, your JWT token might look like.

// token payload for customer user
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "userType": "CUSTOMER",
  "customerId": "..."
}

// token payload for merchant staff
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "userType": "MERCHANT_STAFF",
  "merchantStaffId": "...",
  "merchantId": "...",
  "permissions": ["order:view", "order:edit", ...]
}
Enter fullscreen mode Exit fullscreen mode

And this is an example write APIs that checks and allows different types of the user without having to touch the database.

@CheckTokenPayload(
   authTypeAllows = {"CUSTOMER"},
   customerId = "#customerId"
)
@GetMapping("/customer/get-order")
public Order getOrderDetailForCustomer(@Param String orderId, @Param  String customerId) {
   Order order = getOrderFromDatabase(orderId, userId);
   return order;
}

@CheckTokenPayload(
   authTypeAllows = {"MERCHANT_STAFF", "ADMIN_STAFF"},
   // you may write the middleware to omit merchantId check if the authType is ADMIN_STAFF
   merchantId = "#merchantId",
   permissionsContain = {"order:view"}
)
@GetMapping("/merchant/get-order")
public Order getOrderDetailForMerchantOrAdmin(@Param String orderId, @Param String merchantId) {
   Order order = getOrderFromDatabase(orderId, merchantId);
   return order;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)