Oauth2.0 and OpenIdConnect

OAuth 2.0 Resource Indicators - RFC 8707

Read the official RFC 8707 specification at IETF

Overview

RFC 8707 defines a mechanism for OAuth 2.0 clients to explicitly signal to an authorization server which resource server(s) they intend to access with the requested access token. This extension addresses a critical limitation in the original OAuth 2.0 framework where access tokens could be ambiguous about their intended audience, creating security vulnerabilities in multi-API environments.

What Resource Indicators Solve

In modern microservices architectures and complex API ecosystems, a single authorization server often protects multiple resource servers (APIs). Without resource indicators, several problems arise:

Audience Ambiguity: Access tokens don't clearly specify which resource server(s) they're intended for, making it difficult for resource servers to validate tokens properly.

Token Reuse Attacks: An access token obtained for one API could potentially be misused to access a different API, especially if both APIs trust the same authorization server.

Scope Collision: Different APIs might use the same scope names with different meanings, leading to authorization confusion.

Over-Privileged Tokens: Without targeting specific resources, tokens might grant broader access than necessary, violating the principle of least privilege.

Complex Token Validation: Resource servers struggle to determine if a token was actually intended for them, especially in environments with multiple APIs.

RFC 8707 solves these issues by introducing the resource parameter, allowing clients to explicitly declare their target resource server(s) when requesting authorization.

Core Concepts

The Resource Parameter

The resource parameter is a new optional parameter that can be included in authorization and token requests. It contains an absolute URI identifying the target resource server(s).

Audience Restriction

When a client includes the resource parameter in a token request, the authorization server:

  1. Validates that the client is authorized to access the specified resource
  2. Issues an access token intended specifically for that resource
  3. Includes an aud (audience) claim in the token matching the resource URI
  4. Optionally restricts the token's scope based on what's relevant for that resource

The resource server can then validate that the token's audience matches its own identity, ensuring the token was intended for it.

How It Works

Authorization Request with Resource

When initiating an authorization request, clients can specify one or more target resources:

GET /authorize?
  response_type=code
  &client_id=client123
  &redirect_uri=https://client.example.com/callback
  &scope=read write
  &resource=https://api.example.com
  &state=xyz123 HTTP/1.1
Host: authorization-server.example.com

Token Request with Resource

When exchanging an authorization code for tokens, the client specifies the resource:

POST /token HTTP/1.1
Host: authorization-server.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=abc123
&redirect_uri=https://client.example.com/callback
&client_id=client123
&client_secret=secret
&resource=https://api.example.com

Token Response

The authorization server responds with an access token intended for the specified resource:

{
  "access_token": "eyJhbGc...token",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read write"
}

The issued access token will contain an aud claim matching the requested resource:

{
  "iss": "https://authorization-server.example.com",
  "sub": "user123",
  "aud": "https://api.example.com",
  "scope": "read write",
  "exp": 1735574400
}

Multiple Resources

Clients can request access to multiple resources by including multiple resource parameters:

POST /token HTTP/1.1
Host: authorization-server.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=client123
&client_secret=secret
&resource=https://api1.example.com
&resource=https://api2.example.com
&scope=read

The authorization server has several options for handling multiple resource requests:

  1. Issue a single token with multiple audiences: The token's aud claim contains an array of resource URIs
  2. Issue separate tokens: Return multiple access tokens, each for a different resource
  3. Select a subset: Issue tokens for only some of the requested resources based on policy
  4. Reject the request: If the configuration doesn't support multiple resources

Grant Type Support

Resource indicators can be used with various OAuth 2.0 grant types:

Authorization Code Grant

Most common use case. The client specifies the resource during both the authorization request and token request:

1. Authorization Request → include resource parameter
2. User authenticates and authorizes
3. Authorization Code returned
4. Token Request → include resource parameter
5. Access Token issued for specified resource

Client Credentials Grant

Perfect for service-to-service communication where the client is also the resource owner:

POST /token HTTP/1.1
Host: authorization-server.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=service-a
&client_secret=secret
&resource=https://service-b.example.com
&scope=api.read

Refresh Token Grant

When refreshing an access token, clients can specify a different resource than the original token:

POST /token HTTP/1.1
Host: authorization-server.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=refresh_token_value
&resource=https://different-api.example.com
&scope=read

This allows a single authorization to be used to access multiple APIs over time without requiring re-authentication.

Duende IdentityServer Implementation

Configuring API Resources

In Duende IdentityServer, resource indicators are implemented through API Resources. Each API Resource represents a protected resource server and is identified by a unique name (which serves as the resource URI).

Basic API Resource Configuration

public static IEnumerable<ApiResource> GetApiResources()
{
    return new List<ApiResource>
    {
        new ApiResource("https://api.example.com", "Example API")
        {
            Scopes = { "api.read", "api.write" },
            UserClaims = { "name", "email", "role" }
        },
        
        new ApiResource("https://orders.example.com", "Orders API")
        {
            Scopes = { "orders.read", "orders.create", "orders.delete" },
            UserClaims = { "name", "department" }
        }
    };
}

Key Properties:

  • Name: The resource indicator URI (audience value)
  • DisplayName: Human-readable name shown in consent screens
  • Scopes: List of API scopes associated with this resource
  • UserClaims: Claims about the user to include in access tokens
  • ApiSecrets: Secrets for introspection endpoint authentication
  • AllowedAccessTokenSigningAlgorithms: Supported signing algorithms

Configuring API Scopes

API Scopes define the specific permissions within an API resource:

public static IEnumerable<ApiScope> GetApiScopes()
{
    return new List<ApiScope>
    {
        new ApiScope("api.read", "Read access to API"),
        new ApiScope("api.write", "Write access to API"),
        new ApiScope("orders.read", "Read orders"),
        new ApiScope("orders.create", "Create orders"),
        new ApiScope("orders.delete", "Delete orders")
    };
}

