Skip to content

Commit f5ce35b

Browse files
committed
Add sign-in and token claim hooks; keep ID token and userinfo apart
- ISignInHandler: runs once per sign-in with the ID token, the userinfo response and the combined view kept apart, plus the IdP access token; call your backend (sync users, fetch permissions), store SessionData, or Deny - ITokenClaimsEnricher: shapes the server token at sign-in and on every refresh; sees IdToken, UserInfo, SessionData and the reason; protected claims (sub, scope, client_id, sid, idp, jti) can't be changed - Sessions keep the ID token, userinfo and handler data - Docs: users and claims guide; tests for both hooks
1 parent 45dce04 commit f5ce35b

17 files changed

Lines changed: 742 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ First release.
1919
unsafe for OAuth are refused (non-loopback `http`, `javascript:`, `data:`, `file:`). Scopes are defined in code
2020
(`Core/Auth/AppScopes.cs`); clients get only the scopes they request. No IdP-specific role or group mapping: copy
2121
any IdP claim with `OIDC_TOKEN_CLAIMS` and use it in a policy.
22+
- **Users and claims:** `AppUser` in tools, services, middleware, endpoints and filters. `ISignInHandler` runs once
23+
per sign-in with the ID token and userinfo apart and the IdP access token: call your backend, store `SessionData`,
24+
or deny. `ITokenClaimsEnricher` shapes the server token at sign-in and on every refresh (protected claims can't be
25+
changed).
2226
- **Users sign in once:** rotating refresh tokens renew expired access tokens; every refresh extends the session and
2327
the client registration; a 30-second reuse window (`AUTH_REFRESH_REUSE_SECONDS`) makes concurrent refreshes and
2428
retries safe; sign-ins are stored in files by default (`AUTH_STORE=file`, a `/data` volume in Docker) so restarts
@@ -39,4 +43,4 @@ First release.
3943
architecture tests.
4044
- **Ship:** multi-stage Dockerfile (optional UI build), Compose, GitHub Actions (build, test, UI bundle check,
4145
Docker), `global.json`, central package management.
42-
- **Docs:** 11 guides indexed by task (`docs/development/README.md`) and 4 ADRs.
46+
- **Docs:** 12 guides indexed by task (`docs/development/README.md`) and 4 ADRs.

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,16 @@ Add the server in Claude (Settings → Connectors → Add custom connector) and
6262
| Area | Included |
6363
| --- | --- |
6464
| **MCP** | Tools, resources and prompts discovered from attributes; stateless Streamable HTTP (scales horizontally); annotations; structured content with output schemas |
65-
| **Auth** | OIDC proxy for any OIDC provider (Entra ID, Google, Auth0, Okta, Keycloak, Zitadel, ...): DCR, PKCE, consent, ID token + userinfo merged into one `AppUser`. **Any MCP client can connect**, no allow-list. Or the `jwt` provider, or your own `IAuthProvider`. `[Authorize]` per item, scope policies, any IdP claim when you need it |
65+
| **Auth** | OIDC proxy for any OIDC provider (Entra ID, Google, Auth0, Okta, Keycloak, Zitadel, ...): DCR, PKCE, consent, ID token + userinfo merged into one `AppUser`. **Any MCP client can connect**, no allow-list. Or the `jwt` provider, or your own `IAuthProvider`. `[Authorize]` per item, scope policies, any IdP claim when you need it. `AppUser` injectable everywhere; `ISignInHandler` calls your backend at sign-in (sync users, permissions, deny); `ITokenClaimsEnricher` shapes the token's claims |
6666
| **MCP Apps** | `ui/` workspace (React 19, Vite, Tailwind 4, ext-apps 2): one bundle per view, host theme, tool calls from the view, zod-validated outputs, sandbox preview, per-entry builds |
6767
| **Validation** | Data annotations and `IValidatableObject` on every tool and prompt call, nested objects included |
6868
| **Integrations** | Typed API clients from one line of registration; retries, circuit breaker, timeouts; `bearer`, `api_key`, `basic`, `client_credentials`, or the signed-in user's token |
6969
| **Redis** | Optional, for scale-out or your own caching: one shared StackExchange.Redis connection, `REDIS_URL` as URL or connection string (cluster, Sentinel, TLS), and one `IRedisConnectionFactory` to plug in any vendor (Azure Entra ID, AWS, GCP, certificates). Health-checked |
7070
| **Settings** | `.env` plus typed settings classes validated at startup, every problem listed by variable name |
7171
| **Dev loop** | `dotnet watch` with hot reload, `pnpm run watch` for views, dual-stack `localhost`, startup URL in the log |
72-
| **Tests** | Unit, integration (a real MCP client against the in-memory server, including the full OAuth flow against a fake IdP) and architecture rules: 92 tests out of the box |
72+
| **Tests** | Unit, integration (a real MCP client against the in-memory server, including the full OAuth flow against a fake IdP) and architecture rules: 96 tests out of the box |
7373
| **Ship** | Multi-stage Dockerfile, Compose, GitHub Actions (build, test, UI bundle check, Docker), pinned SDK, central package versions |
74-
| **Docs** | 11 guides organized by task, 4 architecture decision records |
74+
| **Docs** | 12 guides organized by task, 4 architecture decision records |
7575

