Skip to content
32 changes: 32 additions & 0 deletions framework/SimpleModule.Core/Authorization/ICurrentUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using SimpleModule.Core.Extensions;

namespace SimpleModule.Core.Authorization;

/// <summary>
/// Ambient access to the current request's principal, without forcing every
/// caller to inject IHttpContextAccessor and walk claims by hand.
/// </summary>
public interface ICurrentUser
{
string? Id { get; }
bool IsAuthenticated { get; }
bool IsInRole(string role);
bool HasPermission(string permission);
ClaimsPrincipal? Principal { get; }
}

public sealed class HttpContextCurrentUser(IHttpContextAccessor httpContextAccessor) : ICurrentUser
{
public ClaimsPrincipal? Principal => httpContextAccessor.HttpContext?.User;

public string? Id => Principal?.GetUserId();

public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true;

public bool IsInRole(string role) => Principal?.IsInRole(role) == true;

public bool HasPermission(string permission) =>
Principal is not null && Principal.HasPermission(permission);
}
24 changes: 22 additions & 2 deletions framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Http;
Expand All @@ -8,6 +9,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using SimpleModule.Core.Authorization;
using SimpleModule.Core.Constants;
using SimpleModule.Core.Exceptions;
using SimpleModule.Core.Health;
Expand Down Expand Up @@ -85,6 +87,7 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(

// Required by EntityInterceptor to access the current HTTP context
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpContextCurrentUser>();

// Entity framework interceptors for automatic entity field population
builder.Services.AddScoped<ISaveChangesInterceptor, EntityInterceptor>();
Expand All @@ -95,7 +98,16 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
// (e.g., OpenIddict registers SmartAuth policy scheme).
// Register a baseline so the middleware pipeline works even without an auth module.
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
// Authenticated-by-default. Endpoints that genuinely need to be public
// (login, health probes, error/404 fallbacks) opt out with .AllowAnonymous().
// Without a fallback policy, plain app.MapGet(...) outside a module group
// is silently public — the wrong default for a business app.
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
builder.Services.AddAntiforgery();

// Register default IPublicMenuProvider if no module provides one
Expand Down Expand Up @@ -252,7 +264,11 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app)

app.UseInertia();
UseStaticFileCaching(app);
app.MapStaticAssets();
// MapStaticAssets registers endpoints, which would otherwise inherit the
// RequireAuthenticatedUser fallback policy — that breaks JS bundle / CSS /
// favicon loads on anonymous pages like /Identity/Account/Login. Static
// files are intentionally public.
app.MapStaticAssets().AllowAnonymous();

app.UseAuthentication();
app.UseAuthorization();
Expand All @@ -272,6 +288,10 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app)