Client Configuration

Clients must be configured to access specific API resources:

new Client
{
    ClientId = "web-client",
    ClientName = "Web Application",
    
    AllowedGrantTypes = GrantTypes.Code,
    RequirePkce = true,
    
    ClientSecrets = { new Secret("secret".Sha256()) },
    
    RedirectUris = { "https://client.example.com/callback" },
    PostLogoutRedirectUris = { "https://client.example.com" },
    
    // Resource indicators: specify which APIs this client can access
    AllowedScopes = 
    { 
        "openid", 
        "profile",
        "api.read", 
        "api.write",
        "orders.read"
    },
    
    // Enable resource indicators feature
    RequireResourceIndicator = false // Set to true to require resource parameter
}

Startup Configuration

Register API resources and scopes in your IdentityServer configuration:

public void ConfigureServices(IServiceCollection services)
{
    var builder = services.AddIdentityServer(options =>
    {
        options.EmitStaticAudienceClaim = true;
    })
    .AddInMemoryApiScopes(Config.GetApiScopes())
    .AddInMemoryApiResources(Config.GetApiResources())
    .AddInMemoryClients(Config.GetClients());
    
    // Additional configuration...
}

Client Usage Examples

JavaScript/TypeScript Client (oidc-client-ts)

import { UserManager } from 'oidc-client-ts';

const config = {
    authority: 'https://identity.example.com',
    client_id: 'web-client',
    redirect_uri: 'https://client.example.com/callback',
    response_type: 'code',
    scope: 'openid profile api.read',
    
    // Specify target resource
    extraQueryParams: {
        resource: 'https://api.example.com'
    }
};

const userManager = new UserManager(config);

// Sign in with resource indicator
await userManager.signinRedirect();

// Access token will have aud claim: "https://api.example.com"
const user = await userManager.getUser();
console.log('Access token:', user.access_token);

.NET Client

using IdentityModel.Client;

var client = new HttpClient();

// Discover endpoints
var disco = await client.GetDiscoveryDocumentAsync("https://identity.example.com");

// Request token with resource indicator
var tokenResponse = await client.RequestAuthorizationCodeTokenAsync(
    new AuthorizationCodeTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "web-client",
        ClientSecret = "secret",
        Code = authorizationCode,
        RedirectUri = "https://client.example.com/callback",
        
        // Specify target resource
        Resource = { "https://api.example.com" }
    });

if (tokenResponse.IsError)
{
    Console.WriteLine(tokenResponse.Error);
    return;
}

Console.WriteLine($"Access Token: {tokenResponse.AccessToken}");

// Use the token to call the API
client.SetBearerToken(tokenResponse.AccessToken);
var apiResponse = await client.GetAsync("https://api.example.com/data");

Client Credentials Flow with Resource

var tokenResponse = await client.RequestClientCredentialsTokenAsync(
    new ClientCredentialsTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "service-client",
        ClientSecret = "service-secret",
        Scope = "api.read",
        
        // Specify target resource
        Resource = { "https://api.example.com" }
    });

Refresh Token with Different Resource

// Original token was for api.example.com
// Now request access to orders.example.com using the same refresh token

var tokenResponse = await client.RequestRefreshTokenAsync(
    new RefreshTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "web-client",
        ClientSecret = "secret",
        RefreshToken = existingRefreshToken,
        Scope = "orders.read",
        
        // Request different resource
        Resource = { "https://orders.example.com" }
    });

Resource Server Token Validation

API resources must validate that tokens are intended for them by checking the audience claim:

ASP.NET Core API Configuration

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication("Bearer")
        .AddJwtBearer("Bearer", options =>
        {
            options.Authority = "https://identity.example.com";
            
            // Validate audience matches this API's resource indicator
            options.Audience = "https://api.example.com";
            
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateAudience = true,
                ValidAudience = "https://api.example.com",
                ValidateIssuer = true,
                ValidIssuer = "https://identity.example.com",
                ValidateLifetime = true
            };
        });
    
    services.AddAuthorization(options =>
    {
        options.AddPolicy("ApiReader", policy =>
        {
            policy.RequireAuthenticatedUser();
            policy.RequireClaim("scope", "api.read");
        });
    });
}

public void Configure(IApplicationBuilder app)
{
    app.UseAuthentication();
    app.UseAuthorization();
}

Controller with Audience Validation

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DataController : ControllerBase
{
    [HttpGet]
    [Authorize(Policy = "ApiReader")]
    public IActionResult GetData()
    {
        // Token has been validated:
        // - Issued by trusted authority
        // - Intended for this API (audience check)
        // - Contains required scope
        // - Not expired
        
        var userId = User.FindFirst("sub")?.Value;
        return Ok(new { message = "Data from API", userId });
    }
}

Advanced Scenarios

Multiple Resources in Single Request

var tokenResponse = await client.RequestClientCredentialsTokenAsync(
    new ClientCredentialsTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = "aggregator-service",
        ClientSecret = "secret",
        Scope = "api.read orders.read",
        
        // Request access to multiple resources
        Resource = 
        { 
            "https://api.example.com",
            "https://orders.example.com"
        }
    });

// Token will have aud claim as array: ["https://api.example.com", "https://orders.example.com"]

Dynamic Resource Selection

public class TokenService
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly DiscoveryDocumentResponse _disco;
    
    public async Task<string> GetTokenForResource(string resourceUri, string[] scopes)
    {
        var client = _httpClientFactory.CreateClient();
        
        var tokenResponse = await client.RequestClientCredentialsTokenAsync(
            new ClientCredentialsTokenRequest
            {
                Address = _disco.TokenEndpoint,
                ClientId = "dynamic-client",
                ClientSecret = "secret",
                Scope = string.Join(" ", scopes),
                Resource = { resourceUri }
            });
        
        if (tokenResponse.IsError)
        {
            throw new Exception($"Token request failed: {tokenResponse.Error}");
        }
        
        return tokenResponse.AccessToken;
    }
}

