|
| 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