API Scopes
Designing your API surface can be a complicated task. Duende IdentityServer provides a couple of primitives to help you with that.
The original OAuth 2.0 specification has the concept of scopes, which is just defined as the scope of access that the
client requests.
Technically speaking, the scope
parameter is a list of space delimited values - you need to provide the structure and
semantics of it.
In more complex systems, often the notion of a resource
is introduced. This might be e.g. a physical or logical API.
In turn each API can potentially have scopes as well. Some scopes might be exclusive to that resource, and some scopes
might be shared.
Let’s start with simple scopes first, and then we’ll have a look how resources can help structure scopes.
Scopes
Section titled “Scopes”Let’s model something very simple - a system that has three logical operations read
, write
, and delete
.
You can define them using the ApiScope
class:
public static IEnumerable<ApiScope> GetApiScopes(){ return new List<ApiScope> { new ApiScope(name: "read", displayName: "Read your data."), new ApiScope(name: "write", displayName: "Write your data."), new ApiScope(name: "delete", displayName: "Delete your data.") };}
You can then assign the scopes to various clients, e.g.:
var webViewer = new Client{ ClientId = "web_viewer",
AllowedScopes = { "openid", "profile", "read" }};
var mobileApp = new Client{ ClientId = "mobile_app",
AllowedScopes = { "openid", "profile", "read", "write", "delete" }}
Authorization Based On Scopes
Section titled “Authorization Based On Scopes”When a client asks for a scope (and that scope is allowed via configuration and not denied via consent),
the value of that scope will be included in the resulting access token as a claim of type scope
(for both JWTs and
introspection), e.g.:
{ "typ": "at+jwt"}.{ "client_id": "mobile_app", "sub": "123", "scope": "read write delete"}
The consumer of the access token can use that data to make sure that the client is actually allowed to invoke the corresponding functionality. See the APIs section for more information on protecting APIs with access tokens.
Adding User Claims
Section titled “Adding User Claims”You can add more identity information about the user to the access token.
The additional claims added are based on the scope requested.
The following scope definition tells the configuration system that when a write
scope gets granted the user_level
claim should be added to the access token:
var writeScope = new ApiScope( name: "write", displayName: "Write your data.", userClaims: new[] { "user_level" });
This will pass the user_level
claim as a requested claim type to the profile service,
so that the consumer of the access token can use this data as input for authorization decisions or business logic.
Parameterized Scopes
Section titled “Parameterized Scopes”Sometimes scopes have a certain structure, e.g. a scope name with an additional parameter: transaction:id
or
read_patient:patientid
.
In this case you would create a scope without the parameter part and assign that name to a client, but in addition
provide some logic to parse the structure
of the scope at runtime using the IScopeParser
interface or by deriving from our default implementation, e.g.:
public class ParameterizedScopeParser : DefaultScopeParser{ public ParameterizedScopeParser(ILogger<DefaultScopeParser> logger) : base(logger) { }
public override void ParseScopeValue(ParseScopeContext scopeContext) { const string transactionScopeName = "transaction"; const string separator = ":"; const string transactionScopePrefix = transactionScopeName + separator;
var scopeValue = scopeContext.RawValue;
if (scopeValue.StartsWith(transactionScopePrefix)) { // we get in here with a scope like "transaction:something" var parts = scopeValue.Split(separator, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 2) { scopeContext.SetParsedValues(transactionScopeName, parts[1]); } else { scopeContext.SetError("transaction scope missing transaction parameter value"); } } else if (scopeValue != transactionScopeName) { // we get in here with a scope not like "transaction" base.ParseScopeValue(scopeContext); } else { // we get in here with a scope exactly "transaction", which is to say we're ignoring it // and not including it in the results scopeContext.SetIgnore(); } }}
You then have access to the parsed value throughout the pipeline, e.g. in the profile service:
public class HostProfileService : IProfileService{ public override async Task GetProfileDataAsync(ProfileDataRequestContext context) { var transaction = context.RequestedResources.ParsedScopes.FirstOrDefault(x => x.ParsedName == "transaction"); if (transaction?.ParsedParameter != null) { context.IssuedClaims.Add(new Claim("transaction_id", transaction.ParsedParameter)); } }}