// Usage
var apiToken = await tokenService.GetTokenForResource(
    "https://api.example.com", 
    new[] { "api.read" }
);

var ordersToken = await tokenService.GetTokenForResource(
    "https://orders.example.com", 
    new[] { "orders.read", "orders.create" }
);

Custom Resource Validation

public class ResourceValidator : IResourceValidator
{
    public Task<ResourceValidationResult> ValidateRequestedResourcesAsync(
        ResourceValidationRequest request)
    {
        var result = new ResourceValidationResult();
        
        // Custom logic to validate resource requests
        foreach (var resource in request.ParsedResources)
        {
            // Check if client is allowed to access this resource
            if (!IsClientAuthorizedForResource(request.Client, resource))
            {
                result.InvalidResource = resource;
                result.IsError = true;
                result.Error = "unauthorized_client";
                result.ErrorDescription = $"Client not authorized for resource: {resource}";
                return Task.FromResult(result);
            }
            
            // Validate resource exists
            if (!ResourceExists(resource))
            {
                result.InvalidResource = resource;
                result.IsError = true;
                result.Error = "invalid_target";
                result.ErrorDescription = $"Resource not found: {resource}";
                return Task.FromResult(result);
            }
            
            result.Resources.Add(resource);
        }
        
        result.IsError = false;
        return Task.FromResult(result);
    }
    
    private bool IsClientAuthorizedForResource(Client client, string resource)
    {
        // Implement your authorization logic
        // Example: check client properties, database, external service, etc.
        return true;
    }
    
    private bool ResourceExists(string resource)
    {
        // Check if resource is registered in your system
        return true;
    }
}

// Register in Startup
services.AddTransient<IResourceValidator, ResourceValidator>();

Common Implementation Patterns

Pattern 1: One Token Per Resource

The most secure pattern where each API gets its own dedicated token:

public class ApiClientService
{
    private readonly Dictionary<string, string> _tokenCache = new();
    
    public async Task<string> GetTokenForApi(string apiUri)
    {
        if (_tokenCache.TryGetValue(apiUri, out var cachedToken))
        {
            // Check if token is still valid
            if (!IsTokenExpired(cachedToken))
                return cachedToken;
        }
        
        var token = await RequestNewToken(apiUri);
        _tokenCache[apiUri] = token;
        return token;
    }
    
    private async Task<string> RequestNewToken(string resource)
    {
        var client = new HttpClient();
        var disco = await client.GetDiscoveryDocumentAsync(_identityServerUrl);
        
        var tokenResponse = await client.RequestClientCredentialsTokenAsync(
            new ClientCredentialsTokenRequest
            {
                Address = disco.TokenEndpoint,
                ClientId = _clientId,
                ClientSecret = _clientSecret,
                Resource = { resource }
            });
        
        return tokenResponse.AccessToken;
    }
}

Pattern 2: Gateway Pattern

API gateway requests tokens for backend services:

[ApiController]
[Route("api/[controller]")]
public class AggregatorController : ControllerBase
{
    private readonly ITokenService _tokenService;
    private readonly IHttpClientFactory _httpClientFactory;
    
    [HttpGet("combined-data")]
    public async Task<IActionResult> GetCombinedData()
    {
        // Get token for first API
        var token1 = await _tokenService.GetTokenForResource("https://api1.example.com");
        var client1 = _httpClientFactory.CreateClient();
        client1.SetBearerToken(token1);
        var data1 = await client1.GetStringAsync("https://api1.example.com/data");
        
        // Get token for second API
        var token2 = await _tokenService.GetTokenForResource("https://api2.example.com");
        var client2 = _httpClientFactory.CreateClient();
        client2.SetBearerToken(token2);
        var data2 = await client2.GetStringAsync("https://api2.example.com/data");
        
        // Combine and return
        return Ok(new { data1, data2 });
    }
}

Pattern 3: Resource Hierarchy

Organize resources hierarchically:

public static class Resources
{
    public const string MainApi = "https://api.company.com";
    
    public static class Orders
    {
        public const string Base = "https://api.company.com/orders";
        public const string Management = "https://api.company.com/orders/management";
    }
    
    public static class Users
    {
        public const string Base = "https://api.company.com/users";
        public const string Admin = "https://api.company.com/users/admin";
    }
}

// Usage
var token = await GetTokenForResource(Resources.Orders.Management);

Security Considerations

Audience Validation is Critical

Resource servers MUST validate the audience claim to prevent token misuse:

// WRONG: No audience validation
services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.Authority = "https://identity.example.com";
        // Missing: options.Audience = "https://api.example.com";
    });

// CORRECT: Audience validation enabled
services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.Authority = "https://identity.example.com";
        options.Audience = "https://api.example.com"; // Required!
        options.TokenValidationParameters.ValidateAudience = true;
    });

Resource URI Format

Use consistent, well-formed URIs for resource identifiers:

Good Examples:

https://api.example.com
https://api.example.com/v1
https://services.company.com/orders

Bad Examples:

api.example.com              // Missing scheme
https://api.example.com/     // Trailing slash (inconsistent)
https://api.example.com#frag // Fragment not allowed
http://api.example.com       // Should use HTTPS

Scope and Resource Relationship

Scopes and resources work together to provide fine-grained authorization:

// API Resource configuration
new ApiResource("https://orders.example.com")
{
    Scopes = { "orders.read", "orders.write", "orders.delete" }
}

// Token request must specify BOTH resource and scope
Resource = { "https://orders.example.com" }
Scope = "orders.read orders.write"

// Resulting token will have:
// - aud: "https://orders.example.com"
// - scope: "orders.read orders.write"

This allows the resource server to:

  1. Verify the token is for them (audience check)
  2. Verify the operation is allowed (scope check)

Prevent Token Replay Across Resources

