Skip to content

Commit c051668

Browse files
authored
Merge pull request #6646 from kjac/v15/feature/api-members
Add documentation for API Members
2 parents 9bd6cd4 + fdc2f2f commit c051668

File tree

4 files changed

+204
-2
lines changed

4 files changed

+204
-2
lines changed

15/umbraco-cms/SUMMARY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@
102102
* [Routing & Controllers](reference/routing/README.md)
103103
* [Routing in Umbraco](reference/routing/request-pipeline/README.md)
104104
* [IContentFinder](reference/routing/request-pipeline/icontentfinder.md)
105+
* [Content Delivery API](reference/content-delivery-api/README.md)
106+
* [Protected content in the Delivery API](reference/content-delivery-api/protected-content-in-the-delivery-api/README.md)
107+
* [Server to server access](reference/content-delivery-api/protected-content-in-the-delivery-api/server-to-server-access.md)
105108
* [Common Pitfalls & Anti-Patterns](reference/common-pitfalls.md)
106109
* [UmbracoMapper](reference/mapping.md)
107110
* [Depencency Injection / IoC](reference/using-ioc.md)

15/umbraco-cms/reference/content-delivery-api/protected-content-in-the-delivery-api.md renamed to 15/umbraco-cms/reference/content-delivery-api/protected-content-in-the-delivery-api/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ Umbraco allows for restricting access to content. Using the "Public access" feat
99
By default, protected content is ignored by the Delivery API, and is never exposed through any API endpoints. However, by enabling member authorization in the Delivery API, protected content can be accessed by means of access tokens.
1010

