MintPlayer.Spark.Authorization 11.0.0-preview.105

This is a prerelease version of MintPlayer.Spark.Authorization.
dotnet add package MintPlayer.Spark.Authorization --version 11.0.0-preview.105
                    
NuGet\Install-Package MintPlayer.Spark.Authorization -Version 11.0.0-preview.105
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MintPlayer.Spark.Authorization" Version="11.0.0-preview.105" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MintPlayer.Spark.Authorization" Version="11.0.0-preview.105" />
                    
Directory.Packages.props
<PackageReference Include="MintPlayer.Spark.Authorization" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MintPlayer.Spark.Authorization --version 11.0.0-preview.105
                    
#r "nuget: MintPlayer.Spark.Authorization, 11.0.0-preview.105"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MintPlayer.Spark.Authorization@11.0.0-preview.105
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MintPlayer.Spark.Authorization&version=11.0.0-preview.105&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=MintPlayer.Spark.Authorization&version=11.0.0-preview.105&prerelease
                    
Install as a Cake Tool

MintPlayer.Spark.Authorization

Identity for MintPlayer.Spark: ASP.NET Core Identity backed by RavenDB, external login providers, and automatic Angular frontend integration.

Overview

This package no longer owns authorization. As of preview.62, App_Data/security.json is read by Spark core, every application has one, and a missing or malformed file refuses startup. See the authorization guide for the rights model, group semantics, precedence, and the --spark-init-security starter.