Without proper audience validation, an attacker could:

  1. Obtain a token for API A
  2. Use that token to access API B (if B doesn't validate audience)

Mitigation:

// Each API must validate its specific audience
public class OrdersApiStartup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication("Bearer")
            .AddJwtBearer(options =>
            {
                options.Audience = "https://orders.example.com"; // Specific to this API
                options.TokenValidationParameters.ValidateAudience = true;
            });
    }
}

public class UsersApiStartup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication("Bearer")
            .AddJwtBearer(options =>
            {
                options.Audience = "https://users.example.com"; // Different audience
                options.TokenValidationParameters.ValidateAudience = true;
            });
    }
}

Handling Multiple Audiences

If a token has multiple audiences, validation logic must handle arrays:

options.TokenValidationParameters = new TokenValidationParameters
{
    ValidateAudience = true,
    
    // Token might have multiple audiences
    AudienceValidator = (audiences, securityToken, validationParameters) =>
    {
        var validAudience = "https://api.example.com";
        return audiences.Any(aud => aud == validAudience);
    }
};

Resource Indicator in Refresh Tokens

When using refresh tokens, clients can request tokens for different resources:

// Original authorization for API A
var originalToken = await RequestToken("https://api-a.example.com", "scope-a");

// Later, use refresh token to get access to API B
var newToken = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
{
    Address = disco.TokenEndpoint,
    ClientId = "client",
    ClientSecret = "secret",
    RefreshToken = originalToken.RefreshToken,
    Resource = { "https://api-b.example.com" }, // Different resource!
    Scope = "scope-b"
});

Security Consideration: The authorization server should validate that the client is allowed to access the new resource with the existing authorization.

Troubleshooting Common Issues

Issue: Token Rejected by Resource Server

Symptoms: 401 Unauthorized error when calling API with valid token

Common Causes:

  1. Audience Mismatch
// Token has aud: "https://api.example.com"
// But API expects: "https://different-api.example.com"

// Solution: Ensure resource parameter matches API's expected audience
var tokenResponse = await client.RequestTokenAsync(new TokenRequest
{
    Resource = { "https://api.example.com" } // Must match API's audience
});
  1. Missing Audience Claim
// Check IdentityServer configuration
var builder = services.AddIdentityServer(options =>
{
    options.EmitStaticAudienceClaim = true; // Must be true!
});
  1. API Not Validating Audience
// Ensure API has audience validation configured
services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.Audience = "https://api.example.com"; // Required
    });

Issue: Multiple Resource Request Fails

Symptoms: Error when requesting tokens for multiple resources

Cause: Authorization server might not support multiple resources in single request

Solution: Request tokens separately or check server configuration

// Instead of:
Resource = { "https://api1.com", "https://api2.com" }

// Try:
var token1 = await RequestToken("https://api1.com");
var token2 = await RequestToken("https://api2.com");

Issue: Scope Not Available for Resource

Symptoms: Token request succeeds but doesn't include expected scope

Cause: Scope not associated with the requested resource

Solution: Configure API Resource with correct scopes

new ApiResource("https://api.example.com")
{
    Scopes = { "api.read", "api.write" } // Ensure scopes are associated
}

Issue: Client Not Authorized for Resource

Symptoms: unauthorized_client error

Cause: Client configuration doesn't include scopes for the resource

Solution: Update client configuration

new Client
{
    ClientId = "client",
    AllowedScopes = 
    { 
        "openid",
        "api.read",  // Must include scopes for target resource
        "api.write"
    }
}

Best Practices

1. Use Consistent Resource URIs

Establish and document your resource URI naming convention:

public static class ResourceIdentifiers
{
    private const string BaseUri = "https://api.company.com";
    
    public const string OrdersApi = BaseUri + "/orders";
    public const string UsersApi = BaseUri + "/users";
    public const string PaymentsApi = BaseUri + "/payments";
    
    // Document all resource identifiers in one place
    // Makes it easy to maintain consistency across services
}

2. Always Validate Audience in Resource Servers

Never deploy a resource server without audience validation:

// Required security configuration for every API
public class SecurityStartupExtensions
{
    public static IServiceCollection AddSecureAuthentication(
        this IServiceCollection services,
        string authority,
        string audience)
    {
        services.AddAuthentication("Bearer")
            .AddJwtBearer(options =>
            {
                options.Authority = authority;
                options.Audience = audience;
                
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateAudience = true,  // Critical!
                    ValidateIssuer = true,
                    ValidateLifetime = true,
                    ValidateIssuerSigningKey = true
                };
            });
            
        return services;
    }
}

// Usage in Startup
services.AddSecureAuthentication(
    "https://identity.example.com",
    "https://api.example.com"
);

3. Implement Token Caching

Avoid unnecessary token requests by caching tokens per resource:

public class TokenCache
{
    private readonly ConcurrentDictionary<string, CachedToken> _cache = new();
    private readonly SemaphoreSlim _lock = new(1, 1);
    
    public async Task<string> GetOrCreateTokenAsync(
        string resource,
        Func<string, Task<TokenResponse>> tokenFactory)
    {
        // Check cache first
        if (_cache.TryGetValue(resource, out var cached))
        {
            if (cached.ExpiresAt > DateTime.UtcNow.AddMinutes(5))
            {
                return cached.AccessToken;
            }
        }
        
        // Token expired or not cached, get new one
        await _lock.WaitAsync();
        try
        {
            // Double-check after acquiring lock
            if (_cache.TryGetValue(resource, out cached))
            {
                if (cached.ExpiresAt > DateTime.UtcNow.AddMinutes(5))
                {
                    return cached.AccessToken;
                }
            }
            
            var tokenResponse = await tokenFactory(resource);
            
            if (tokenResponse.IsError)
            {
                throw new Exception($"Token request failed: {tokenResponse.Error}");
            }
            
            var newCached = new CachedToken
            {
                AccessToken = tokenResponse.AccessToken,
                ExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn)
            };
            
            _cache[resource] = newCached;
            return newCached.AccessToken;
        }
        finally
        {
            _lock.Release();
        }
    }
    
    private class CachedToken
    {
        public string AccessToken { get; set; }
        public DateTime ExpiresAt { get; set; }
    }
}