7676
## Project layout
7777

‎templates/DotnetMcpTemplate/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ identity provider (Entra ID, Google, Auth0, Okta, Keycloak, Zitadel, ...):
8383
2. Set `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` in `.env`, and `MCP_AUTH_MODE=required`.
8484
3. Connect a client: it registers, shows the consent page, and sends you to the identity provider to sign in.
8585

86-
Tools, resources and prompts declare `[Authorize]` or `[AllowAnonymous]`. Scopes are defined in
86+
Tools, resources and prompts declare `[Authorize]` or `[AllowAnonymous]`. Inject `AppUser` anywhere (tools, services,
87+
middleware, endpoints) to read the user's claims. Call your backend once per sign-in with an `ISignInHandler` (sync
88+
users, fetch permissions, deny access) and shape the token with an `ITokenClaimsEnricher`
89+
([users and claims](docs/development/users-and-claims.md)). Scopes are defined in
8790
`Core/Auth/AppScopes.cs` and policies in `Core/Auth/Policies.cs`.
8891

8992
**Users sign in once.** Expired access tokens are renewed with rotating refresh tokens, every refresh extends the

‎templates/DotnetMcpTemplate/docs/development/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Read top to bottom when you're new; jump to a section when you have a task.
3131
| Guide | Covers |
3232
| --- | --- |
3333
| [Authentication and authorization](authentication-and-authorization.md) | Auth modes, the OIDC proxy, any MCP client, `jwt` provider, policies, scopes, IdP claims, troubleshooting |
34+
| [Users and claims](users-and-claims.md) | ID token vs userinfo; read claims anywhere; call your backend at sign-in (`ISignInHandler`); shape the token (`ITokenClaimsEnricher`) |
3435
| [Custom auth providers](custom-auth-providers.md) | Implement `IAuthProvider` for anything else |
3536