What moved into core: SecurityConfiguration, Right, ISecurityConfigurationLoader, the evaluator, the validator, the claims-based group provider, the posture reporter and [SparkAuthorize]. spark.AddAuthorization(), AuthorizationOptions (including DefaultBehavior) and spark.AllowAnonymousAccess() are deleted, not deprecated. An application that wants to be open grants */* in its file, where the decision is visible.

What this package still gives you:

  • Identity — SparkUser, SparkRole, RavenDB user/role stores, the /spark/auth/* endpoint family, and how much of it to mount (SparkLocalCredentials)
  • External login — GitHub and any other OAuth/OIDC provider
  • JWT bearer — for machine callers
  • The Angular half — the @mintplayer/ng-spark/auth/* entry points of @mintplayer/ng-spark, installed and scaffolded by MSBuild

Custom group membership is a core concern now: use spark.UseGroupMembershipProvider<T>() from MintPlayer.Spark.Extensions, with or without this package.

Installation

dotnet add package MintPlayer.Spark.Authorization

Backend Setup

Add Authentication

The authorization package includes built-in ASP.NET Core Identity support with RavenDB-backed user and role stores. To enable authentication:

using MintPlayer.Spark.Authorization.Extensions;
using MintPlayer.Spark.Authorization.Identity;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSpark(builder.Configuration, spark =>
{
    spark.UseContext<MySparkContext>();
    spark.AddActions();
    spark.AddAuthentication<SparkUser>();
});

builder.Services.ConfigureApplicationCookie(options =>
{
    options.Cookie.Name = ".SparkAuth.MyApp";
});

And in the middleware pipeline:

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseSpark();      // authentication, authorization, antiforgery and the XSRF-TOKEN cookie

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
    endpoints.MapSpark();   // Spark's endpoints, including /spark/auth/* from AddAuthentication
});

UseSpark() owns the whole pipeline — it calls UseAuthentication() / UseAuthorization() in the right order and wires antiforgery. There is no UseSparkAntiforgery(); see below.

Identity Endpoints

AddAuthentication<TUser>() registers the identity endpoints itself, so you never map them by hand. They live under /spark/auth/:

Endpoint Method Modes Description
/spark/auth/register POST Full Register (email, password, userName); mails a confirmation link
/spark/auth/resendConfirmationEmail POST Full Re-send the confirmation link (unconfirmed accounts only)
/spark/auth/login POST Full, SignInOnly Log in with email or user name (cookie with ?useCookies=true, else bearer)
/spark/auth/refresh POST Full, SignInOnly Refresh a bearer token
/spark/auth/forgotPassword POST Full, SignInOnly Mail a reset link — also to an unconfirmed address
/spark/auth/resetPassword POST Full, SignInOnly Complete a reset; confirms the email
/spark/auth/confirmEmail GET all Legacy mailbox link (plain-text answer)
/spark/auth/confirm-email POST all { userId, code, changedEmail? } — what the SPA's confirm page posts
/spark/auth/manage/info GET / POST all / Full, SignInOnly Read email + confirmed; change password (oldPassword) or, with SparkEmailChange.Enabled, email (link to the new address)
/spark/auth/manage/password POST Full, SignInOnly { currentPassword?, newPassword } — set a first password or change it
/spark/auth/manage/profile GET / POST all User name, preferredCulture (mail language) + app fields (ISparkProfileContributor<TUser>)
/spark/auth/manage/2fa POST all Microsoft's 2FA management (creates the authenticator key)
/spark/auth/manage/2fa/authenticator-uri GET all { sharedKey, authenticatorUri, qrCodeSvg }, Cache-Control: no-store
/spark/auth/manage/personal-data GET all GDPR export: the account + ISparkPersonalDataContributor<TUser> sections
/spark/auth/manage/account DELETE all GDPR deletion, re-authentication required
/spark/auth/me GET all Current user info
/spark/auth/logout POST all Log out (requires XSRF token)
/spark/auth/csrf-refresh POST all Get a fresh CSRF token

Every mutating route carries the antiforgery stamp, anonymous ones included; the one stated exemption is Microsoft's bearer /refresh (its credential travels in the body).

Sign-in identifier (#460 D4)

/login and the OIDC /connect/login page share SparkSignInManager<TUser>.FindUserForSignInAsync: an identifier containing @ is an email and is looked up by email only; without @ it is a user name (a user name never contains @, see below). One lookup, so a wrong password never falls through to a second account. Two-factor and recovery codes work unchanged (the resolved user carries them).

Which kinds are accepted is the application's choice, SparkAuthenticationOptions.SignInIdentifiers (the password is always required):

spark.AddAuthentication<SparkUser>(auth =>
{
    auth.LocalCredentials = SparkLocalCredentials.Full;
    auth.SignInIdentifiers = SparkSignInIdentifiers.Email;   // or UserName; default Email | UserName
});
  • A disallowed kind is refused exactly like an unknown account (same 401, no lookup, no failed attempt counted), so it reveals nothing about accounts.
  • /spark/auth/capabilities reports it as signInIdentifiers (["email"], ["userName"] or both; empty under LocalCredentials = Disabled). ng-spark/auth's login page labels its field from it — "Email" (type=email, autocomplete=email), "User name" or "Email or user name" (autocomplete=username) — and so does the OIDC /connect/login page.
  • A value allowing neither is refused at startup unless LocalCredentials is Disabled, where there is no password sign-in to configure.
  • forgotPassword/resetPassword stay email-based in every setting — they are not sign-in, and the link has to be mailed somewhere.
The user name is a public handle (#264, G-Q22)

The user name is what other users see: a {UserName} reference label, a history author, a contributor, the OIDC preferred_username. An email address must never reach another user's browser, so:

  • SparkUserNameValidator<TUser> refuses any user name containing @ on every save — register, external sign-up, the profile page, an application's own UserManager calls. Error code UserNameContainsAt. It also keeps the two sign-in lookups from colliding.
  • POST /register requires a userName (SparkRegisterRequest: email, password, userName); a missing or blank one is a 400. ng-spark/auth's register page asks for it, and SparkClient.RegisterAsync(email, password, userName) sends it.
  • A confirmed email change never touches the user name. (SparkUserManager<TUser>, whose only job was to move an email-shaped user name along, is removed.)
  • External sign-up derives the handle from the provider (display-name slug, or the provider's handle under SparkUserNameSource.ProviderHandle), suffixed -2, -3, … when taken. A name that is an email address is never used: the account gets user- and six hex digits instead.
  • Existing accounts whose user name contains @ get such a generated handle once, through the package migration M_202610051200_UserNamesAreNotEmails (it also rewrites NormalizedUserName). Their owners can choose another on the account page (POST /manage/profile with userName).

Every account mail goes through Identity's IEmailSender<TUser> (MailManager implements it), and its link comes from ISparkAuthLinkBuilder: the SPA's pages — {PublicBaseUrl}/confirm-email?userId=…&code=…[&changedEmail=…] and {PublicBaseUrl}/reset-password?email=…&code=… — whose components post the token back.

  • ⚠️ Set Spark:Auth:PublicBaseUrl (or SparkAuthenticationOptions.Links.PublicBaseUrl) outside Development. Without it no link-bearing mail is sent (logged as an error) rather than deriving a link from the request's Host header, which on an anonymous endpoint is the attacker's to choose (password-reset poisoning). Development falls back to the request origin.
  • Sending (#460 M8): add MintPlayer.Spark.MailManager (spark.AddMailManager(), Spark:Mail:*) and nothing else. Spark replaces Identity's no-op IEmailSender<TUser> with one that queues the shipped MJML templates SparkAuth/ConfirmEmail, SparkAuth/PasswordReset and SparkAuth/LinkConfirmation (English + Dutch; override one with Templates/Mail/SparkAuth/{Name}[.{culture}].mjml in the app). Each mail is Sensitive (encrypted in the queue, scrubbed when done), expires shortly before its token and is written in SparkUser.PreferredCulture (null → Spark:Mail:DefaultCulture). An IEmailSender<TUser> the app registers itself is kept.
  • Registration needs mail (D6). LocalCredentials = Full whose mail would be discarded — Identity's no-op, or Spark's sender without MailManager — refuses startup. Opt out with AllowUnconfirmedRegistration = true / Spark:Auth:AllowUnconfirmedRegistration=true.
  • SparkAuthenticationOptions.RequireConfirmedEmail (default false) refuses sign-in to unconfirmed accounts. A completed password reset confirms the email, and forgotPassword mails unconfirmed addresses, so older unconfirmed accounts can always get in.
  • Email change is opt-in. SparkAuthenticationOptions.EmailChange defaults to SparkEmailChange.Disabled: POST manage/info { newEmail } answers 400 EmailChangeDisabled (the whole request, password half included), no mail is sent, and a change link already mailed no longer confirms. SparkEmailChange.Enabled turns it on; /spark/auth/capabilities reports emailChange, and the profile page shows its form only then.
Secrets at rest (#460 D5)

The authenticator key and every external-login token are stored as sdp1: + Data Protection ciphertext (purpose MintPlayer.Spark.Authorization.UserStore + field kind, no user id). Legacy plaintext is still read, and SparkUserBackfill<TUser> rewrites it once after start (marker document SparkAuth/Backfills/Users.v1; BackfillUsersOnStartup = false to skip). ⚠️ If the key ring loses its keys, protected values become unreadable: the authenticator key then reads as a key nobody holds — two-factor stays required, authenticator codes fail, recovery codes still work, and resetting the key repairs the account (a null would have made Identity skip the second factor). Tokens read as absent until the provider issues new ones. SparkUser.CreatedAtUtc and RegistrationMethod (password, external:{scheme}, other) are stamped on create; the backfill fills CreatedAtUtc from the oldest revision where revisions exist (RavenDB keeps no creation date in metadata).

External providers (#460 D7, #490 D5/D6/D7)

Every provider is added on the Spark builder, after spark.AddAuthentication<TUser>() (a preset called before it throws). There is no configureProviders parameter any more.

builder.Services.AddSpark(builder.Configuration, spark =>
{
    spark.AddAuthentication<SparkUser>();

    // Everything configured under Spark:Auth:Providers (below), with optional code tuning:
    spark.AddExternalProviders(builder.Configuration, providers => providers
        .GitHub(o => o.Scope.Add("read:org")));

    // Or one preset at a time, in code:
    spark.AddGitHub(o => { o.ClientId = "…"; o.ClientSecret = "…"; });
    spark.AddGoogle(o => { … });
    spark.AddMicrosoftAccount(o => { … });
    spark.AddFacebook(o => { … });
    spark.AddTwitter(o => { … });   // X, OAuth 2.0 + PKCE
    spark.AddLinkedIn(o => { … });
    spark.AddOpenIdConnect("MintPlayer", "MintPlayer ID", o =>
    {
        o.Authority = "https://id.example";   // e.g. another app's MintPlayer.Spark.IdentityProvider
        o.ClientId = "…";
        o.ClientSecret = "…";
    });
});

Each preset takes an optional Action<TOptions>, which runs after Spark's defaults (and after the configuration binding), and an overload (scheme, displayName, configure) for a second instance or another label. Calling a preset twice for the same scheme merges: the handler is registered once and each call's configure runs in call order — so AddExternalProviders(configuration) followed by spark.AddGitHub(o => …) binds the configuration first and applies the code after it. Two different presets on one scheme throw.

Configuration (Spark:Auth:Providers):

{
  "Spark": {
    "Auth": {
      "Providers": {
        "GitHub":           { "ClientId": "…", "ClientSecret": "…" },
        "Google":           { "ClientId": "…", "ClientSecret": "…" },
        "MicrosoftAccount": { "ClientId": "…", "ClientSecret": "…" },
        "Facebook":         { "ClientId": "…", "ClientSecret": "…" },
        "Twitter":          { "ClientId": "…", "ClientSecret": "…", "DisplayName": "X" },
        "LinkedIn":         { "ClientId": "…", "ClientSecret": "…" },
        "OpenIdConnect": {
          "MintPlayer": {
            "DisplayName": "MintPlayer ID",
            "Authority": "https://id.example",
            "ClientId": "…",
            "ClientSecret": "…"
          }
        }
      }
    }
  }
}
  • The social keys are fixed scheme names; DisplayName is optional and labels the button (/spark/auth/capabilities serves it). Each OpenIdConnect:<scheme> becomes a scheme of that name.
  • A provider with no section, or an empty ClientId, is not registered — the capabilities endpoint, and so the sign-in buttons, follow. Keep secrets in user-secrets or environment variables (Spark__Auth__Providers__GitHub__ClientSecret).
  • An unknown key throws at startup, listing the valid keys (GitHub, Google, MicrosoftAccount, Facebook, Twitter, LinkedIn, OpenIdConnect): a misspelt provider would otherwise silently never appear.
  • The providers => providers.GitHub(…) / .OpenIdConnect("MintPlayer", …) hooks run only for a provider the configuration enabled.

Raw handlers must be declared. A remote scheme (any RemoteAuthenticationHandler: OAuth, OIDC, …) registered through services.AddAuthentication() without a Spark declaration throws at startup — nothing would say whether its email can be trusted. Declare it with spark.AddExternalScheme("MyProvider", SparkExternalProviderPolicy.Default) (an entry in SparkAuthenticationOptions.ExternalProviders counts too). A raw handler does not get Spark's OnRemoteFailure report; the presets do.

Verified-email signal. Each preset declares one: verified → a confirmed account; a reliable signal saying "not verified" → no account (email_not_verified); no reliable signal (Facebook, X, Microsoft work/school accounts) → an unconfirmed account and a confirmation mail (confirm_email_sent when RequireConfirmedEmail is on). Microsoft is trusted only for personal accounts (id-token tid = the consumers tenant). OpenID Connect reads email_verified (boolean, or the string "true"). Override per scheme with SparkAuthenticationOptions.ExternalProviders[scheme]. New user names are a slug of the display name (john-doe, john-doe-2, never the email's local part), editable on the profile page; GitHub keeps the login verbatim (applications compare it with repository owners).

Per provider:

Preset Scheme / callback Notes
AddGitHub GitHub · /signin-github user:email scope; verified email from /user/emails
AddGoogle Google · /signin-google
AddMicrosoftAccount Microsoft · /signin-microsoft adds openid for the tid
AddFacebook Facebook · /signin-facebook
AddTwitter Twitter (label "X") · /signin-twitter OAuth 2.0 + PKCE, scopes users.read tweet.read users.email, HTTP Basic client auth on the token request. The email needs the "Request email from users" setting on the X developer app; without it confirmed_email is absent and the account has no email.
AddLinkedIn LinkedIn · /signin-linkedin OIDC userinfo
AddOpenIdConnect <scheme> · /signin-<scheme>, /signout-callback-<scheme> code + PKCE, response_mode=query, openid profile email, userinfo claims, tokens not saved, client_id sent on sign-out

Register the callback URL (https://<host>/signin-…) with the provider.

Account deletion and personal data (#460 D8)

DELETE /spark/auth/manage/account with { password }, or with no body when the session's sign-in is younger than ReauthenticationMaxAge (default 5 minutes) — otherwise 403 reauthentication_required. Every ISparkAccountDeletionHandler<TUser> runs first (registration order); one that throws stops the deletion with the account intact (500 deletion_failed, retryable — make handlers idempotent). The store deletes the account last and releases its email and passkey reservations. Only then does every ISparkAccountDeletedHandler<TUser> run — the place for a goodbye mail, which must not go out when the deletion stopped; one that throws is logged and the response is still 204. Audit fields hold user ids only; RavenDB revisions are not rewritten — content-level personal data is the application's handler's job.

After sign-in (ISparkSignedInHandler<TUser>)

Every fresh sign-in (password, second factor, recovery code, external login, passkey; never a session refresh) runs each registered ISparkSignedInHandler<TUser> before the ticket is issued. A handler that throws is logged and the sign-in still succeeds, so make it idempotent and cheap (a load by id). The Organizations package uses it to create a user's personal organization on first sign-in (docs/guide-organizations.md).

Custom Group Membership Provider

By default, Spark resolves user groups from ASP.NET Core Identity roles. To integrate with a different authentication system, implement IGroupMembershipProvider:

public class MyGroupProvider : IGroupMembershipProvider
{
    public Task<IEnumerable<string>> GetCurrentUserGroupsAsync(
        CancellationToken cancellationToken = default)
    {
        // Return the group names the current user belongs to
        // These names are matched against group translations in security.json
        return Task.FromResult<IEnumerable<string>>(["Administrators"]);
    }
}

Register it:

builder.Services.AddSpark(builder.Configuration, spark =>
{
    spark.UseGroupMembershipProvider<MyGroupProvider>();
});

UseGroupMembershipProvider lives in MintPlayer.Spark.Extensions — it is a core concern, so an application can say where groups come from without depending on this package.

⚠️ A provider cannot hand a caller anonymous or authenticated. Those are decided from authentication state and their ids are excluded from claim-derived membership, so returning "Signed-in users" resolves nothing.

UseGroupMembershipProvider removes the default registration rather than adding a second one, so which provider runs does not depend on registration order.

Adding membership instead of replacing it. spark.AddGroupMembershipProvider<T>() registers a provider whose answers are merged with the primary one's (claims by default, or whatever UseGroupMembershipProvider installed). Use it for membership that doesn't come from the sign-in, such as the privileges Moderation grants by reputation. A provider can also return group ids by implementing IGroupIdMembershipProvider (GetCurrentUserGroupIdsAsync), which avoids matching on display names. Reserved ids (anonymous, authenticated) and ids not declared in security.json are dropped. Every provider is asked once per request, and the merged answer is cached for the rest of that request, including [SparkAuthorize(Group = …)]. The order of the calls doesn't matter, and adding the same provider type twice does nothing. (AddGroupMembershipProvider is declared in MintPlayer.Spark.Abstractions, namespace MintPlayer.Spark.Extensions, so a package referencing only Abstractions can compose a provider.)

AI agents: grants and personal access tokens (MCP)

With MintPlayer.Spark.Mcp installed, spark.AddAuthentication<TUser>() also wires the agent side of the MCP server (guide, docs/spark_mcp_PRD.md D11–D15). Without the Mcp package all of this is inert.

  • AgentGrant (collection AgentGrants): what one user allowed one agent — read, or read + changes; everything the app makes available to agents, or chosen types. Natural ids: AgentGrants/{userId}/pat/{tokenId}, …/ext/{sha256(issuer|clientId)[..32]}, …/app/{clientId}. It is read on every /mcp call (cached 5 seconds, invalidated by an edit), so an edit or a revocation applies to the agent's next call. LastUsedAt is written at most once a minute.
  • Personal access tokens: spat_…, shown once, stored as a SHA-256, 90 days by default and a year at most. Refused once revoked (the grant is deleted), expired, when the user's security stamp changes (password change, removed sign-in method) or the user is locked out. The scheme PersonalAccessTokens.Scheme is not a Spark web credential: a token authenticates /mcp and nothing else, so it can never call the app's API without the agent layers.
  • Groups from the store: for an agent token (any non-ambient scheme whose sub is the local user id) the user's Roles and group claims are read from the store on every request, so removing a role denies the agent's next call. The cookie keeps its claims until Identity's security-stamp validation (30 minutes by default), as before.
  • Endpoints under /spark/auth/manage/agents (cookie + antiforgery, like the other account endpoints; mapped only with the Mcp package or a contributing package): GET the overview, POST tokens, POST update, POST revoke, POST applications/revoke. A grant is addressed by its key below the signed-in user's prefix, so a user can only reach their own. A choice beyond what the app allows for that agent (mcp.AgentPermissions) or the type's agentPermissions is refused with agent_permission_not_offered.
  • Extension point: register an ISparkConnectedApplicationSource to list (and revoke) access a package granted that is not an agent grant, e.g. the identity provider's sign-ins at other websites. Its entries appear on the same page.
  • Client: sparkAuthRoutes(…, withConnectedAgents()) mounts account/agents (SparkConnectedAgentsComponent) and links it from the account overview.
AI agents: an external authorization server (MCP setup 2)

spark.AddExternalAgentAuthorizationServer(issuer, loginProvider, o => …) (namespace MintPlayer.Spark.Authorization.ResourceServer) lets agents sign in at your organization's OAuth server and call /mcp with its access tokens (guide, docs/spark_mcp_PRD.md D22). Call it after AddAuthentication<TUser>() and after the sign-in provider for the same server; it needs spark.AddMcp(...) with a canonical URL.

spark.AddOpenIdConnect("Corp", "Corp sign-in", o => { /* the website's sign-in at the same server */ });
spark.AddExternalAgentAuthorizationServer("https://login.corp.example/realms/acme", loginProvider: "Corp");
  • Validation is AddSparkResourceServer's (signature from the issuer's keys, pinned issuer, lifetime, at+jwt unless RequireAccessTokenType = false; UseIntrospection for opaque tokens), under its own scheme, which is not a Spark web credential: the token opens /mcp only. The audience is always the MCP canonical URL. A sender-constrained token (cnf) is refused.
  • Mapping: only through a login of loginProvider whose key equals the token's SubjectClaim (sub; oid for Entra), via Identity's FindByLoginAsync. Never by email. Unmapped → 401. ISparkCurrentUser.Id is the local id; groups come from the store, never from the token.
  • Shadow grant AgentGrants/{userId}/ext/{sha256(issuer|clientId)[..32]}, created on the agent's first call (client id from azp, else client_id): everything exposed, read only. Changes need the user's toggle on the account page and WriteScope (mcp:write) in the token. Revoking it keeps it as disconnected (status disconnected), because the external server keeps issuing tokens; an edit allows the agent again. Unlinking the login, locking or deleting the user also refuse the next call.
  • Startup fails when loginProvider is not a registered remote scheme, without the Mcp package, or without a canonical URL.