if (options.EnableHealthChecks)
{
// Health probes are intentionally anonymous so kubelet / load balancers
// can hit them without credentials. The framework owns these — do not
// remove the .AllowAnonymous() calls when sweeping anonymous routes
// out of an application.
app.MapHealthChecks(
RouteConstants.HealthLive,
new HealthCheckOptions { Predicate = _ => false }
Expand Down
25 changes: 24 additions & 1 deletion modules/Admin/src/SimpleModule.Admin/AdminModule.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SimpleModule.Admin.Contracts;
using SimpleModule.Core;
using SimpleModule.Core.Menu;
using SimpleModule.OpenIddict.Contracts;

namespace SimpleModule.Admin;

Expand All @@ -14,6 +16,27 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddScoped<IAdminContracts, AdminService>();
}

public void ConfigureMiddleware(IApplicationBuilder app)
{
// Admin's session-management endpoints inject IOpenIddictSessionContracts, whose
// implementation lives in SimpleModule.OpenIddict (not its Contracts assembly).
// Without that module installed, minimal-API parameter binding falls through to
// body-binding and the host crashes at MapModuleEndpoints with the misleading
// "Body was inferred but the method does not allow inferred body parameters."
// Fail fast here with a directive a human can act on.
// Use IServiceProviderIsService so we don't actually instantiate the (scoped)
// service from the root provider.
var probe = app.ApplicationServices.GetRequiredService<IServiceProviderIsService>();
if (!probe.IsService(typeof(IOpenIddictSessionContracts)))
{
throw new InvalidOperationException(
"SimpleModule.Admin requires SimpleModule.OpenIddict to be installed. "
+ "Add a reference to the SimpleModule.OpenIddict package (or project) "
+ "so IOpenIddictSessionContracts can be resolved by Admin's session endpoints."
);
}
}

public void ConfigureMenu(IMenuBuilder menus)
{
menus.Add(
Expand Down
21 changes: 21 additions & 0 deletions modules/Email/src/SimpleModule.Email/EmailModule.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using FluentValidation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -21,6 +22,26 @@ namespace SimpleModule.Email;
)]
public class EmailModule : IModule, IModuleServices
{
public void ConfigureMiddleware(IApplicationBuilder app)
{
// EmailService and the job-registration hosted service inject IBackgroundJobs,
// whose implementation lives in SimpleModule.BackgroundJobs (not its Contracts
// assembly). Without that module installed, the host crashes at first resolution
// with "Unable to resolve service for type 'IBackgroundJobs'" — fail fast here
// with a directive message instead.
// Use IServiceProviderIsService so we don't actually instantiate the (scoped)
// service from the root provider.
var probe = app.ApplicationServices.GetRequiredService<IServiceProviderIsService>();
if (!probe.IsService(typeof(IBackgroundJobs)))
{
throw new InvalidOperationException(
"SimpleModule.Email requires SimpleModule.BackgroundJobs to be installed. "
+ "Add a reference to the SimpleModule.BackgroundJobs package (or project) "
+ "so IBackgroundJobs can be resolved by Email's send/retry jobs."
);
}
}

public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddModuleDbContext<EmailDbContext>(configuration, EmailConstants.ModuleName);
Expand Down
36 changes: 36 additions & 0 deletions modules/Users/src/SimpleModule.Users/UsersModule.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -37,6 +38,41 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
options.LoginPath = "/Identity/Account/Login";
options.LogoutPath = "/Identity/Account/Logout";
options.AccessDeniedPath = "/Identity/Account/AccessDenied";

// /api/* clients (JS, CLI, integration tests) want a bare 401 — not a
// 302 to /Identity/Account/Login. The default cookie handler sniffs the
// Accept header but inconsistently, leading to 401 for some routes and
// 302 for others. Force 401 for any unauthenticated /api request.
options.Events.OnRedirectToLogin = context =>
{
if (
context.Request.Path.StartsWithSegments(
"/api",
StringComparison.OrdinalIgnoreCase
)
)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
if (
context.Request.Path.StartsWithSegments(
"/api",
StringComparison.OrdinalIgnoreCase
)
)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
});

// Bridge UsersModuleOptions into ASP.NET Identity options
Expand Down
33 changes: 28 additions & 5 deletions packages/SimpleModule.Client/src/resolve-page.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
export async function resolvePage(name: string) {
const moduleName = name.split('/')[0];
const assemblyName = `SimpleModule.${moduleName}`;
const cacheBuster = (document.querySelector('meta[name="cache-buster"]') as HTMLMetaElement)
?.content;
const suffix = cacheBuster ? `?v=${cacheBuster}` : '';

const mod = await import(
/* @vite-ignore */
`/_content/${assemblyName}/${assemblyName}.pages.js${suffix}`
);
// Downstream apps frequently ship modules under a bare assembly name
// (e.g. "Customers", "Invoices") rather than the framework's "SimpleModule.X"
// convention. Try the bare name first, then fall back to the prefixed form
// so framework modules continue to resolve.
const candidates = [moduleName, `SimpleModule.${moduleName}`];
// biome-ignore lint/suspicious/noExplicitAny: matches existing dynamic-import shape
let mod: any;
let assemblyName = candidates[0];
let lastError: unknown;
for (const candidate of candidates) {
try {
mod = await import(
/* @vite-ignore */
`/_content/${candidate}/${candidate}.pages.js${suffix}`
);
assemblyName = candidate;
break;
} catch (err) {
lastError = err;
}
}

if (!mod) {
throw new Error(
`Could not load pages bundle for module "${moduleName}". ` +
`Tried ${candidates.join(', ')}. Last error: ${String(lastError)}`,
);
}

if (!mod.pages) {
throw new Error(
Expand Down
19 changes: 19 additions & 0 deletions packages/SimpleModule.Client/src/use-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,30 @@ interface SharedProps {
* <p>{t(ProductsKeys.Manage.DeleteConfirm, { name: product.name })}</p>
* ```
*/
const warnedNamespaces = new Set<string>();

export function useTranslation(namespace: string): TranslationResult {
const { props } = usePage<SharedProps>();
const locale = props.locale ?? 'en';
const translations = props.translations ?? {};

// If translations were never populated, the SimpleModule.Localization module
// is probably not installed (its middleware is what writes props.translations).
// Without it, every t(...) call falls back to the bare key and the UI renders
// raw identifiers like "Users.Title". Warn loudly in dev once per namespace.
if (
process.env.NODE_ENV !== 'production' &&
props.translations === undefined &&
!warnedNamespaces.has(namespace)
) {
warnedNamespaces.add(namespace);
console.warn(
`[useTranslation] Module "${namespace}" is asking for translations but ` +
'Inertia shared data has no "translations" key. Is SimpleModule.Localization installed? ' +
'Without it, translation keys will render as-is.',
);
}

const t = useMemo(() => {
const prefix = `${namespace}.`;

Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/tests/smoke/filestorage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ test.describe('FileStorage smoke', () => {
expect(response?.url()).toContain('/Account/Login');
});

test('API endpoint returns 302', async ({ request }) => {
test('API endpoint returns 401', async ({ request }) => {
const response = await request.get('/api/files', {
maxRedirects: 0,
});
expect(response.status()).toBe(302);
expect(response.status()).toBe(401);
});
});
});
Loading