MintPlayer.Spark.Authorization
11.0.0-preview.105
dotnet add package MintPlayer.Spark.Authorization --version 11.0.0-preview.105
NuGet\Install-Package MintPlayer.Spark.Authorization -Version 11.0.0-preview.105
<PackageReference Include="MintPlayer.Spark.Authorization" Version="11.0.0-preview.105" />
<PackageVersion Include="MintPlayer.Spark.Authorization" Version="11.0.0-preview.105" />
<PackageReference Include="MintPlayer.Spark.Authorization" />
paket add MintPlayer.Spark.Authorization --version 11.0.0-preview.105
#r "nuget: MintPlayer.Spark.Authorization, 11.0.0-preview.105"
#:package MintPlayer.Spark.Authorization@11.0.0-preview.105
#addin nuget:?package=MintPlayer.Spark.Authorization&version=11.0.0-preview.105&prerelease
#tool nuget:?package=MintPlayer.Spark.Authorization&version=11.0.0-preview.105&prerelease
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/capabilitiesreports it assignInIdentifiers(["email"],["userName"]or both; empty underLocalCredentials = 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/loginpage.- A value allowing neither is refused at startup unless
LocalCredentialsisDisabled, where there is no password sign-in to configure. forgotPassword/resetPasswordstay 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 ownUserManagercalls. Error codeUserNameContainsAt. It also keeps the two sign-in lookups from colliding.POST /registerrequires auserName(SparkRegisterRequest:email,password,userName); a missing or blank one is a 400. ng-spark/auth's register page asks for it, andSparkClient.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 getsuser-and six hex digits instead. - Existing accounts whose user name contains
@get such a generated handle once, through the package migrationM_202610051200_UserNamesAreNotEmails(it also rewritesNormalizedUserName). Their owners can choose another on the account page (POST /manage/profilewithuserName).
Account mail and links (#460 D6, D16)
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(orSparkAuthenticationOptions.Links.PublicBaseUrl) outside Development. Without it no link-bearing mail is sent (logged as an error) rather than deriving a link from the request'sHostheader, 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-opIEmailSender<TUser>with one that queues the shipped MJML templatesSparkAuth/ConfirmEmail,SparkAuth/PasswordResetandSparkAuth/LinkConfirmation(English + Dutch; override one withTemplates/Mail/SparkAuth/{Name}[.{culture}].mjmlin the app). Each mail isSensitive(encrypted in the queue, scrubbed when done), expires shortly before its token and is written inSparkUser.PreferredCulture(null →Spark:Mail:DefaultCulture). AnIEmailSender<TUser>the app registers itself is kept. - Registration needs mail (D6).
LocalCredentials = Fullwhose mail would be discarded — Identity's no-op, or Spark's sender without MailManager — refuses startup. Opt out withAllowUnconfirmedRegistration = true/Spark:Auth:AllowUnconfirmedRegistration=true. SparkAuthenticationOptions.RequireConfirmedEmail(defaultfalse) refuses sign-in to unconfirmed accounts. A completed password reset confirms the email, andforgotPasswordmails unconfirmed addresses, so older unconfirmed accounts can always get in.- Email change is opt-in.
SparkAuthenticationOptions.EmailChangedefaults toSparkEmailChange.Disabled:POST manage/info { newEmail }answers 400EmailChangeDisabled(the whole request, password half included), no mail is sent, and a change link already mailed no longer confirms.SparkEmailChange.Enabledturns it on;/spark/auth/capabilitiesreportsemailChange, 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;
DisplayNameis optional and labels the button (/spark/auth/capabilitiesserves it). EachOpenIdConnect:<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(collectionAgentGrants): 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/mcpcall (cached 5 seconds, invalidated by an edit), so an edit or a revocation applies to the agent's next call.LastUsedAtis 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 schemePersonalAccessTokens.Schemeis not a Spark web credential: a token authenticates/mcpand 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
subis the local user id) the user'sRolesand 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):GETthe 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'sagentPermissionsis refused withagent_permission_not_offered. - Extension point: register an
ISparkConnectedApplicationSourceto 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())mountsaccount/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+jwtunlessRequireAccessTokenType = false;UseIntrospectionfor opaque tokens), under its own scheme, which is not a Spark web credential: the token opens/mcponly. The audience is always the MCP canonical URL. A sender-constrained token (cnf) is refused. - Mapping: only through a login of
loginProviderwhose key equals the token'sSubjectClaim(sub;oidfor Entra), via Identity'sFindByLoginAsync. Never by email. Unmapped → 401.ISparkCurrentUser.Idis 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 fromazp, elseclient_id): everything exposed, read only. Changes need the user's toggle on the account page andWriteScope(mcp:write) in the token. Revoking it keeps it as disconnected (statusdisconnected), 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
loginProvideris 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:
- Check that the SPA declares
@mintplayer/ng-spark(it ships the@mintplayer/ng-spark/auth/*entry points) — warningSPARK030when$(SpaRoot)package.jsondoes not. The build never runsnpm(it used to, in the wrong directory for a root-levelnode_modules, writing an unpinned range); add the dependency yourself with the major matching your Angular major. - Generate
spark-auth.setup.tsonce — 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_infoon cancel), redirects toerrorUrlwithsparkExternalLogin=<code>appended — or toreturnUrlwhen noerrorUrlwas 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(sparkAuthenticatedGuardis 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 withwithAccount({ canActivate: [...] }), change a path withwithAccount({ profile: 'me' }), or leave pages out withexclude: ['externalLogins'].confirm-emailmust matchSpark:Auth:Links:ConfirmEmailPath, which is where confirmation mails link to. No path starts with a parameter, so the pages neither shadowsparkRoutes()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 anISparkProfileContributor<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 403reauthentication_requiredis explained on the page. - Mode restrictions. Under
SparkLocalCredentials.Disabled,manage/passwordandmanage/infoare not mapped. ExcludechangePasswordthere (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, thenUseSpark()/MapSpark()../apps/Fleet/Fleet/Program.cs-- the same thing throughAddSparkFull/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 theQuery-without-Readshowcase../../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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- Microsoft.AspNetCore.Authentication.Facebook (>= 11.0.0-rc.1.26425.128)
- Microsoft.AspNetCore.Authentication.Google (>= 11.0.0-rc.1.26425.128)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 11.0.0-rc.1.26425.128)
- Microsoft.AspNetCore.Authentication.MicrosoftAccount (>= 11.0.0-rc.1.26425.128)
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 11.0.0-rc.1.26425.128)
- MintPlayer.AspNetCore.Endpoints (>= 11.4.1-rc.0)
- MintPlayer.SourceGenerators.Attributes (>= 13.0.0)
- MintPlayer.Spark.Abstractions (>= 11.0.0-preview.105)
- MintPlayer.Spark.Authorization.Abstractions (>= 11.0.0-preview.103)
- MintPlayer.Spark.MailManager.Abstractions (>= 11.0.0-preview.95)
- MintPlayer.Spark.Migrations (>= 11.0.0-preview.96)
- QRCoder (>= 1.8.0)
- RavenDB.Client (>= 7.2.6)
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 |