XSRF/Antiforgery Protection

When using cookie-based authentication, mutation endpoints (POST, PUT, DELETE) are protected with XSRF tokens. You do not wire this up — UseSpark() does all of it: it generates the XSRF-TOKEN cookie on every response and validates the X-XSRF-TOKEN header on incoming mutations. The Angular frontend reads the cookie and echoes it back in the header (the double-submit pattern), which Angular's HttpClient does by default.

There is no UseSparkAntiforgery() method, and never was — do not call UseAuthentication(), UseAuthorization() or UseAntiforgery() yourself either. UseSpark() orders all four, and adding your own copy changes that order.

Antiforgery applies only to ambient credentials — a cookie, which a browser attaches to a cross-site request whether or not the user meant to. A caller presenting a bearer token or a client certificate is exempt, because a token that must be attached deliberately cannot be attached by an attacker's page, and demanding a cookie-derived header of a CI job that has no cookie would only make legitimate calls impossible. See Authentication Schemes for the full rule.

How Authorization Integrates with Spark

Spark core's PermissionService delegates every check to IAccessControl, which AddSpark registers unconditionally as the security.json evaluator. There is no state in which authorization is absent, and therefore no default to choose:

// From MintPlayer.Spark/Services/PermissionService.cs
public async Task EnsureAuthorizedAsync(string action, string target, ...)
{
    var resource = $"{action}/{target}";
    if (!await DecideAsync(resource, cancellationToken))
        throw new SparkAccessDeniedException(resource);
}