3637
## 5. Keep it correct
@@ -49,6 +50,8 @@ Read top to bottom when you're new; jump to a section when you have a task.
4950
| Add a setting | [Configuration → Typed settings](configuration-and-environments.md#typed-settings) |
5051
| Require a scope | [Auth → Scopes](authentication-and-authorization.md#scopes) |
5152
| Use IdP roles or groups | [Auth → Roles, groups and other IdP claims](authentication-and-authorization.md#roles-groups-and-other-idp-claims) |
53+
| Read the user's claims, or add claims to the token | [Users and claims](users-and-claims.md) |
54+
| Sync users to my backend or fetch permissions at sign-in | [Users and claims → At sign-in](users-and-claims.md#at-sign-in-isigninhandler-oidc-provider) |
5255
| Show a UI for a tool | [MCP Apps](mcp-apps.md), then [Adding a view](../../ui/README.md#adding-a-view) |
5356
| Fix a login problem | [Auth → Default provider](authentication-and-authorization.md#default-provider-oidc-proxy-auth_provideroidc) |
5457
| Test a tool with auth | [Testing → Integration tests](testing.md#integration-tests-the-server-as-a-client-sees-it) |

‎templates/DotnetMcpTemplate/docs/development/authentication-and-authorization.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,6 @@ if (user.ClaimValues("roles").Contains("admin")) { ... }
108108
```
109109

110110
With the `jwt` provider the IdP's claims are already in the token; skip step 1.
111+
112+
To call your backend once per sign-in (sync the user, fetch permissions, refuse access) use an `ISignInHandler`; to
113+
map or compute claims on every token (Keycloak's nested `realm_access.roles`, a tenant) use an `ITokenClaimsEnricher`. Reading claims in tools, services, middleware and filters: [users and claims](users-and-claims.md).
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# Users and claims
2+
3+
Every authenticated request carries a token, and every part of the app sees its claims as the same `AppUser`.
4+
5+
```text
6+
AUTH_PROVIDER=oidc AUTH_PROVIDER=jwt
7+
sign-in at the IdP the IdP's access token, as is
8+
├─ ID token (validated) ─┐ │
9+
└─ userinfo response ─┴─ kept apart, and combined │
10+
▼ │
11+
your ISignInHandler(s) once per sign-in: call your backend, │
12+
store SessionData, or Deny │
13+
▼ │
14+
sub, name, email, picture, preferred_username + OIDC_TOKEN_CLAIMS │
15+
▼ │
16+
your ITokenClaimsEnricher(s) every token: at sign-in and refresh │
17+
= the server's access token ──────────────────┬───────────────┘
18+
▼
19+
your IClaimsTransformation(s) (every request, any provider)
20+
▼
21+
HttpContext.User → AppUser
22+
```
23+
24+
See every claim a user has with the `whoami` tool (MCP Inspector → Tools → `whoami`).
25+
26+
## ID token, userinfo and the combined view
27+
28+
The OIDC provider sends identity data twice: in the **ID token** (validated: signature, issuer, audience, nonce) and,
29+
when `OIDC_USERINFO` allows, in the **userinfo response**. Both are kept exactly as received, and also combined:
30+
31+
| | What it is | Where |
32+
| --- | --- | --- |
33+
| `IdToken` | The validated ID token's claims, including `iss`, `aud`, `exp` | `SignInContext`, `TokenClaimsContext` |
34+
| `UserInfo` | The userinfo response, or `null` when it wasn't called | `SignInContext`, `TokenClaimsContext` |
35+
| `IdentityProviderClaims` | Both combined: **userinfo wins** for the same claim, protocol claims (`iss`, `aud`, `exp`, `nonce`, ...) removed. userinfo must be for the same `sub` | `SignInContext`, `TokenClaimsContext`; the source of the standard claims and `OIDC_TOKEN_CLAIMS` |
36+
37+
Use `IdToken` or `UserInfo` when the source matters (e.g. trust `email_verified` from the ID token only); use the
38+
combined view, or `IdpValues("realm_access.roles")`, for everything else.
39+
40+
## Reading claims
41+
42+
| Where | How |
43+
| --- | --- |
44+
| Tool, resource, prompt | Add a parameter `AppUser user`. It's hidden from the schema, so the model can't fake it |
45+
| Service | Take `AppUser user` as a method parameter from the tool (explicit, easy to test), or inject `AppUser` in a scoped service's constructor |
46+
| ASP.NET Core middleware | `public async Task InvokeAsync(HttpContext context, AppUser user)`, or `context.User` |
47+
| Minimal API endpoint | `app.MapGet("/reports", (AppUser user) => …).RequireAuthorization()` |
48+
| MCP request filter | `AppUser.FromPrincipal(context.User)` |
49+
| Anywhere else in a request | Inject `AppUser` (scoped), or `IHttpContextAccessor` → `HttpContext.User` |
50+
51+
```csharp
52+
public Task<InvoiceDto> GetInvoice(string number, AppUser user, CancellationToken cancellationToken)
53+
{
54+
var tenant = user.ClaimValues("tenant_id").FirstOrDefault(); // any claim, by name
55+
var canWrite = user.ClaimValues("permissions").Contains("invoices.write");
56+
...
57+
}
58+
```
59+
60+
| `AppUser` member | |
61+
| --- | --- |
62+
| `Id`, `Name`, `Email`, `Username`, `Picture` | Standard OIDC claims (`sub`, `name`, `email`, `preferred_username`, `picture`) |
63+
| `Scopes`, `HasScope("projects:write")` | Scopes granted to the client (`scope` or `scp`) |
64+
| `Claims` | Every claim of the token as JSON (arrays for repeated claims) |
65+
| `ClaimValues("roles")` | All values of one claim, `[]` when missing |
66+
| `IsAuthenticated` | `false` for anonymous callers (`MCP_AUTH_MODE=mixed` with `[AllowAnonymous]`) |
67+
68+
In policies use `RequireClaim`: `policy.RequireAuthenticatedUser().RequireClaim("permissions", "invoices.write")` in
69+
`Core/Auth/Policies.cs`, then `[Authorize(Policy = "...")]`.
70+
See [authorization](authentication-and-authorization.md#roles-groups-and-other-idp-claims).
71+
72+
## At sign-in: `ISignInHandler` (oidc provider)
73+
74+
Runs **once per sign-in**, after the user signed in at the IdP and before the MCP client gets a token. Use it to
75+
sync the user to your backend, fetch permissions, check a subscription, or refuse the sign-in.
76+
77+
```csharp
78+
// Integrations/UserDirectory/UserDirectoryClient.cs: your backend, as a typed API client (see api-clients.md)
79+
public sealed class UserDirectoryClient(HttpClient http) : ApiClient(http), IUserDirectoryClient
80+
{
81+
public const string Prefix = "USER_DIRECTORY"; // USER_DIRECTORY_BASE_URL, USER_DIRECTORY_AUTH, ...
82+
83+
public Task<DirectoryUser?> SyncAsync(SyncUserRequest request, string idpAccessToken, CancellationToken cancellationToken) =>
84+
PostAsync<DirectoryUser>("users/sync", request,
85+
new RequestHeaders { ["Authorization"] = $"Bearer {idpAccessToken}" }, cancellationToken);
86+
}
87+
```
88+
89+
```csharp
90+
// Services/Auth/SyncUserSignInHandler.cs
91+
public sealed class SyncUserSignInHandler(IUserDirectoryClient directory) : ISignInHandler
92+
{
93+
public async ValueTask OnSignInAsync(SignInContext context, CancellationToken cancellationToken)
94+
{
95+
var user = await directory.SyncAsync(
96+
new SyncUserRequest(context.Subject, context.IdentityProviderClaims["email"]?.ToString(), context.UserInfo?["name"]?.ToString()),
97+
context.IdentityProviderAccessToken!, // call your API as this user
98+
cancellationToken);
99+
100+
if (user is null || !user.Active)
101+
{
102+
context.Deny("Your account has no access to this MCP server. Ask an administrator.");
103+
return;
104+
}
105+
106+
// Kept with the session: every token built later (sign-in and each refresh) can use it.
107+
context.SessionData["internal_user_id"] = user.Id;
108+
context.SessionData["permissions"] = new JsonArray(user.Permissions.Select(p => (JsonNode?)p).ToArray());
109+
}
110+
}
111+
```
112+
113+
```csharp
114+
// Integrations/IntegrationsSetup.cs and Services/ServicesSetup.cs
115+
services.AddApiClient<IUserDirectoryClient, UserDirectoryClient>(configuration, UserDirectoryClient.Prefix);
116+
services.AddScoped<ISignInHandler, SyncUserSignInHandler>();
117+
```
118+
119+
- **What it sees:** `Subject`, `Issuer`, `ClientId`, `Scopes`, `IdToken`, `UserInfo`, `IdentityProviderClaims`,
120+
`IdpValues(path)`, and `IdentityProviderAccessToken` (the IdP's access token; what it grants depends on
121+
`OIDC_SCOPES`). Your API can accept that token, or authenticate the server itself
122+
(`USER_DIRECTORY_AUTH=client_credentials` or `api_key`) and trust the `Subject` it sends.
123+
- **`SessionData`:** JSON kept with the session (in the auth store) and handed to every enricher. Keep it small and
124+
free of secrets; it's refreshed only at the next sign-in.
125+
- **`Deny(reason)`:** the MCP client receives `access_denied` with your message; no session is created.
126+
- **Errors:** an exception fails the sign-in with a generic `server_error` (the details are only logged).
127+
- Several handlers run in registration order until one denies. Each runs in a DI scope.
128+
129+
## On every token: `ITokenClaimsEnricher` (oidc provider)
130+
131+
Decides, in code, what goes into the server's token. It runs every time a token is built: at sign-in **and on every
132+
refresh**, so what it computes stays current within one access-token lifetime (`AUTH_TOKEN_LIFETIME_MINUTES`)
133+
without signing in again.
134+
135+
```csharp
136+
// Services/Auth/AppClaimsEnricher.cs
137+
public sealed class AppClaimsEnricher(ITenantDirectory tenants) : ITokenClaimsEnricher
138+
{
139+
public async ValueTask EnrichAsync(TokenClaimsContext context, CancellationToken cancellationToken)
140+
{
141+
// From the sign-in handler, without calling the backend again:
142+
context.Set("permissions", context.SessionValues("permissions").ToArray());
143+
144+
// Map the IdP's shape to yours (dot paths read nested claims):
145+
// Keycloak "realm_access.roles", Cognito "cognito:groups", Entra ID "roles", Okta "groups", ...
146+
context.Set("roles", context.IdpValues("realm_access.roles").ToArray());
147+
148+
// Look up what must stay fresh (runs on every refresh too):
149+
var tenant = await tenants.FindForUserAsync(context.Subject, cancellationToken);
150+
if (tenant is not null)
151+
{
152+
context.Set("tenant_id", tenant.Id);
153+
}
154+
155+
context.Remove("picture"); // or drop what you don't need
156+
}
157+
}
158+
159+
// Services/ServicesSetup.cs
160+
services.AddScoped<ITokenClaimsEnricher, AppClaimsEnricher>();
161+
```
162+
163+
- **What it sees:** `Reason` (`SignIn` or `Refresh`), `Subject`, `ClientId`, `Scopes`, `IdToken`, `UserInfo`,
164+
`IdentityProviderClaims`, `SessionData`, `IdpValues(path)`, `SessionValues(path)`, and `Claims`, the claims about to
165+
go into the token (standard ones plus `OIDC_TOKEN_CLAIMS`).
166+
- **Values:** string, number, bool, or an array / list (one claim per value, so `RequireClaim` works).
167+
- **Several enrichers** run in registration order. Each runs in a DI scope, so scoped services (a `DbContext`) work.
168+
- **Protected claims:** `sub`, `scope`, `client_id`, `sid`, `idp` and `jti` are restored after the enrichers run.
169+
An enricher can't change who the user is or grant scopes.
170+
- **Keep tokens small:** clients send the token with every request. Add ids and short lists, not documents.
171+
- **Errors:** an exception fails the sign-in or refresh. Catch what you can live without.
172+
173+
**Handler or enricher?** Call your backend **once** in the sign-in handler (sync, permissions) and put the result in
174+
`SessionData`; use the enricher to shape the token and to look up only what must be fresher than one sign-in.
175+
176+
## Per request: `IClaimsTransformation` (any provider)
177+
178+
To change claims on every request, with any auth provider (including `jwt`, where the IdP issues the token), use
179+
ASP.NET Core's `IClaimsTransformation`. `AppUser` and policies see the result.
180+
181+
```csharp
182+
public sealed class PlanClaims(IPlanService plans) : IClaimsTransformation
183+
{
184+
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
185+
{
186+
if (principal.Identity is not ClaimsIdentity { IsAuthenticated: true } identity || identity.HasClaim(c => c.Type == "plan"))
187+
{
188+
return principal;
189+
}
190+
191+
var plan = await plans.GetPlanAsync(principal.FindFirstValue("sub")!);
192+
identity.AddClaim(new Claim("plan", plan));
193+
return principal;
194+
}
195+
}
196+
197+
// services.AddScoped<IClaimsTransformation, PlanClaims>();
198+
```
199+
200+
It runs on every authenticated request, so cache anything expensive. Prefer the sign-in handler or an enricher for
201+
values that can live in the token; use a transformation for values that must be fresh on every call.

0 commit comments

Comments
 (0)