Intro
This article will drill into some design patterns for using OAuth 2.0 scopes in real world systems, and will make some recommendations for managing them at scale.
Components
Software solutions are usually composed of multiple APIs and clients, and a number of business areas. A system that sells products to online customers would typically have a number of microservices called by multiple apps targeted at different roles of user:
OAuth 2.0 Scopes
Scopes are strings provided to APIs, so that they know whether to grant access to the type of data and operation requested, as described in the Introduction to Scopes page.
OAuth does not provide instructions on how best to manage scopes though, and that is instead left to designers of each system. A common way to get started with scopes is to use a combination of the type of resource and the access required on it:
Resource Type | Access Level | Scope Value |
---|---|---|
order | read | order_read |
Scope Usage in Clients
Clients request scopes whenever they get tokens from the Authorization Server, and in our business scenario this will first occur when users sign in. If required, a consent screen can be shown to end users, which can be useful for visualizing scopes and claims.
Consent screens are used in scenarios where a data owner grants third parties access to their own data, which is not the case for our business scenario. However, it is worth being aware that in the general case the scopes granted may be a subset of those requested.
Scopes are contained in or referenced by access tokens, which the client will later send to APIs. Scopes granted usually then last for an entire user session without needing to redirect the user again, or ask for re-consent.
During a user session the client can silently refresh access tokens, and this can either use all scopes from the sign in request, or a subset of them.
Scope Design Goals
Scopes for real world systems need to be designed coherently and the following sections provide some techniques which companies can consider in order to meet these goals:
- Use conventions that people will find easy to understand and extend to new business areas
- Ensure that scopes in each component are easy to reason about, by keeping them high level
Data Models
APIs should separate data both by business area and data sensitivity, so that different parts of the model can be exposed to different clients. A partial model for the above components is illustrated below.
Hierarchical Scopes
When data is hierarchical it usually makes sense to also use hierarchical scopes, as in the below examples, where colon characters are used to navigate to subresources:
Scope | Grants access to |
---|---|
order | Full information about orders, which perhaps not many clients should have access to |
order:item | Information about items within an order |
order:price | Prices offered to customers based on their benefits |
order:shipping:status | Details on whether the order has been successfully delivered |
order:shipping:address | Information on where the order will be shipped |
Enforcing Scopes in API Gateways
When a particular API operation is called, high level scopes can be enforced at the entry point in a Reverse Proxy or API Gateway. Typically the gateway returns ‘401 unauthorized’ if a token is expired, or ‘403 forbidden’ if a required scope is missing:
This prevents obviously invalid calls from ever reaching the actual API, which reduces costs in some API architectures.
Enforcing Scopes in APIs
APIs can make more detailed checks on allowed scopes via code similar to the following, which allows either calls with the required scope or a parent scope:
public List<OrderItem> getOrderItems(Criteria criteria) {
// This is satisfied by either an entire 'order' scope or a restricted 'order:item' scope
if (!this.claimsPrincipal.hasScope("order:item"))
throw forbiddenError();
}
return this.repository.getOrderItems(OrderItem);
}
Issue Least Privilege Scopes to Clients
Where possible, design clients to only have access the data they need, and limit the scope to read only access. One possible convention is to make read only access the default and then add a ‘write’ suffix when higher privilege is needed:
Scope | Access Granted |
---|---|
order | Read only access to full order information |
order:items | Read only access to details about order items |
inventory.write | The ability to create, change or delete an entire inventory item |
inventory:price.write | The ability to change the price details for an inventory item |
You can then use code as follows in your APIs, to deny access when a data changing scope is not present:
public void updateInventoryPrice(OrderItem item) {
// This is satisfied by either an entire 'inventory.write' scope or a restricted 'inventory:price.write' scope
if (!this.claimsPrincipal.hasScope("inventory:price.write"))
throw forbiddenError();
}
return this.repository.createInventoryItem(item);
}
Authorization Requires More Than Scopes
Consider next the following example scopes for our business domain:
User Role | Scopes | Authorization Rule |
---|---|---|
Customer | customer:benefits | A customer can view but not change their benefits |
Administrator | inventory.write | An administrator can create or update inventory items and their prices |
Supplier User | inventory:sales | A supplier business partner is able to view reports on sales of their inventory items |
Scopes are only part of the authorization solution, and a complete implementation will also need to enforce rules such as these:
User Role | Authorization Rule |
---|---|
Customer | A customer can only view benefits and orders associated to their own customer id |
Customer | Customers with higher subscription levels can access additional inventory |
Administrator | An administrator may have access to all data, though this often involves business rules such as regional restrictions |
Supplier User | A supplier business partner can only view inventory for their own company’s supplier id |
The finer details of authorization should be handled by Claims, another part of the security architecture, and we will explore this topic in Claims Best Practices.
The Curity Identity Server enables scopes to be composed of claims, so that you can elegantly combine the two concepts.
Avoid Scope Explosion
If not designed carefully, you can end up with a large number of scopes that are difficult to maintain over time. The most common cause of ‘scope explosion’ is when client specific concerns are used in scope names, such as roles or usage scenarios.
- inventory-for-supplier
- order-admin-usa.write
In these examples, client concerns have leaked into the scope names, leading to duplication. In the next document, on Claims Best Practices, we will deal more elegantly with enforcing the above rules.
Prefix Scopes
These are a special type of scope, to enable clients to request scopes that are unknown at design time. This is managed by configuring a scope such as ‘transaction-’, where the convention is to use a trailing hyphen in the name.
The client then asks for a scope containing a specific transaction id at runtime. The scope granted is always for a concrete resource, as can be seen if the consent feature is used:
If prefix scopes are needed they are supported by the Curity Identity Server, though scopes should not usually identify concrete resources. Another limitation of prefix scopes is that they cannot be composed of claims.
Scopes per Component
Scopes used in each component should make sense to its developers. API developers will use scopes specific to their business area, whereas our Customer App might use all of the following:
Scope | Usage |
---|---|
openid | Indicates that OpenID Connect is used and that the app identifies the user |
profile | The OpenID Connect profile claim which enables the app to get the logged in user’s name |
customer:benefits | End users can view their benefits based on prior purchases |
inventory | End users can view inventory details when placing orders |
orders.write | End users can create orders and then view or update them afterwards |
shipping.write | When end users create orders they also provide details for a shipping record |
Scopes and Downstream APIs
By default, the token issued to the client can simply be forwarded to other APIs, as is common in a microservices architecture, and each API will only check for its own scopes. This is the simplest and most commonly used option within an organisation.
Downgrading Scopes via Token Exchange
In some cases, such as when sending tokens to less trusted divisions of the organisation, a different Token Sharing strategy would be used.
The Token Exchange flow can be used in this case, and is described in the RFC8693 proposed standard. This is illustrated below, for the call between the Orders and Shipping APIs, where scopes other than those related to shipping are removed, without changing the token’s user identity or expiry.
The Curity Identity Server has a custom implementation to make downgrading scopes in tokens easy via a simple HTTPS call. This would require the Orders API to be configured as an OAuth client with the Token Exchange capability.
curl -X POST https://localhost:8443/oauth/v2/oauth-token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=https://curity.se/grant/accesstoken" \
-d "client_id=orders-api" \
-d "client_secret=EFfA7Ml9kkIqJPcFNuo-z" \
-d "scope=payment.write" \
-d "token=56acc3f6-b9ef-4a34-a9d4-f9d7a27a505b"
High Privilege Scopes and Time to Live
Access tokens are issued after authentication and should be given a short expiry time such as 15 minutes. They are usually then silently refreshed within the same user session.
By default the same scopes are included in all of these access tokens, and this may not be the desired behavior for high privilege scopes, such as those used for a money transfer.
The Curity Identity Server provides a feature where you can assign a scope a time to live, which works by dropping scopes when tokens are refreshed. Further information on the details of token issuing are provided in the product documentation.
If a client used access tokens with a time to live of 15 minutes, then attempted to use the above payment scope 20 minutes into a user session, a 403 error response would be received, due to the dropped scope. The client would then need to redirect the user to authenticate again, after which a new token with the high privilege scope would be issued.
Conclusion
Scopes are plain strings in OAuth 2.0 and you should design them to convey the following meaning. Aim to issue least privilege scopes to clients and do not use scopes to represent business authorization rules.
- Scopes control which areas of data a caller can access with a token
- Scopes control what operations can be performed on that data
The Curity Identity Server supports many scope related features, providing a toolbox for implementing high level access control. These features can then be scaled to many APIs, requiring only simple code. See also the article on Claims Best Practices.
Let’s Stay in Touch!
Get the latest on identity management, API Security and authentication straight to your inbox.