4. Use Specific Scopes Per Resource

Design scopes that are meaningful within the context of each resource:

// Good: Scopes clearly associated with specific resources
new ApiResource("https://orders.api.company.com")
{
    Scopes = 
    { 
        "orders.read",
        "orders.create",
        "orders.update",
        "orders.delete"
    }
}

new ApiResource("https://users.api.company.com")
{
    Scopes = 
    { 
        "users.read",
        "users.create",
        "users.update",
        "users.admin"
    }
}

// Avoid: Generic scopes that could apply to multiple resources
// Bad examples: "read", "write", "admin" (too generic)

5. Document Resource URIs and Scopes

Maintain clear documentation for developers:

# API Resources and Scopes

## Orders API
**Resource URI**: `https://orders.api.company.com`

### Scopes
- `orders.read` - Read access to orders
- `orders.create` - Create new orders
- `orders.update` - Update existing orders
- `orders.delete` - Delete orders (admin only)

### Example Token Request
```http
POST /token
grant_type=client_credentials
&client_id=your_client_id
&client_secret=your_secret
&resource=https://orders.api.company.com
&scope=orders.read orders.create

Users API

Resource URI: https://users.api.company.com

Scopes

  • users.read - Read user profiles
  • users.create - Create new users
  • users.update - Update user profiles
  • users.admin - Full administrative access

### 6. Implement Proper Error Handling

Provide clear error messages for resource-related issues:

```csharp
public class TokenService
{
    public async Task<string> GetTokenAsync(string resource, string[] scopes)
    {
        try
        {
            var tokenResponse = await _client.RequestClientCredentialsTokenAsync(
                new ClientCredentialsTokenRequest
                {
                    Address = _disco.TokenEndpoint,
                    ClientId = _clientId,
                    ClientSecret = _clientSecret,
                    Resource = { resource },
                    Scope = string.Join(" ", scopes)
                });
            
            if (tokenResponse.IsError)
            {
                throw tokenResponse.ErrorType switch
                {
                    ResponseErrorType.Protocol => new InvalidResourceException(
                        $"Invalid resource or scope: {tokenResponse.Error}"),
                    
                    ResponseErrorType.Http => new TokenServiceException(
                        $"HTTP error requesting token: {tokenResponse.HttpErrorReason}"),
                    
                    ResponseErrorType.Exception => new TokenServiceException(
                        $"Exception requesting token: {tokenResponse.Exception.Message}"),
                    
                    _ => new TokenServiceException(
                        $"Unknown error requesting token: {tokenResponse.Error}")
                };
            }
            
            return tokenResponse.AccessToken;
        }
        catch (Exception ex) when (ex is not InvalidResourceException)
        {
            throw new TokenServiceException(
                $"Failed to obtain token for resource {resource}", ex);
        }
    }
}

public class InvalidResourceException : Exception
{
    public InvalidResourceException(string message) : base(message) { }
}

public class TokenServiceException : Exception
{
    public TokenServiceException(string message) : base(message) { }
    public TokenServiceException(string message, Exception inner) : base(message, inner) { }
}

7. Use Environment-Specific Resource URIs

Configure resource URIs per environment:

// appsettings.Development.json
{
  "Resources": {
    "OrdersApi": "https://orders-dev.api.company.com",
    "UsersApi": "https://users-dev.api.company.com"
  }
}

// appsettings.Production.json
{
  "Resources": {
    "OrdersApi": "https://orders.api.company.com",
    "UsersApi": "https://users.api.company.com"
  }
}

// Configuration class
public class ResourceConfiguration
{
    public string OrdersApi { get; set; }
    public string UsersApi { get; set; }
}

// Startup
services.Configure<ResourceConfiguration>(
    Configuration.GetSection("Resources"));

// Usage
public class OrdersService
{
    private readonly ResourceConfiguration _resources;
    
    public OrdersService(IOptions<ResourceConfiguration> resources)
    {
        _resources = resources.Value;
    }
    
    public async Task<string> GetTokenAsync()
    {
        return await _tokenService.GetTokenAsync(_resources.OrdersApi);
    }
}

8. Monitor Token Usage by Resource

Implement logging to track which resources are being accessed:

public class TokenService
{
    private readonly ILogger<TokenService> _logger;
    
    public async Task<string> GetTokenAsync(string resource)
    {
        _logger.LogInformation(
            "Requesting token for resource: {Resource}", 
            resource);
        
        var stopwatch = Stopwatch.StartNew();
        
        try
        {
            var token = await RequestTokenInternalAsync(resource);
            
            _logger.LogInformation(
                "Successfully obtained token for {Resource} in {ElapsedMs}ms",
                resource,
                stopwatch.ElapsedMilliseconds);
            
            return token;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Failed to obtain token for {Resource} after {ElapsedMs}ms",
                resource,
                stopwatch.ElapsedMilliseconds);
            throw;
        }
    }
}

Real-World Examples

Example 1: E-Commerce Microservices

Architecture with multiple specialized APIs:

public class ECommerceResourceConfiguration
{
    public static class Resources
    {
        public const string ProductCatalog = "https://api.shop.com/products";
        public const string OrderManagement = "https://api.shop.com/orders";
        public const string PaymentProcessing = "https://api.shop.com/payments";
        public const string CustomerData = "https://api.shop.com/customers";
        public const string Inventory = "https://api.shop.com/inventory";
    }
    
