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.
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.
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).
When a client includes the resource parameter in a token request, the authorization server:
aud (audience) claim in the token matching the resource URIThe resource server can then validate that the token's audience matches its own identity, ensuring the token was intended for it.
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
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
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
}
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:
aud claim contains an array of resource URIsResource indicators can be used with various OAuth 2.0 grant types:
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
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
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.
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).
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:
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")
};
}
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
}
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...
}
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);
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");
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" }
});
// 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" }
});
API resources must validate that tokens are intended for them by checking the audience claim:
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();
}
[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 });
}
}
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"]
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" }
);
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>();
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;
}
}
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 });
}
}
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);
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;
});
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
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:
Without proper audience validation, an attacker could:
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;
});
}
}
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);
}
};
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.
Symptoms: 401 Unauthorized error when calling API with valid token
Common Causes:
// 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
});
// Check IdentityServer configuration
var builder = services.AddIdentityServer(options =>
{
options.EmitStaticAudienceClaim = true; // Must be true!
});
// Ensure API has audience validation configured
services.AddAuthentication("Bearer")
.AddJwtBearer(options =>
{
options.Audience = "https://api.example.com"; // Required
});
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");
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
}
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"
}
}
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
}
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"
);
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; }
}
}
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)
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
Resource URI: https://users.api.company.com
users.read - Read user profilesusers.create - Create new usersusers.update - Update user profilesusers.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) { }
}
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);
}
}
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;
}
}
}
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"
}
}
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);
}
}
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
});
}
}
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; }
}
}
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}");
}
}
}
}
| Aspect | Scope-Only | Resource Indicators |
|---|---|---|
| Audience Clarity | Ambiguous - scope doesn't indicate target API | Explicit - audience claim identifies exact resource |
| Token Reuse Risk | High - token could be used on wrong API | Low - audience validation prevents misuse |
| Multi-API Support | Poor - scope collisions possible | Excellent - clear resource boundaries |
| Granular Control | Scopes provide permission level | Resource + Scope provides API + permission |
| Migration Effort | N/A | Moderate - requires updates to clients and APIs |
| Aspect | Separate Auth Servers | Single Auth Server + Resource Indicators |
|---|---|---|
| Operational Complexity | High - multiple servers to maintain | Low - single server |
| Token Consistency | Different token formats possible | Consistent token format |
| User Experience | Multiple login prompts | Single sign-on across all resources |
| Development Effort | High - integrate with multiple systems | Low - single integration point |
| Security Boundary | Strong - complete isolation | Good - logical isolation via audience |
Resource indicators help with GDPR compliance by:
// 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" }
}
}
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);
}
}
[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);
}
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);
}
}
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: