Brief description of what this article covers.
Step-by-Step Instructions
- First step
- Second step
Additional Notes
Any additional information or tips.
Connecting a .NET 8 Service to the Employee API
> TL;DR — This is the client-side guide for a .NET 8 service that needs to call the McKim & Creed Employee API app-to-app (no signed-in user). The service authenticates as itself with its own client ID + secret, requests a token for the API using the scope api://1af74f73-c0b1-4df8-9b83-0e4cb964946e/.default, and sends that token as a Bearer header. This guide assumes the Entra app registration and ProjectData.Read permission are already in place — see Registering a New App-to-App Connection to the Employee API for that setup.
This covers the app-to-app (client credentials) pattern only — there is no user login. If your service needs to act on behalf of a signed-in user, that's a different (delegated) flow and is out of scope here.
> [!tip] Need a copy to send to someone outside the vault?
> A self-contained Word version of this guide is attached: Connecting a .NET 8 Service to the Employee API.docx
---
Connection values
Use these exact values. The Tenant ID and the Employee API identifiers are fixed; the Client ID and Client Secret belong to your application's registration.
| Setting | Value |
| --- | --- |
| Tenant ID (Directory) | 354619d1-eb70-4490-b9be-08842a4c270e |
| Your app Client ID | (the calling service's Application ID) |
| Your app Client Secret | (from Certificates & secrets) |
| Employee API base URL | https://mckimcreedapi.mckimcreed.com/ |
| Scope to request | api://1af74f73-c0b1-4df8-9b83-0e4cb964946e/.default |
| Granted role | ProjectData.Read |
> [!important] For client credentials, the scope is always .default
> Request api://1af74f73-c0b1-4df8-9b83-0e4cb964946e/.default — not the role name. The roles you actually receive come from the admin-consented application permissions; .default tells Entra "give me everything this app is consented for."
---
Step 1 — Install MSAL
dotnet add package Microsoft.Identity.Client
Microsoft.Identity.Client (MSAL.NET) is all you need for the client-credentials / daemon flow — no Microsoft.Identity.Web required.
Step 2 — Configuration (`appsettings.json`)
The ClientId and ClientSecret here are the calling app's own registration values — not the Employee API's.
{
"EmployeeApi": {
"TenantId": "354619d1-eb70-4490-b9be-08842a4c270e",
"ClientId": "<YOUR-APP-CLIENT-ID>",
"ClientSecret": "<YOUR-APP-SECRET-VALUE>",
"BaseUrl": "https://mckimcreedapi.mckimcreed.com/",
"Scope": "api://1af74f73-c0b1-4df8-9b83-0e4cb964946e/.default"
}
}
> [!warning] Never commit the secret
> Use dotnet user-secrets in development and Azure Key Vault or an environment variable in production. Better still, switch to a certificate (.WithCertificate(...)) so there's no shared secret at all.
Step 3 — A `DelegatingHandler` that attaches the token
This keeps your business code clean — it just calls the API, and the handler transparently fetches/attaches the token. MSAL caches the token in memory and only contacts Entra again when it's near expiry, so this is cheap to call on every request.
using Microsoft.Identity.Client;
using System.Net.Http.Headers;
public sealed class EmployeeApiAuthHandler : DelegatingHandler
{
private readonly IConfidentialClientApplication _app;
private readonly string[] _scopes;
public EmployeeApiAuthHandler(IConfidentialClientApplication app, string scope)
{
_app = app;
_scopes = new[] { scope }; // "api://1af74f73-.../.default"
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
// MSAL caches this; it only calls Entra when the cached token is expired.
AuthenticationResult result =
await _app.AcquireTokenForClient(_scopes).ExecuteAsync(ct);
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", result.AccessToken);
return await base.SendAsync(request, ct);
}
}
Step 4 — Register everything in `Program.cs`
using Microsoft.Identity.Client;
var builder = WebApplication.CreateBuilder(args); // or Host.CreateApplicationBuilder for a Worker
var cfg = builder.Configuration.GetSection("EmployeeApi");
// One shared ConfidentialClientApplication = one shared in-memory token cache.
builder.Services.AddSingleton<IConfidentialClientApplication>(_ =>
ConfidentialClientApplicationBuilder
.Create(cfg["ClientId"])
.WithClientSecret(cfg["ClientSecret"]) // or .WithCertificate(cert)
.WithAuthority($"https://login.microsoftonline.com/{cfg["TenantId"]}")
.Build());
// The handler that injects the bearer token
builder.Services.AddTransient(sp =>
new EmployeeApiAuthHandler(
sp.GetRequiredService<IConfidentialClientApplication>(),
cfg["Scope"]!));
// A typed HttpClient pointed at the Employee API, wrapped with the auth handler
builder.Services.AddHttpClient<EmployeeApiClient>(c =>
c.BaseAddress = new Uri(cfg["BaseUrl"]!))
.AddHttpMessageHandler<EmployeeApiAuthHandler>();
var app = builder.Build();
Step 5 — Call the endpoints
The typed client makes plain HTTP calls — auth is automatic. Adjust the route to whatever ProjectData.Read exposes.
public sealed class EmployeeApiClient
{
private readonly HttpClient _http;
public EmployeeApiClient(HttpClient http) => _http = http;
public async Task<List<ProjectDto>?> GetProjectsAsync(CancellationToken ct = default)
{
var resp = await _http.GetAsync("api/projectdata", ct);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<List<ProjectDto>>(cancellationToken: ct);
}
}
Inject EmployeeApiClient anywhere (controller, worker, minimal API) and call GetProjectsAsync().
---
What happens under the hood
- First call → MSAL sends
client_id+client_secret+scope=api://1af74f73-.../.defaulttologin.microsoftonline.com//oauth2/v2.0/token. - Entra checks the admin-consented
ProjectData.Readapplication permission and returns an access token whoserolesclaim containsProjectData.Read. - The handler attaches it as
Authorization: Bearer. - The Employee API validates the token (audience =
api://1af74f73-..., issuer = our tenant,rolescontainsProjectData.Read) and serves the endpoint. - Subsequent calls reuse the cached token until it's ~5 min from expiry — no repeated round-trips to Entra.
> [!note] Token type
> The token represents the app's identity, not a user's. It carries a roles claim (not scp) and no user claims. The Employee API authorizes on the roles claim for ProjectData.Read. The defining MSAL method is AcquireTokenForClient — if you see AcquireTokenInteractive or a sign-in popup, that's the delegated user flow, which is not what this setup uses.
---
Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| 401 Unauthorized | Wrong audience/authority or expired token | Confirm scope is api://1af74f73-.../.default and authority targets tenant 354619d1-... |
| 403 Forbidden / missing roles claim | Permission added as Delegated, or admin consent missing | Must be an Application permission with green "Granted" consent on the app registration |
| AADSTS70011: invalid scope | Requested a role name instead of .default | Request api://1af74f73-.../.default |
| invalid_client | Secret expired or mistyped | Generate a new secret and update config |
| Token has scp but no roles | App is using the delegated/user flow | Use AcquireTokenForClient (client credentials), not an interactive flow |
---
Related
- Registering a New App-to-App Connection to the Employee API — the Entra registration steps (admin side)
- Employee API — Project Knowledge Base (see
03. Resources/Project Knowledge Base/EmployeeAPI)
Comments
0 comments
Please sign in to leave a comment.