SaaS App Integration Walkthrough
This page takes you from a freshly installed Modgud all the way to a working external app doing single-sign-on against Modgud and reading audience-keyed authorization claims from an access token, UserInfo or authorized introspection response.
Audience: realm admins and developers integrating a SaaS app. Regular end-user onboarding is documented in first steps.
Conceptual overview
Modgud models the world in three layers:
- Realm — a tenant. Own database, own users, own apps. Setup automatically creates the
systemrealm. - App — a SaaS application within a realm (e.g.
modgud,acme,billing). Each app owns its permission catalog and links to zero or more OAuth clients and resource servers. - Group / Role / Permission — who may do what in which app. Groups bundle users and roles, roles bundle permissions, permissions are
<resource>:<action>strings within an App's catalog (the App context is implicit).
When you bind a new SaaS app you traverse five stations:
- Register the app
- Create an OAuth client for the app's frontend
- Create the resource server (OAuth API), link it to the app, and mint its resource-bearing scope
- Optional: create roles + assign to a group
- Configure the resource-server code in the SaaS app's backend
Prerequisites
You need:
- A running Modgud instance (see Getting Started)
- An admin account (a member of the
Administratorsgroup, created via the first-time bootstrap) - A URL for your target app (for redirect URIs), e.g.
https://acme.dev.local
Station 1: register the app
Navigate to Administration → Applications. You'll see at least the system app modgud.
Click Create.
| Field | Example | Explanation |
|---|---|---|
| Slug (immutable) | acme | Permission catalog container, kebab-case. Cannot be changed after creation. |
| Display Name | Acme | Shown in lists and consent screens |
| Description | Team task manager | Optional |
| Catalog entries | todo:read, todo:write, project:read, project:write (one per line) | <resource>:<action> strings — the App's permission vocabulary |
After Create the app shows up in the list.
TIP
Catalog entries aren't carved in stone — you can extend them later. But: existing roles break if you remove an entry that's still in use. The admin UI surfaces those references before letting you delete.
Beyond the catalog, an app's Settings tab lets you override a slice of the realm's configuration per app — its own subdomain/origin, branding, self-registration posture, and native-grant / DCR / CIMD toggles — while anything left off inherits the realm. See Application settings.
Station 2: OAuth client for the frontend
The OAuth client is the identity your app's frontend uses when requesting tokens from the IDP. An SPA, a mobile app, a desktop tool — they're all clients.
Navigate to Administration → OAuth Clients. Click Create. The Create modal exposes the full set of fields — Grant Types, Redirect URIs, Allowed Scopes, Access Token Type, Applications, CORS Origins — so you set everything in one pass; there's no second "edit after create" step required.
| Field | Example | Explanation |
|---|---|---|
| Client ID | acme-web | Stable identifier used in the OAuth flow |
| Display Name | Acme Web | UI label |
| Client type | confidential | confidential for server-side / backend (BFF) clients, public for browser-only SPA / mobile |
| Consent type | implicit | for trusted first-party apps; explicit shows a consent screen |
| Applications | pick acme | Important — binds the client to the app. Multi-select is allowed (multi-app frontends). |
| Client Secret | leave empty = generate | Auto-generated for confidential, shown only once — copy it! |
| Redirect URIs | https://acme.dev.local/auth/callback | One per line |
| Post-Logout Redirect URIs | https://acme.dev.local/ | One per line |
| Allowed Grant Types | authorization_code + refresh_token | For a web app pick authorization_code and refresh_token. There are no silent defaults — a client with no grant types cannot mint tokens. |
| Allowed Scopes | openid email profile roles permissions acme | The OIDC scopes plus the resource-bearing acme scope you create in Station 3. Request roles to get the per-audience role list, permissions for the <resource>:<action> list. |
| Access Token Type | JWT | Required for the local JWKS-validation path in this walkthrough. Modgud's token-format default is Reference (opaque and resolved via /connect/introspect); explicitly choose JWT here because the resource server below uses OnlyJwt. |
Click Create. The client secret is shown — copy it and store it safely; you'll never see it again.
Browser-only SPA (PKCE, no backend)
If your frontend is a pure SPA that talks to the IDP directly (PKCE, no server-side BFF), set Client type to public, leave the secret empty, and add the SPA's origin (e.g. https://acme.dev.local) to the client's Allowed CORS Origins. The OIDC endpoints (/connect/authorize, /connect/token, /connect/userinfo) only echo CORS headers for origins registered on a client in the active realm, so a missing origin makes the browser block the cross-origin call. A confidential / BFF web app (the primary path above) makes its token calls server-side and doesn't need a CORS origin.
What does the apps choice change?
The App selection controls which App-scoped scopes the client may request. It does not itself create claim blocks. Requested resource-bearing scopes create token audiences; each audience that resolves to a registered OAuth API gets resource_access[<audience>], using that API's linked App for roles (scope=roles) and its PermissionIds subset for permissions (scope=permissions).
Station 3: create the resource server
The resource server is the identity Modgud uses to compute the per-Audience subset narrowing in resource_access blocks. Each App whose authorization data must reach a downstream API needs at least one OAuth API registration.
Go to Administration → OAuth → APIs and click Create:
- Name —
acme(this becomes theaudclaim) - Application — pick the
acmeApp you just registered - PermissionIds — leave full catalog for now (a microservice would tighten this to its slice)
Save. The OAuth API now exists and the IdP knows which catalog to resolve against when a token targets aud=acme.
3a. Create the resource-bearing scope (don't skip this)
The six default scopes seeded into every realm (openid, email, profile, roles, permissions, offline_access) all have empty Resources. A token only gets aud=acme when one of the requested scopes carries Resources=[acme] — and without that audience the IdP never emits a resource_access[acme] block, so your resource server's audience check 401s. The default scopes alone are not enough.
On the API's detail view click Create implicit scope (this calls POST /api/admin/oauth/apis/{id}/create-implicit-scope). It mints a scope named acme with Resources=[acme], hidden from the discovery document by default (clients learn their scopes from your docs, not from .well-known). This is the scope that puts aud=acme on the token.
Then make sure the acme scope is actually requested end-to-end:
- Add
acmeto the client's Allowed Scopes (Station 2 — it's already in the example list above). - Include
acmein thescopeparameter of the authorize request, e.g.scope=openid email profile roles permissions acme.
Only then does the access token carry aud=acme and the principal a resource_access[acme] block. Inside that block, the roles array appears only if the request also included the roles scope, and the permissions array only if it included the permissions scope.
Microservice apps
Multi-service apps create one OAuthApi per microservice, each with a narrower PermissionIds subset (and its own implicit scope). The user's resource_access[<service>] block for that specific microservice is then narrowed to its declared subset — sibling microservices' permissions don't leak.
Station 4: roles and groups
On setup Modgud seeds exactly one realm admin (Administrators group with wildcard BoundTo: ["*"]). For your new app you'll usually want more nuanced roles.
4a. Create a role
Administration → Roles → Create.
| Field | Example |
|---|---|
| Name | Acme Editor |
| Description | May create and edit todos and projects |
| App | acme |
| Permissions | todo:read, todo:write |
Application roles bind to one App via AppId; a pure realm:admin role is the explicit realm-local exception. The PermissionIds reference specific catalog entries of that App. The same string todo:read in a different App's catalog is a different permission.
4b. Create a group
Administration → Groups → Create.
| Tab | Field | Example |
|---|---|---|
| General | Name | Acme Team |
| General | Bound to apps | pick acme |
| Members | (user list) | yourself + colleagues |
| Roles | Acme Editor |
BoundTo matters
A group only takes effect in the apps listed in BoundTo. Pick ★ All apps (*) only for realm-wide admin groups. Leave it empty for pure mailing-list / org-only groups.
Save. Users in this group now hold todo:read + todo:write within the acme app context.
Station 5: resource-server code
Now the backend configuration of your SaaS app. ASP.NET Core example:
A complete, runnable version of everything below ships in the repo at src/dotnet/TestApps/Modgud.TestApps.ResourceApi/Program.cs — that's the canonical example the integration tests run against. The snippets here are trimmed for the walkthrough.
Packages
dotnet add package Modgud.AspNetCore.ResourceServerProgram.cs
using System.Security.Claims;
using Modgud.AspNetCore.ResourceServer;
builder.Services.AddModgudResourceServer(options =>
{
// Authority is the realm's HOST ROOT — realms resolve by Host
// header, so the issuer has NO realm path. Never append "/system"
// or any "/<realm>" segment: a path-suffixed Authority makes the
// discovery fetch 404 and fails issuer validation.
options.Authority = "https://auth.example.com";
options.Audience = "acme"; // matches the OAuthApi name / aud claim
// TokenMode defaults to OnlyJwt.
});
// The scheme projects the JWT's embedded audience block directly:
// - resource_access["acme"].roles → ClaimTypes.Role
// - resource_access["acme"].permissions → "permission" claims
// The IdP pre-expands bypass tiers (realm:admin, <resource>:admin) before
// emission, so the RS only ever does exact-match — no evaluator on this side.
builder.Services.AddAuthorization();Coarse role check
app.MapGet("/admin", () => "Admin only")
.RequireAuthorization(p => p.RequireRole("Acme Editor"));[Authorize(Roles = "Acme Editor")] works the same way — the authentication scheme projects resource_access["acme"].roles as ClaimTypes.Role claims.
Granular permission check
Gate endpoints with .RequireModgudPermission(...) — the authorization policy reads the flattened permission claims and does a straight exact-match:
app.MapPost("/todos", () => Results.Ok())
.RequireModgudPermission("todo:write");If you need to read permissions imperatively, they live under ModgudClaimTypes.Permission:
app.MapGet("/whoami", (ClaimsPrincipal user) => Results.Ok(new
{
permissions = user
.FindAll(ModgudClaimTypes.Permission)
.Select(c => c.Value),
})).RequireAuthorization();Full integration patterns (authorization policies, dynamic checks, common pitfalls) live in Guide → Integrating a Resource Server.
End-to-end test
- Open
https://acme.dev.local - The frontend redirects you to the Modgud login page with
scope=openid email profile roles permissions acme(theacmescope is what putsaud=acmeon the token) - Log in as a user from station 4
- Consent screen (if
explicitconsent type) - Redirect back to the app with an auth code
- The app exchanges the code at
/connect/token - The resulting access token already carries
sub,email,name, andresource_access.acme.roles = ["Acme Editor"]plusresource_access.acme.permissions = ["todo:read", "todo:write"]—AddModgudResourceServerreads that straight off the validated JWT without a UserInfo round-trip [Authorize(Roles = "Acme Editor")]lets you in, and.RequireModgudPermission("todo:write")passes — the resource server validated the JWT against the realm's JWKS (because the client's Access Token Type is JWT) and matched the flattenedpermissionclaims
Made it through? Done. First SaaS app integrated.
What comes next
- Multiple apps in one client: a frontend that bundles two apps assigns its OAuth client to both, then requests resource-bearing scopes targeting APIs in each App. The resulting principal can carry one block per targeted API Audience. Each backend projects its own block.
- Microservice apps: several resource servers under one app — create more OAuth APIs in the OAuth APIs admin and link them all to the same App, each with its own narrower
PermissionIdssubset. - External login providers: under Login Providers you configure Microsoft Entra ID and standards-compatible OIDC or SAML providers. Modgud remains the OIDC provider for your application but delegates the user-authentication step.
- Standing up a second, similar app: right-click an existing App, Client, Scope, API, Role, or Group in its list and choose Clone to pre-fill a new one from it, instead of repeating all five stations from scratch. See Cloning an app.
Tips and pitfalls
- Permission strings have two segments:
<resource>:<action>, inside an App's catalog. The App context is implicit from the catalog — the same string in two different App catalogs is two different permissions. BoundTo: []≠BoundTo: ["*"]. Empty = the group is dormant for permission purposes but can still be used for mailing-list. Wildcard = active everywhere.- Don't try to delete the system app
modgud. It's flaggedIsSystem; the attempt is rejected. - Lost realm admin. If you locked yourself out of the
Administratorsgroup: the recovery CLI inside the container can pull you back in — see Recovery CLI. - Lost a secret. Client secrets are shown exactly once. If you've lost one: regenerate in the corresponding detail modal.
- No
aud=acme, noresource_access. The default scopes carry empty Resources, so the token gets no audience and the block is never emitted (your RS then 401s on its audience check). Create the API's implicit scope (Station 3a) and requestacmein the authorize request. scope=permissionsnot requested. Without it, thepermissionsarray in theresource_accessblock is omitted — yourRequireModgudPermission(…)check sees nothing. Same forrolesand the role list. Add the scope to the client's allowed-scopes list and to every authorization request.- Access Token Type set to Reference while the resource server uses
OnlyJwt. A Reference token is opaque and has nothing to validate by signature — switch the client to JWT, or configureAddModgudResourceServerfor reference-token introspection. - Authority has a realm path.
Authoritymust be the host root (https://auth.example.com), never…/systemor…/<realm>. Realms resolve by Host header; a path-suffixed Authority breaks discovery and issuer validation.