Two earlier shapes are gone. The original returned early when no IAccessControl was registered, so a missing package silently opened every endpoint. Its replacement — a deny-all default plus AddAuthorization() / AllowAnonymousAccess() opt-ins — was safe but still left "nobody wired it up" as a state with a made-up meaning. Now the file decides, and an open application says so by granting */*.

Angular Frontend Setup

npm dependency and the generated setup file

When you reference MintPlayer.Spark.Authorization (via NuGet), the package's MSBuild targets:

  1. Check that the SPA declares @mintplayer/ng-spark (it ships the @mintplayer/ng-spark/auth/* entry points) — warning SPARK030 when $(SpaRoot)package.json does not. The build never runs npm (it used to, in the wrong directory for a root-level node_modules, writing an unpinned range); add the dependency yourself with the major matching your Angular major.
  2. Generate spark-auth.setup.ts once — a TypeScript scaffolding file with documented auth helpers.

Wire Up Your Angular App

After the first build, a spark-auth.setup.ts file appears in your SPA's src/ directory. Use it to wire up authentication:

app.config.ts:

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { setupSparkAuthProviders, setupSparkAuthHttp } from './spark-auth.setup';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(setupSparkAuthHttp()),
    ...setupSparkAuthProviders(),
  ]
};

app.routes.ts:

import { Routes } from '@angular/router';
import { setupSparkAuthRoutes, sparkAuthGuard } from './spark-auth.setup';

export const routes: Routes = [
  {
    path: '',
    children: [
      ...setupSparkAuthRoutes(),
      { path: 'home', loadComponent: () => import('./pages/home/home.component') },
      { path: 'protected', loadComponent: () => import('./pages/protected/protected.component'), canActivate: [sparkAuthGuard] },
    ]
  }
];

The generated setup file includes the following helpers:

Export Description
setupSparkAuthProviders(config?) Returns providers array for app.config.ts
setupSparkAuthHttp() Returns HttpFeature with auth interceptor (handles 401 redirects)
setupSparkAuthRoutes(config?) Returns route array with login, register, forgot-password, reset-password pages
sparkAuthGuard Route guard that redirects unauthenticated users to login
SparkAuthBarComponent Auth bar component (<spark-auth-bar>) for login/logout UI
SparkAuthService Injectable service with login(), register(), logout(), loginWithProvider(), user signal, etc.

External Login (GitHub, Google, …)

Once a provider is registered server-side (Step 3), sign-in is one call — SparkAuthService owns the whole handshake:

const result = await this.authService.loginWithProvider('GitHub', { returnUrl: '/projects' });
if (result.success) this.router.navigate(['/projects']);

It defaults to a popup and resolves once the flow ends, whichever way it ends. Pass { mode: 'redirect' } for a full-page navigation instead; that promise never settles, because the outcome arrives as the next page load rather than as a value.

Where a redirect-mode outcome lands (errorUrl, #490 M6). GET /spark/auth/external-login and GET /spark/auth/external-logins/link take an optional errorUrl next to returnUrl. Both are sanitized the same way (a local path, anything else becomes /) and carried through the provider round trip on the callback URL. In redirect mode:

  • success redirects to returnUrl;
  • a refusal, including a failure at the provider (remote_failure, no_login_info on cancel), redirects to errorUrl with sparkExternalLogin=<code> appended — or to returnUrl when no errorUrl was given.

Popup mode ignores it. The shipped sign-in and account pages pass their own URL (with its returnUrl query, minus a stale sparkExternalLogin), so the code lands on the page that reads and shows it; a custom page passes { errorUrl } to loginWithProvider / linkProvider. The identity provider's /connect/login does the same with its federation buttons.

On failure result.error is one of no_login_info (the user cancelled at the provider), email_not_verified (the provider did not attest the address, so no account was created), account_creation_failed, popup_blocked or popup_closed. The codes are deliberately coarse: they never distinguish "no such account" from anything else.

Do not hand-roll window.open plus a message listener. The popup can end in four ways — success, a server-side refusal, a blocked window, and a user who simply closes it — and a listener that is only removed on success leaks on the other three.

twitterProvider() (scheme Twitter, labelled "X") and linkedInProvider() (scheme LinkedIn) match the server's spark.AddTwitter() / spark.AddLinkedIn() presets, next to githubProvider(), googleProvider(), facebookProvider() and microsoftProvider(). For an OpenID Connect scheme (spark.AddOpenIdConnect(scheme, …) or Spark:Auth:Providers:OpenIdConnect:<scheme>) use oidcProvider(scheme, displayName, iconClass?), e.g. oidcProvider('HR', 'Spark HR'); the scheme must match the server's exactly.

Account pages (withAccount(), #460 D16)

import { sparkAuthRoutes, withLocalLogin, withAccount } from '@mintplayer/ng-spark/auth/routes';
import { provideSparkAccountProfileFields } from '@mintplayer/ng-spark/auth/models';

// routes
...sparkAuthRoutes(withLocalLogin(), withAccount()),
// providers (optional: app fields on the profile page)
provideSparkAccountProfileFields(
  { name: 'Bio', label: 'profile.bio', type: 'textarea', maxLength: 500 },
  { name: 'Newsletter', label: 'profile.newsletter', type: 'checkbox' },
),
Page Default path Component Server
Confirm email (public) confirm-email SparkConfirmEmailComponent POST confirm-email { userId, code, changedEmail? }
Overview account SparkAccountOverviewComponent links to the mounted pages
Profile account/profile SparkAccountProfileComponent GET/POST manage/profile; email change (opt-in, SparkEmailChange.Enabled) via POST manage/info { newEmail }
Password account/password SparkChangePasswordComponent POST manage/password
Two-factor account/two-factor SparkTwoFactorSetupComponent POST manage/2fa, GET manage/2fa/authenticator-uri
Connected logins account/logins SparkExternalLoginsComponent GET external-logins, link / unlink
Passkeys account/passkeys, forwarding to /po/passkeys/me — (the generic page) the Passkeys page, the my-passkeys query, the AddPasskey / RenamePasskey / RemovePasskey actions
Personal data + deletion account/personal-data SparkPersonalDataComponent GET manage/personal-data, DELETE manage/account
  • Guarding and paths. Every page except confirm-email is guarded by sparkAuthGuard (sparkAuthenticatedGuard is the same guard). That guard waits for the session check, so reloading an account page does not send a signed-in user to the sign-in page. Override the guard with withAccount({ canActivate: [...] }), change a path with withAccount({ profile: 'me' }), or leave pages out with exclude: ['externalLogins']. confirm-email must match Spark:Auth:Links:ConfirmEmailPath, which is where confirmation mails link to. No path starts with a parameter, so the pages neither shadow sparkRoutes() nor are shadowed by it.
  • Profile. The profile page shows:
    • the user name;
    • the email, with a change form that mails the NEW address, so nothing changes until that link is opened;
    • the language for emails, which sets SparkUser.PreferredCulture. The choices are the app's languages from /spark/culture, and "Default" clears it;
    • the app's SPARK_ACCOUNT_PROFILE_FIELDS, each validated and stored by an ISparkProfileContributor<TUser> that declares the same name. Field errors render next to their control.
  • Two-factor. The QR code is the server's SVG, shown as an <img> data URL and never inserted as markup. Recovery codes are shown once, right after they are generated.
  • Account deletion. It asks for the password, or accepts a sign-in younger than ReauthenticationMaxAge (5 minutes). A 403 reauthentication_required is explained on the page.
  • Mode restrictions. Under SparkLocalCredentials.Disabled, manage/password and manage/info are not mapped. Exclude changePassword there (the account overview also hides it, and two-factor, from /spark/auth/capabilities).
  • Login label. The login form's identifier field follows the server's signInIdentifiers: "Email or user name" (auth.emailOrUserName, D4, also when the server does not report it), "Email" (auth.email) or "User name" (auth.userName).

Customizing the Generated File

The spark-auth.setup.ts file is generated once and never overwritten. You can freely customize it - for example, to change default configuration:

export function setupSparkAuthProviders(config?: Partial<SparkAuthConfig>) {
  return [provideSparkAuth({
    apiBasePath: '/spark/auth',
    defaultRedirectUrl: '/dashboard',
    loginUrl: '/sign-in',
    ...config,
  })];
}

Importing Directly

You can also skip the generated file and import directly from the npm package:

import { provideSparkAuth, withSparkAuth } from '@mintplayer/ng-spark/auth';
import {
  sparkAuthRoutes, withLocalLogin, withRegistration, withExternalLogin, githubProvider,
} from '@mintplayer/ng-spark/auth/routes';

The root entry point carries the bootstrap API only; everything else lives on a sub-path (/routes, /core, /models, /guards, …).

Pages are opted into individually — sparkAuthRoutes() with no features mounts nothing:

...sparkAuthRoutes(withLocalLogin(), withRegistration()),
...sparkAuthRoutes(withExternalLogin(githubProvider())),

Match them to the server's SparkLocalCredentials, which defaults to Disabled.

MSBuild Properties

Customize the build targets by setting these properties in your .csproj:

Property Default Description
EnableSparkAuthSpa true Master switch for all SPA-related targets
GenerateSparkAuthSetupFile true Set to false to skip generating the TypeScript setup file
SpaRoot ClientApp\ Path to the SPA source directory
SparkAuthSetupFile $(SpaRoot)src\spark-auth.setup.ts Path for the generated TypeScript file
SparkAuthNpmPackage @mintplayer/ng-spark npm package the SPA must declare

Example - disable automatic frontend setup:

<PropertyGroup>
    <EnableSparkAuthSpa>false</EnableSparkAuthSpa>
</PropertyGroup>

Local Development (ProjectReference)

A PackageReference brings the targets through buildTransitive; a ProjectReference imports nothing. In this repository you write no <Import>: the root Directory.Build.targets imports spark-authorization.props and .targets once, for every project that is an Exe and not a test project, and the targets gate themselves at execution time on the resolved references, so an application without Authorization gets none of them. A tool that is an Exe but no Spark application opts out with <SparkApplication>false</SparkApplication>. See Library layers.

A repository of your own that references this project by path needs the same: import the two files from its own Directory.Build.targets (or the app's csproj), not from each library.

Example: Role-Based Access Control

A typical setup with three roles:

Group Companies Cars People
Administrators Full CRUD Full CRUD Full CRUD
Managers Read Create/Edit (no delete) Create/Edit (no delete)
Viewers Read Read Read
Anonymous visitors Read -- --

The corresponding security.json:

{
  "wellKnown": {
    "anonymous": "00000000-0000-0000-0000-000000000000",
    "authenticated": "a1b2c3d4-0000-0000-0000-00000000000f"
  },
  "groups": {
    "00000000-0000-0000-0000-000000000000": "Anonymous visitors",
    "a1b2c3d4-0000-0000-0000-00000000000f": "Signed-in users",
    "a1b2c3d4-0000-0000-0000-000000000001": "Administrators",
    "a1b2c3d4-0000-0000-0000-000000000002": "Managers",
    "a1b2c3d4-0000-0000-0000-000000000003": "Viewers"
  },
  "rights": [
    { "key": "...", "resource": "QueryRead/Company", "groupId": "00000000-0000-0000-0000-000000000000", "isDenied": false },

    { "key": "...", "resource": "QueryReadEditNewDelete/Company", "groupId": "a1b2c3d4-0000-0000-0000-000000000001", "isDenied": false },
    { "key": "...", "resource": "QueryReadEditNewDelete/Car", "groupId": "a1b2c3d4-0000-0000-0000-000000000001", "isDenied": false },
    { "key": "...", "resource": "QueryReadEditNewDelete/Person", "groupId": "a1b2c3d4-0000-0000-0000-000000000001", "isDenied": false },

    { "key": "...", "resource": "QueryReadEditNew/Car", "groupId": "a1b2c3d4-0000-0000-0000-000000000002", "isDenied": false },
    { "key": "...", "resource": "QueryReadEditNew/Person", "groupId": "a1b2c3d4-0000-0000-0000-000000000002", "isDenied": false },
    { "key": "...", "resource": "QueryRead/Company", "groupId": "a1b2c3d4-0000-0000-0000-000000000002", "isDenied": false },

    { "key": "...", "resource": "QueryRead/Car", "groupId": "a1b2c3d4-0000-0000-0000-000000000003", "isDenied": false },
    { "key": "...", "resource": "QueryRead/Person", "groupId": "a1b2c3d4-0000-0000-0000-000000000003", "isDenied": false },
    { "key": "...", "resource": "QueryRead/Company", "groupId": "a1b2c3d4-0000-0000-0000-000000000003", "isDenied": false }
  ]
}

⚠️ The anonymous grant is the ONLY one an unauthenticated visitor gets — anonymous is not a floor under the other groups. A Manager who is also meant to read Companies needs their own grant, which is why one appears on every role above.

A user's roles (SparkUser.Roles) are these groups by name. UserManager.AddToRoleAsync(user, "viewers") stores the name the group is declared under (Viewers), and the role claims of the cookie and of an agent carry it, so User.IsInRole("Viewers"), which compares ordinally, holds. A role stored in another casing reads by the declared name too, and IsInRoleAsync, RemoveFromRoleAsync and GetUsersInRoleAsync ignore case. A name no group declares is kept as the store receives it: Identity's normalized form (VIEWERS).

Complete Example

See the demo apps for working authorization setups:

  • ../apps/CodeCoverage/CodeCoverage/Program.cs -- spark.AddAuthentication<SparkUser>(…) with an external provider, then UseSpark() / MapSpark()
  • ../apps/Fleet/Fleet/Program.cs -- the same thing through AddSparkFull / UseSparkFull, which bundle the common packages
  • ../apps/Fleet/Fleet/App_Data/security.json -- role-based permissions including custom action permissions
  • ../apps/HR/HR/App_Data/security.json -- role-based permissions for HR entities
  • ../apps/DemoApp/DemoApp/App_Data/security.json -- a fully public app, and the Query-without-Read showcase
  • ../../spark/MintPlayer.Spark/Services/SecurityFileAccessControl.cs -- permission evaluation, in core

Requirements

  • .NET 10.0+
  • RavenDB 6.2+
  • Node.js (for automatic npm integration)
  • Angular 22+ (for frontend components)

License

MIT License

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (5)

Showing the top 5 NuGet packages that depend on MintPlayer.Spark.Authorization:

Package Downloads
MintPlayer.Spark.AllFeatures

All-in-one package for MintPlayer.Spark. References all Spark libraries and source-generates AddSparkFull/UseSparkFull/MapSparkFull convenience methods.

MintPlayer.Spark.IdentityProvider

Self-contained OIDC Identity Provider for MintPlayer.Spark. Adds Authorization Code + PKCE flow with RavenDB storage. No external OIDC framework required.

MintPlayer.Spark.Moderation

Community moderation for MintPlayer.Spark: votes and a reputation ledger on IModeratable entities, privileges earned as security.json groups (bounded to reversible rights), ten vote-fraud defences (age and activity gates, diversity, eligibility, caps, delayed crediting, a nightly detector with compensating reversals, hashed network observations), flags and a review queue, locks, suspensions and an audit log. Configured through moderation.json layered under IConfiguration.

MintPlayer.Spark.Settings

Admin-changeable settings for MintPlayer.Spark: settings declared in code (SparkSetting<T>), one Setting document per key, read through ISparkSettings as stored value, else configuration, else the code default. Who may change them is a security.json slot the application binds.

MintPlayer.Spark.Dashboards

Administrator dashboards for MintPlayer.Spark: a dashboard is a live virtual persistent object whose values IDashboardProvider fills, pushed over /spark/live, with optional history on RavenDB time series and built-in RavenDB statistics and host metrics. Who may open them is a security.json slot the application binds.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
11.0.0-preview.105 46 10/12/2026
11.0.0-preview.104 63 10/11/2026
11.0.0-preview.103 60 10/9/2026
11.0.0-preview.102 61 10/8/2026
11.0.0-preview.101 56 10/7/2026
11.0.0-preview.97 84 10/5/2026
11.0.0-preview.96 52 10/4/2026
11.0.0-preview.95 73 10/4/2026
11.0.0-preview.94 66 10/4/2026
11.0.0-preview.93 70 10/2/2026
11.0.0-preview.92 67 10/2/2026
11.0.0-preview.91 70 9/29/2026
11.0.0-preview.90 66 9/27/2026
11.0.0-preview.89 59 9/27/2026
11.0.0-preview.88 64 9/26/2026
11.0.0-preview.86 60 9/24/2026
11.0.0-preview.85 63 9/24/2026
10.0.0-preview.84 81 9/21/2026
10.0.0-preview.83 65 9/19/2026
10.0.0-preview.82 68 9/17/2026
Loading failed