Integrating a Resource Server
This guide walks through wiring an ASP.NET Core resource server to Modgud so it can:
- Validate access tokens that Modgud issued
- Pick up role claims via UserInfo so
[Authorize(Roles = "…")]works - Read fine-grained permission strings from the per-Audience
resource_accessblock so it can gate on<resource>:<action>checks
The reference scenario is a fictional acme app — replace the slug with yours throughout.
Prerequisites
Before wiring code, finish the admin setup in Modgud:
- Create the app
acmewith its permission catalog (<resource>:<action>entries) - Create an OAuth client (e.g.
acme-web) and link it toacme - Create an OAuth API (resource server) named after the app's slug under OAuth → APIs, link it to
acme, and pick the catalog subset itsPermissionIdscover - Set up at least one role + group with
BoundTo: ["acme"]and assign your test user
The full admin walkthrough lives at Admin → SaaS App Integration Walkthrough.
ASP.NET Core integration
1. Add the package reference
Until the NuGet package ships, reference the project from the Modgud source tree:
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<ProjectReference Include="..\..\modgud\src\dotnet\Modgud.Client.AspNetCore\Modgud.Client.AspNetCore.csproj" />
</ItemGroup>2. Configure authentication
using Modgud.Client.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Your realm's issuer — the segment after /<host>/ is the realm slug.
options.Authority = "https://auth.example.com/system";
// Your app's slug. Modgud uses the app slug as the audience
// claim, so all microservices under one app share the same `aud`.
options.Audience = "acme";
// CRITICAL: pulls UserInfo on every token validation and merges its
// claims into the principal. Without this, role + resource_access
// claims sit on the IdP and your endpoints don't see them.
options.GetClaimsFromUserInfoEndpoint = true;
});
// Wires the Modgud ClaimsTransformation:
// - flattens resource_access[<AppSlug>].roles into ClaimTypes.Role
// so [Authorize(Roles = "Editor")] just works.
// - flattens resource_access[<AppSlug>].permissions onto an exposed
// IModgudPrincipal so you can call user.HasPermission("todo:write").
services.AddModgudClient(o =>
{
o.AppSlug = "acme";
});
services.AddAuthorization();3. Use it
// Any authenticated user.
app.MapGet("/me", (ClaimsPrincipal user) => new
{
Sub = user.FindFirstValue(ClaimTypes.NameIdentifier),
Email = user.FindFirstValue(ClaimTypes.Email),
Roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value),
}).RequireAuthorization();
// Role-only check (coarse).
app.MapDelete("/admin/wipe", () => Results.Ok())
.RequireAuthorization(p => p.RequireRole("Editor"));Granular permission checks
For checks finer than role names, read the permissions array out of the principal. The ClaimsTransformation exposes it as plain claims under the canonical permission claim type:
public static class PrincipalExtensions
{
public static bool HasPermission(this ClaimsPrincipal user, string permission)
=> user.HasClaim(ModgudClaimTypes.Permission, permission);
}
app.MapPost("/invoices", (ClaimsPrincipal user, InvoiceDto dto) =>
{
if (!user.HasPermission("invoice:write"))
return Results.Forbid();
// … create invoice
return Results.Ok();
});Or: an authorization policy
public sealed class HasPermissionRequirement(string permission) : IAuthorizationRequirement
{
public string Permission { get; } = permission;
}
public sealed class HasPermissionHandler : AuthorizationHandler<HasPermissionRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext ctx, HasPermissionRequirement req)
{
if (ctx.User.HasClaim(ModgudClaimTypes.Permission, req.Permission))
ctx.Succeed(req);
return Task.CompletedTask;
}
}
services.AddSingleton<IAuthorizationHandler, HasPermissionHandler>();
services.AddAuthorization(o =>
o.AddPolicy("CanWriteInvoices", p =>
p.AddRequirements(new HasPermissionRequirement("invoice:write"))));
app.MapPost("/invoices", (InvoiceDto dto) => Results.Ok())
.RequireAuthorization("CanWriteInvoices");What's in the permissions array
The IdP server-side does two transformations before emitting:
- Bypass-pre-expansion: if the user holds
realm:admin, the block lists every concrete catalog entry of every reachable App; a<r>:admingrant is expanded into every<r>:*entry in the current App's catalog. Your check is always exact-match — no evaluator port required. - Per-RS subset narrowing: each Audience block is narrowed to the calling OAuthApi's declared
PermissionIds. A microservice within a multi-RS App sees only its own permissions, never a sibling's.
Two gating flavours, when to pick which
| You need | Use |
|---|---|
Coarse role gating (Admin, Editor, Viewer) | [Authorize(Roles = "…")] via UserInfo + claims-transformation. |
Granular per-action permissions (invoice:write, report:export) | user.HasPermission(…) against the per-Audience resource_access.permissions block. |
| Both | Both — they compose. The same user can be Roles="Editor" and hold permission invoice:write. |
Both flavours use the same UserInfo call — there's no separate server-to-server endpoint to wire up.
Requesting the right scopes
The resource_access block is gated by scope:
- Request
scope=rolesto receive therolesarray per Audience. - Request
scope=permissionsto receive thepermissionsarray per Audience (bypass-pre-expanded + RS-subset-narrowed). - Both standard scopes are seeded into every realm.
Without those scopes, the corresponding arrays are omitted (or empty) and your gating will deny everyone — make sure the OAuth client requests them.
Common pitfalls
GetClaimsFromUserInfoEndpoint = false(default in some templates) — UserInfo is never called, soresource_accessnever reaches the principal, so role/permission claims are missing, so[Authorize(Roles)]denies everyone. Always opt in.- Wrong
AppSluginAddModgudClient— the transformation readsresource_access[<wrong-slug>], finds nothing, doesn't add roles/permissions. Symptoms: authenticated user, no roles, no permissions. Double-check the slug matches the App in Modgud. - Resource server not linked to an App — without a linked App there's no
PermissionIdssubset, so theresource_accessblock for this Audience is empty. Open the OAuth API in Modgud admin and assign the App. - Token's
auddoesn't matchJwtBearerOptions.Audience— JWT validation rejects with audience mismatch. The convention isaud == app-slug; align both sides. scope=permissionsnot requested —resource_accesscarries roles but the permissions array stays empty. Add the scope to your client's allowed scopes and to the authorization request.
Reference
- Concept overview: Apps and resource_access
- Permissions reference: Permissions & gating
- OAuth endpoints: reference/oauth-api
- Library source:
src/dotnet/Modgud.Client.AspNetCore/