    public static IEnumerable<ApiResource> GetApiResources()
    {
        return new[]
        {
            new ApiResource(Resources.ProductCatalog, "Product Catalog API")
            {
                Scopes = { "products.read", "products.manage" },
                UserClaims = { "name", "email", "role" }
            },
            
            new ApiResource(Resources.OrderManagement, "Order Management API")
            {
                Scopes = { "orders.read", "orders.create", "orders.manage" },
                UserClaims = { "name", "email", "customer_id" }
            },
            
            new ApiResource(Resources.PaymentProcessing, "Payment Processing API")
            {
                Scopes = { "payments.process", "payments.refund" },
                UserClaims = { "email", "payment_admin" }
            },
            
            new ApiResource(Resources.CustomerData, "Customer Data API")
            {
                Scopes = { "customers.read", "customers.write", "customers.delete" },
                UserClaims = { "name", "email", "role", "department" }
            },
            
            new ApiResource(Resources.Inventory, "Inventory API")
            {
                Scopes = { "inventory.read", "inventory.adjust" },
                UserClaims = { "warehouse_id", "role" }
            }
        };
    }
}

// Frontend application client
new Client
{
    ClientId = "web-app",
    ClientName = "E-Commerce Web Application",
    AllowedGrantTypes = GrantTypes.Code,
    RequirePkce = true,
    
    ClientSecrets = { new Secret("secret".Sha256()) },
    
    RedirectUris = { "https://shop.com/callback" },
    
    AllowedScopes = 
    {
        "openid", "profile",
        "products.read",
        "orders.read", "orders.create",
        "customers.read"
    }
}

// Backend order processing service
new Client
{
    ClientId = "order-processor",
    ClientName = "Order Processing Service",
    AllowedGrantTypes = GrantTypes.ClientCredentials,
    
    ClientSecrets = { new Secret("service-secret".Sha256()) },
    
    AllowedScopes = 
    {
        "orders.manage",
        "inventory.adjust",
        "payments.process",
        "customers.read"
    }
}

Example 2: Multi-Tenant SaaS Platform

Tenant-specific resource isolation:

public class MultiTenantTokenService
{
    public async Task<string> GetTokenForTenantAsync(
        string tenantId, 
        string baseResource,
        string[] scopes)
    {
        // Construct tenant-specific resource URI
        var resource = $"{baseResource}/tenant/{tenantId}";
        
        var tokenResponse = await _client.RequestClientCredentialsTokenAsync(
            new ClientCredentialsTokenRequest
            {
                Address = _disco.TokenEndpoint,
                ClientId = _clientId,
                ClientSecret = _clientSecret,
                Resource = { resource },
                Scope = string.Join(" ", scopes),
                
                // Include tenant context
                Parameters = 
                {
                    { "tenant_id", tenantId }
                }
            });
        
        return tokenResponse.AccessToken;
    }
}

// API Resource configuration with tenant support
public class TenantApiResource : ApiResource
{
    public TenantApiResource(string name, string displayName) 
        : base(name, displayName)
    {
        // Custom validation to ensure tenant context
        Properties["RequiresTenantContext"] = "true";
    }
}

// Resource definitions
new TenantApiResource("https://api.saas.com/data", "Data API")
{
    Scopes = { "data.read", "data.write" }
}

// Tenant-aware token validation
public class TenantAudienceValidator
{
    public bool ValidateAudience(
        IEnumerable<string> audiences,
        string tenantId,
        string expectedBaseResource)
    {
        var expectedAudience = $"{expectedBaseResource}/tenant/{tenantId}";
        return audiences.Any(aud => aud == expectedAudience);
    }
}

Example 3: Healthcare System with HIPAA Compliance

Strict resource access control for sensitive data:

public class HealthcareResources
{
    public static class Resources
    {
        // Patient data requires special handling
        public const string PatientRecords = "https://health.system.com/patients";
        public const string MedicalImaging = "https://health.system.com/imaging";
        public const string LabResults = "https://health.system.com/lab-results";
        
        // Administrative resources
        public const string Scheduling = "https://health.system.com/scheduling";
        public const string Billing = "https://health.system.com/billing";
    }
    
    public static IEnumerable<ApiResource> GetApiResources()
    {
        return new[]
        {
            new ApiResource(Resources.PatientRecords, "Patient Records API")
            {
                Scopes = 
                { 
                    "patients.read",           // Read basic info
                    "patients.read.full",      // Read complete records
                    "patients.write",          // Update records
                    "patients.emergency"       // Emergency access
                },
                UserClaims = 
                { 
                    "name", 
                    "email", 
                    "role",
                    "provider_id",
                    "facility_id",
                    "npi_number"  // National Provider Identifier
                },
                // Add custom properties for audit logging
                Properties = 
                {
                    { "RequiresAuditLog", "true" },
                    { "DataClassification", "PHI" }, // Protected Health Information
                    { "RetentionYears", "7" }
                }
            },
            
            new ApiResource(Resources.MedicalImaging, "Medical Imaging API")
            {
                Scopes = { "imaging.read", "imaging.upload" },
                UserClaims = { "name", "role", "provider_id" },
                Properties = 
                {
                    { "RequiresAuditLog", "true" },
                    { "DataClassification", "PHI" }
                }
            }
        };
    }
}

// Physician client with restricted access
new Client
{
    ClientId = "physician-portal",
    ClientName = "Physician Portal",
    AllowedGrantTypes = GrantTypes.Code,
    RequirePkce = true,
    
    AllowedScopes = 
    {
        "openid", "profile",
        "patients.read.full",
        "patients.write",
        "imaging.read",
        "lab-results.read"
    },
    
    // Additional HIPAA-specific claims
    Claims = 
    {
        new ClientClaim("hipaa_authorized", "true"),
        new ClientClaim("training_completed", "2024-01-15")
    }
}