1111
{% hint style="info" %}
12-
Member authorization in the Delivery API was introduced in version 12.3.
12+
If you are not familiar with members in Umbraco, please read the [Members](https://docs.umbraco.com/umbraco-cms/fundamentals/data/members) article.
1313
{% endhint %}
1414

1515
{% hint style="info" %}
16-
If you are not familiar with members in Umbraco, please read the [Members](https://docs.umbraco.com/umbraco-cms/fundamentals/data/members) article.
16+
This article describes how to access protected content in a client-to-server context, using an interactive authorization flow.
17+
18+
If you are looking to achieve server-to-server access to protected content, please refer to [server-to-server access article](server-to-server-access.md) instead.
1719
{% endhint %}
1820

1921
## Member authorization
22.4 KB
Loading
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
---
2+
description: How to fetch protected content from the Delivery API with a server-to-server approach.
3+
---
4+
5+
# Server-to-server access to protected content in the Delivery API
6+
7+
If protected content is consumed from the Delivery API in a server-to-server context, the [interactive authorization flow](README.md) won't work. Instead, we have to utilize the OpenId Connect Client Credentials flow, which is configured in the application settings.
8+
9+
## Configuration
10+
11+
In the Delivery API, Client Credentials map known Members to client IDs and secrets. These Members are known as API Members. When an API consumer uses the Client Credentials of an API Member, the consumer efficiently assumes the identity of this API Member.
12+
13+
{% hint style="info" %}
14+
An API Member works the same as a regular Member, with the added option of authorizing with Client Credentials.
15+
{% endhint %}
16+
17+
In the following configuration example, the Member "member@local" is mapped to a set of Client Credentials:
18+
19+
{% code title="appsettings.json" %}
20+
21+
```json
22+
{
23+
"Umbraco": {
24+
"CMS": {
25+
"DeliveryApi": {
26+
"Enabled": true,
27+
"MemberAuthorization": {
28+
"ClientCredentialsFlow": {
29+
"Enabled": true,
30+
"AssociatedMembers": [
31+
{
32+
"ClientId": "my-client",
33+
"ClientSecret": "my-client-secret",
34+
"UserName": "member@local"
35+
}
36+
]
37+
}
38+
}
39+
}
40+
}
41+
}
42+
}
43+
```
44+
45+
{% endcode %}
46+
47+
After restarting the site, the backoffice will list "member@local" as an API Member:
48+
49+
![An API Member in the backoffice](images/api-member.png)
50+
51+
## Authorizing and consuming the Delivery API
52+
53+
The configured Client Credentials can be exchanged for an access token using the Delivery API token endpoint. Subsequently, the access token can be used as a Bearer token to retrieve protected content from the Delivery API.
54+
55+
The following code sample illustrates how this can be done.
56+
57+
{% hint style="info" %}
58+
This sample requires the NuGet packages [`Microsoft.Extensions.Hosting`](https://www.nuget.org/packages/Microsoft.Extensions.Hosting) and [`IdentityModel`](https://www.nuget.org/packages/IdentityModel) to run.
59+
{% endhint %}
60+
61+
You should _always_ reuse access tokens for the duration of their lifetime. This will increase performance both for your Delivery API consumer and for the Delivery API itself.
62+
63+
{% hint style="info" %}
64+
The code sample handles token reuse in the `ApiAccessTokenService` service. It must be registered as a singleton service to work.
65+
{% endhint %}
66+
67+
In the code sample, the token endpoint is hardcoded in the token exchange request. The Delivery API also supports OpenId Connect Discovery for API Members, if you prefer that.
68+
69+
{% code title="Program.cs" lineNumbers="true" %}
70+
```csharp
71+
using Microsoft.Extensions.DependencyInjection;
72+
using Microsoft.Extensions.Hosting;
73+
using System.Net.Http.Json;
74+
using IdentityModel.Client;
75+
76+
var builder = Host.CreateApplicationBuilder(args);
77+
builder.Services.AddSingleton<ApiAccessTokenService>();
78+
builder.Services.AddTransient<ApiConsumerService>();
79+
80+
using IHost host = builder.Build();
81+
var consumer = host.Services.GetRequiredService<ApiConsumerService>();
82+
await consumer.ExecuteAsync();
83+
84+
public static class Constants
85+
{
86+
// the base URL of the Umbraco site - change this to fit your custom setup
87+
public static string Host => "https://localhost:44391";
88+
}
89+
90+
// This is the API consumer, which will be listing the first few available content items - including protected ones.
91+
public class ApiConsumerService
92+
{
93+
private readonly ApiAccessTokenService _apiAccessTokenService;
94+
95+
public ApiConsumerService(ApiAccessTokenService apiAccessTokenService)
96+
=> _apiAccessTokenService = apiAccessTokenService;
97+
98+
public async Task ExecuteAsync()
99+
{
100+
// get an access token from the access token service.
101+
var accessToken = _apiAccessTokenService.GetAccessToken();
102+
if (accessToken is null)
103+
{
104+
Console.WriteLine("Could not get an access token, aborting.");
105+
return;
106+
}
107+
108+
var client = new HttpClient();
109+
client.SetBearerToken(accessToken);
110+
111+
// fetch [pageSize] content items from the "all content" Delivery API endpoint.
112+
const int pageSize = 5;
113+
var apiResponse = await client.GetAsync($"{Constants.Host}/umbraco/delivery/api/v2/content?take={pageSize}");
114+
var apiContentResponse = await apiResponse
115+
.EnsureSuccessStatusCode()
116+
.Content
117+
.ReadFromJsonAsync<ApiContentResponse>();
118+
119+
if (apiContentResponse is null)
120+
{
121+
Console.WriteLine("Could not parse content from the API response.");
122+
return;
123+
}
124+
125+
Console.WriteLine($"There are {apiContentResponse.Total} items in total - listing the first {pageSize} items.");
126+
foreach (var item in apiContentResponse.Items)
127+
{
128+
Console.WriteLine($"- {item.Name} ({item.Id})");
129+
}
130+
}
131+
}
132+
133+
// This service ensures the reuse of access tokens for the duration of their lifetime.
134+
// It must be registered as a singleton service to work properly.
135+
public class ApiAccessTokenService
136+
{
137+
private readonly Lock _lock = new();
138+
139+
private string? _accessToken;
140+
private DateTime _accessTokenExpiry = DateTime.MinValue;
141+
142+
public string? GetAccessToken()
143+
{
144+
if (_accessTokenExpiry > DateTime.UtcNow)
145+
{
146+
// we already have a token, reuse it.
147+
return _accessToken;
148+
}
149+
150+
using (_lock.EnterScope())
151+
{
152+
if (_accessTokenExpiry > DateTime.UtcNow)
153+
{
154+
// another thread fetched a new token before this thread entered the lock, reuse it.
155+
return _accessToken;
156+
}
157+
158+
var client = new HttpClient();
159+
var tokenResponse = client.RequestClientCredentialsTokenAsync(
160+
new ClientCredentialsTokenRequest
161+
{
162+
Address = $"{Constants.Host}/umbraco/delivery/api/v1/security/member/token",
163+
ClientId = "umbraco-member-my-client",
164+
ClientSecret = "my-client-secret"
165+
}
166+
)
167+
// cannot await inside a using.
168+
.GetAwaiter().GetResult();
169+
170+
if (tokenResponse.IsError || tokenResponse.AccessToken is null)
171+
{
172+
Console.WriteLine($"Error obtaining a token: {tokenResponse.ErrorDescription}");
173+
return null;
174+
}
175+
176+
_accessToken = tokenResponse.AccessToken;
177+
_accessTokenExpiry = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 20);
178+
return tokenResponse.AccessToken;
179+
}
180+
}
181+
}
182+
183+
public class ApiContentResponse
184+
{
185+
public required int Total { get; set; }
186+
187+
public required ApiContentItemResponse[] Items { get; set; }
188+
}
189+
190+
public class ApiContentItemResponse
191+
{
192+
public required Guid Id { get; set; }
193+
194+
public required string Name { get; set; }
195+
}
196+
```
197+
{% endcode %}

0 commit comments

Comments
 (0)