// Emergency access client (break-glass scenario)
new Client
{
    ClientId = "emergency-access",
    ClientName = "Emergency Access System",
    AllowedGrantTypes = GrantTypes.ClientCredentials,
    
    ClientSecrets = { new Secret("emergency-secret".Sha256()) },
    
    AllowedScopes = 
    {
        "patients.emergency",  // Special emergency scope
        "imaging.read",
        "lab-results.read"
    },
    
    // Requires additional logging and notification
    Properties = 
    {
        { "EmergencyAccess", "true" },
        { "RequiresJustification", "true" },
        { "NotifySecurityTeam", "true" }
    }
}

// Token validation with audit logging
public class HipaaTokenValidator
{
    private readonly IAuditLogger _auditLogger;
    
    public async Task ValidateAndLogAsync(
        ClaimsPrincipal principal,
        string resource)
    {
        // Log every access to PHI resources
        await _auditLogger.LogAccessAsync(new AccessLog
        {
            Timestamp = DateTime.UtcNow,
            UserId = principal.FindFirst("sub")?.Value,
            UserName = principal.FindFirst("name")?.Value,
            Resource = resource,
            Action = "AccessAttempt",
            ProviderId = principal.FindFirst("provider_id")?.Value,
            FacilityId = principal.FindFirst("facility_id")?.Value
        });
    }
}

Performance Optimization

Token Request Batching

When multiple resources are needed simultaneously:

public class BatchTokenService
{
    private readonly SemaphoreSlim _batchLock = new(1, 1);
    private readonly List<TokenRequest> _pendingRequests = new();
    
    public async Task<string> GetTokenAsync(string resource)
    {
        var tcs = new TaskCompletionSource<string>();
        var request = new TokenRequest { Resource = resource, CompletionSource = tcs };
        
        await _batchLock.WaitAsync();
        try
        {
            _pendingRequests.Add(request);
            
            // Start batch processing if this is the first request
            if (_pendingRequests.Count == 1)
            {
                _ = Task.Run(ProcessBatchAsync);
            }
        }
        finally
        {
            _batchLock.Release();
        }
        
        return await tcs.Task;
    }
    
    private async Task ProcessBatchAsync()
    {
        await Task.Delay(50); // Small delay to collect more requests
        
        await _batchLock.WaitAsync();
        List<TokenRequest> batch;
        try
        {
            batch = new List<TokenRequest>(_pendingRequests);
            _pendingRequests.Clear();
        }
        finally
        {
            _batchLock.Release();
        }
        
        // Group by resource to avoid duplicate requests
        var uniqueResources = batch
            .GroupBy(r => r.Resource)
            .Select(g => g.First())
            .ToList();
        
        // Request tokens for all unique resources in parallel
        var tasks = uniqueResources.Select(async req =>
        {
            try
            {
                var token = await RequestTokenAsync(req.Resource);
                
                // Complete all requests for this resource
                var matchingRequests = batch.Where(r => r.Resource == req.Resource);
                foreach (var match in matchingRequests)
                {
                    match.CompletionSource.SetResult(token);
                }
            }
            catch (Exception ex)
            {
                var matchingRequests = batch.Where(r => r.Resource == req.Resource);
                foreach (var match in matchingRequests)
                {
                    match.CompletionSource.SetException(ex);
                }
            }
        });
        
        await Task.WhenAll(tasks);
    }
    
    private class TokenRequest
    {
        public string Resource { get; set; }
        public TaskCompletionSource<string> CompletionSource { get; set; }
    }
}

Proactive Token Refresh

Refresh tokens before they expire:

public class ProactiveTokenCache
{
    private readonly Timer _refreshTimer;
    private readonly ConcurrentDictionary<string, CachedToken> _cache = new();
    
    public ProactiveTokenCache()
    {
        // Check for expiring tokens every minute
        _refreshTimer = new Timer(RefreshExpiringTokens, null, 
            TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
    }
    
    private async void RefreshExpiringTokens(object state)
    {
        var expiringTokens = _cache
            .Where(kvp => kvp.Value.ExpiresAt < DateTime.UtcNow.AddMinutes(10))
            .ToList();
        
        foreach (var kvp in expiringTokens)
        {
            try
            {
                var newToken = await RequestTokenAsync(kvp.Key);
                _cache[kvp.Key] = new CachedToken
                {
                    AccessToken = newToken.AccessToken,
                    ExpiresAt = DateTime.UtcNow.AddSeconds(newToken.ExpiresIn)
                };
            }
            catch (Exception ex)
            {
                // Log but don't throw - will retry on next cycle
                Console.WriteLine($"Failed to refresh token for {kvp.Key}: {ex.Message}");
            }
        }
    }
}

Comparison with Other Approaches

Resource Indicators vs. Scope-Only Authorization

AspectScope-OnlyResource Indicators
Audience ClarityAmbiguous - scope doesn't indicate target APIExplicit - audience claim identifies exact resource
Token Reuse RiskHigh - token could be used on wrong APILow - audience validation prevents misuse
Multi-API SupportPoor - scope collisions possibleExcellent - clear resource boundaries
Granular ControlScopes provide permission levelResource + Scope provides API + permission
Migration EffortN/AModerate - requires updates to clients and APIs

Resource Indicators vs. Separate Authorization Servers

AspectSeparate Auth ServersSingle Auth Server + Resource Indicators
Operational ComplexityHigh - multiple servers to maintainLow - single server
Token ConsistencyDifferent token formats possibleConsistent token format
User ExperienceMultiple login promptsSingle sign-on across all resources
Development EffortHigh - integrate with multiple systemsLow - single integration point
Security BoundaryStrong - complete isolationGood - logical isolation via audience

Compliance and Standards

GDPR Considerations

Resource indicators help with GDPR compliance by:

  1. Purpose Limitation: Tokens are explicitly scoped to specific data processing purposes (resources)
  2. Data Minimization: Each resource can expose only necessary claims
  3. Audit Trail: Resource-specific tokens make it easier to track data access
// Example: Separate resources for different data categories
public const string PersonalData = "https://api.company.com/personal-data";
public const string MarketingData = "https://api.company.com/marketing-data";
public const string AnalyticsData = "https://api.company.com/analytics-data";

// Each resource has appropriate scopes and claims
new ApiResource(PersonalData, "Personal Data API")
{
    Scopes = { "personal.read", "personal.update" },
    UserClaims = { "sub", "email" }, // Minimal claims
    Properties = 
    {
        { "LegalBasis", "Contract" },
        { "DataCategory", "Personal" },
        { "RetentionDays", "90" }
    }
}

PCI DSS Compliance

For payment card industry compliance:

public const string PaymentProcessing = "https://secure.payments.com/process";

new ApiResource(PaymentProcessing, "Payment Processing API")
{
    Scopes = { "payments.tokenize", "payments.charge" },
    
    // No cardholder data in claims
    UserClaims = { "merchant_id", "terminal_id" },
    
    Properties = 
    {
        { "PCILevel", "1" },
        { "RequiresTLS1.3", "true" },
        { "TokenExpiration", "300" } // 5 minutes
    }
}

// Short-lived tokens for PCI compliance
services.AddIdentityServer(options =>
{
    options.AccessTokenJwtType = "at+jwt";
    
    // Configure short expiration for payment tokens
    options.EmitStaticAudienceClaim = true;
}).AddResourceValidator<PCIResourceValidator>();

public class PCIResourceValidator : IResourceValidator
{
    public Task<ResourceValidationResult> ValidateRequestedResourcesAsync(
        ResourceValidationRequest request)
    {
        var result = new ResourceValidationResult();
        
        foreach (var resource in request.ParsedResources)
        {
            if (resource.Contains("payments"))
            {
                // Enforce stricter requirements for payment resources
                if (!request.Client.Properties.ContainsKey("PCICertified"))
                {
                    result.IsError = true;
                    result.Error = "unauthorized_client";
                    result.ErrorDescription = "Client must be PCI certified";
                    return Task.FromResult(result);
                }
                
                // Set short token lifetime
                result.Resources.Add(resource);
            }
        }
        
        return Task.FromResult(result);
    }
}

Testing Resource Indicators

Unit Testing

[Fact]
public async Task TokenRequest_WithResourceIndicator_ShouldIncludeAudienceClaim()
{
    // Arrange
    var resource = "https://api.example.com";
    var tokenService = new TokenService(_httpClientFactory, _options);
    
    // Act
    var token = await tokenService.GetTokenAsync(resource);
    var handler = new JwtSecurityTokenHandler();
    var jwtToken = handler.ReadJwtToken(token);
    
    // Assert
    Assert.Contains(jwtToken.Audiences, aud => aud == resource);
}

[Fact]
public async Task ResourceServer_WithWrongAudience_ShouldRejectToken()
{
    // Arrange
    var tokenForApiA = await GetTokenAsync("https://api-a.example.com");
    var apiBClient = CreateClientForApiB(); // Configured for api-b
    
    // Act
    apiBClient.SetBearerToken(tokenForApiA);
    var response = await apiBClient.GetAsync("https://api-b.example.com/data");
    
    // Assert
    Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

Integration Testing

public class ResourceIndicatorIntegrationTests : IClassFixture<IdentityServerFixture>
{
    private readonly IdentityServerFixture _fixture;
    
    public ResourceIndicatorIntegrationTests(IdentityServerFixture fixture)
    {
        _fixture = fixture;
    }
    
    [Fact]
    public async Task EndToEnd_AuthorizationCodeFlow_WithResourceIndicator()
    {
        // Arrange
        var client = _fixture.CreateClient();
        var resource = "https://api.example.com";
        
        // Act - Get authorization code
        var authorizeResponse = await client.GetAsync(
            $"/connect/authorize?response_type=code&client_id=test-client&" +
            $"redirect_uri=https://client.example.com/callback&" +
            $"resource={Uri.EscapeDataString(resource)}&" +
            $"scope=api.read&state=test");
        
        // Extract authorization code from redirect
        var code = ExtractCodeFromRedirect(authorizeResponse);
        
        // Exchange code for token with same resource
        var tokenResponse = await client.RequestAuthorizationCodeTokenAsync(
            new AuthorizationCodeTokenRequest
            {
                Address = $"{_fixture.ServerAddress}/connect/token",
                ClientId = "test-client",
                ClientSecret = "secret",
                Code = code,
                RedirectUri = "https://client.example.com/callback",
                Resource = { resource }
            });
        
        // Assert
        Assert.False(tokenResponse.IsError);
        Assert.NotNull(tokenResponse.AccessToken);
        
        // Verify audience in token
        var handler = new JwtSecurityTokenHandler();
        var token = handler.ReadJwtToken(tokenResponse.AccessToken);
        Assert.Contains(token.Audiences, aud => aud == resource);
        
        // Verify token works with resource server
        var apiClient = _fixture.CreateClient();
        apiClient.SetBearerToken(tokenResponse.AccessToken);
        var apiResponse = await apiClient.GetAsync("https://api.example.com/data");
        Assert.Equal(HttpStatusCode.OK, apiResponse.StatusCode);
    }
}

Conclusion

RFC 8707 Resource Indicators provide a robust solution for securing multi-API environments by explicitly identifying the intended audience for access tokens. When combined with Duende IdentityServer's comprehensive implementation, development teams can build secure, scalable authorization systems that support complex microservices architectures.

Key Takeaways:

  1. Resource indicators solve the audience ambiguity problem in OAuth 2.0
  2. Always validate the audience claim in resource servers
  3. Use consistent resource URI naming conventions across your organization
  4. Combine resource indicators with appropriate scopes for fine-grained control
  5. Implement proper token caching to optimize performance
  6. Follow security best practices including TLS, PKCE, and proper secret management
For production implementations, always refer to the latest RFC 8707 specification, Duende IdentityServer documentation, and security best practices.

Additional Resources