Skip to content

Commit 9b8905e

Browse files
authored
Merge pull request #3 from abdebek/dev
feat(templates): tighten auth layering + docs/version alignment
2 parents 4d83cc8 + 1baff10 commit 9b8905e

23 files changed

Lines changed: 600 additions & 252 deletions

.github/workflows/nuget.yml

Lines changed: 6 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ on:
55
tags:
66
- 'v*'
77
branches:
8+
- dev
89
- main
910
- master
1011
pull_request:
1112
branches:
13+
- dev
1214
- main
1315
- master
1416
workflow_dispatch:
@@ -93,100 +95,8 @@ jobs:
9395

9496
validate-templates:
9597
needs: build-and-test
96-
runs-on: ubuntu-latest
97-
strategy:
98-
matrix:
99-
include:
100-
- name: multi-default
101-
args: "-n McaDefault"
102-
project: "McaDefault"
103-
buildPath: "McaDefault.sln"
104-
buildTests: "false"
105-
- name: multi-all-sql
106-
args: "-n McaAllSql --all --db sqlserver"
107-
project: "McaAllSql"
108-
buildPath: "McaAllSql.sln"
109-
buildTests: "true"
110-
- name: single-default
111-
args: "-n McaSingle --single-project"
112-
project: "McaSingle"
113-
buildPath: "McaSingle.csproj"
114-
buildTests: "false"
115-
- name: single-all-pg
116-
args: "-n McaSingleAllPg --single-project --all --db postgres"
117-
project: "McaSingleAllPg"
118-
buildPath: "McaSingleAllPg.csproj"
119-
buildTests: "true"
120-
- name: multi-tests-pg
121-
args: "-n McaTestsPg --tests --db postgres"
122-
project: "McaTestsPg"
123-
buildPath: "McaTestsPg.sln"
124-
buildTests: "true"
125-
126-
steps:
127-
- name: Checkout code
128-
uses: actions/checkout@v4
129-
130-
- name: Setup .NET 10.0
131-
uses: actions/setup-dotnet@v4
132-
with:
133-
dotnet-version: '10.0.x'
134-
135-
- name: Pack libraries for local validation
136-
run: |
137-
mkdir -p ./local-packages
138-
dotnet pack src/MinimalCleanArch/MinimalCleanArch.csproj -c Release -o ./local-packages
139-
dotnet pack src/MinimalCleanArch.DataAccess/MinimalCleanArch.DataAccess.csproj -c Release -o ./local-packages
140-
dotnet pack src/MinimalCleanArch.Extensions/MinimalCleanArch.Extensions.csproj -c Release -o ./local-packages
141-
dotnet pack src/MinimalCleanArch.Validation/MinimalCleanArch.Validation.csproj -c Release -o ./local-packages
142-
dotnet pack src/MinimalCleanArch.Security/MinimalCleanArch.Security.csproj -c Release -o ./local-packages
143-
dotnet pack src/MinimalCleanArch.Messaging/MinimalCleanArch.Messaging.csproj -c Release -o ./local-packages
144-
dotnet pack src/MinimalCleanArch.Audit/MinimalCleanArch.Audit.csproj -c Release -o ./local-packages
145-
146-
- name: Resolve MCA package version
147-
id: mca-version
148-
shell: bash
149-
run: |
150-
version=$(grep -o '<PackageVersion>[^<]*</PackageVersion>' src/Directory.Build.props | sed 's/<[^>]*>//g')
151-
echo "version=$version" >> "$GITHUB_OUTPUT"
152-
echo "MCA package version: $version"
153-
154-
- name: Configure Local NuGet Source
155-
run: |
156-
dotnet nuget add source ${{ github.workspace }}/local-packages -n LocalCI
157-
158-
- name: Install template from repo
159-
run: dotnet new install templates/mca --force
160-
161-
- name: Scaffold and build ${{ matrix.name }}
162-
shell: bash
163-
run: |
164-
set -euo pipefail
165-
ROOT="$(mktemp -d)"
166-
SCENARIO_DIR="$ROOT/${{ matrix.name }}"
167-
mkdir -p "$SCENARIO_DIR"
168-
pushd "$SCENARIO_DIR"
169-
170-
echo "Scaffolding: dotnet new mca ${{ matrix.args }}"
171-
dotnet new mca ${{ matrix.args }} --mcaVersion "${{ steps.mca-version.outputs.version }}"
172-
173-
PROJECT_ROOT="$SCENARIO_DIR/${{ matrix.project }}"
174-
pushd "$PROJECT_ROOT"
175-
176-
echo "Restoring..."
177-
dotnet restore
178-
179-
echo "Building ${{ matrix.buildPath }}..."
180-
dotnet build ${{ matrix.buildPath }} --configuration Release --nologo
181-
182-
if [ "${{ matrix.buildTests }}" = "true" ] && [ -d tests ]; then
183-
echo "Building tests..."
184-
find tests -name "*.csproj" -print0 | xargs -0 -I{} dotnet build "{}" --configuration Release --nologo
185-
fi
186-
187-
popd
188-
popd
189-
echo "Artifacts at $SCENARIO_DIR"
98+
uses: ./.github/workflows/validate-templates.yml
99+
secrets: inherit
190100

191101
pack:
192102
needs: [build-and-test, validate-templates]
@@ -346,7 +256,7 @@ jobs:
346256
if-no-files-found: ignore
347257

348258
publish:
349-
needs: pack
259+
needs: [pack, security-scan]
350260
runs-on: ubuntu-latest
351261
if: |
352262
(startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') &&
@@ -508,7 +418,7 @@ jobs:
508418

509419
security-scan:
510420
runs-on: ubuntu-latest
511-
if: github.event_name == 'pull_request'
421+
if: startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true'
512422

513423
steps:
514424
- name: Checkout code

.github/workflows/validate-templates.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
name: Validate Templates
22

33
on:
4+
workflow_call:
45
workflow_dispatch:
5-
pull_request:
6-
paths:
7-
- "templates/**"
8-
- ".github/workflows/validate-templates.yml"
9-
- "temp/**"
106

117
jobs:
128
validate:

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Use the auth walkthrough in [`templates/README.md`](templates/README.md).
4848

4949
## Documentation Map
5050
- Templates: [`templates/README.md`](templates/README.md)
51+
- Generated app architecture: [`templates/README.md#architecture-overview`](templates/README.md#architecture-overview)
5152
- Sample app: [`samples/MinimalCleanArch.Sample/README.md`](samples/MinimalCleanArch.Sample/README.md)
5253
- Core: [`src/MinimalCleanArch/README.md`](src/MinimalCleanArch/README.md)
5354
- DataAccess: [`src/MinimalCleanArch.DataAccess/README.md`](src/MinimalCleanArch.DataAccess/README.md)

templates/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,32 @@ MyApp/
244244
|- Endpoints/
245245
```
246246

247+
## Architecture Overview
248+
249+
Generated apps follow Clean Architecture with DDD-style domain modeling and CQRS-style handlers.
250+
251+
### Layer Responsibilities
252+
253+
- `Domain`: entities, value objects, domain events, repository contracts, core rules. No infrastructure dependencies.
254+
- `Application`: commands/queries, handlers, and orchestration of use-cases using domain contracts.
255+
- `Infrastructure`: EF Core, Identity/OpenIddict wiring, email providers, repository implementations, external integrations.
256+
- `Api` (multi-project) or `Endpoints` + `Program.cs` (single-project): HTTP transport, endpoint mapping, auth policies, middleware.
257+
258+
### Dependency Direction
259+
260+
- `Domain` depends on nothing else.
261+
- `Application` depends on `Domain`.
262+
- `Infrastructure` depends on `Application` and `Domain`.
263+
- `Api` depends on all required layers and composes the app at startup.
264+
265+
### Typical Request Flow
266+
267+
1. Endpoint receives HTTP request and maps payload to command/query.
268+
2. Application handler executes use-case through domain contracts/repositories.
269+
3. Domain entities enforce invariants and may raise domain events.
270+
4. Infrastructure persists state and publishes/handles events.
271+
5. Result is mapped to consistent HTTP responses/ProblemDetails.
272+
247273
## Auth and Security Notes
248274

249275
- `--auth` automatically enables `--security`.

templates/mca/.template.config/template.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
"mcaVersion": {
129129
"type": "parameter",
130130
"datatype": "string",
131-
"defaultValue": "0.1.13-preview",
131+
"defaultValue": "0.1.14",
132132
"description": "MinimalCleanArch package version to reference",
133133
"replaces": "__MCA_VERSION__"
134134
},
@@ -278,7 +278,7 @@
278278
"modifiers": [
279279
{ "condition": "(!UseMessaging)", "exclude": ["**/Events/**", "**/Handlers/*EventHandler.cs"] },
280280
{ "condition": "(!UseValidation)", "exclude": ["**/Validation/**"] },
281-
{ "condition": "(!UseAuth)", "exclude": ["**/Identity/**", "**/Endpoints/*AuthEndpoints.cs", "**/Endpoints/OpenIddictEndpoints.cs", "**/Endpoints/OAuthEndpoints.cs", "**/Configuration/IdentityServiceExtensions.cs", "**/Configuration/OpenIddict*.cs", "**/Configuration/ConfigureOpenIddictServerOptions.cs", "**/Configuration/CertificateLoader.cs", "**/Configuration/EmailSettings.cs", "**/Services/CustomClaimsPrincipalFactory.cs", "**/Services/PkceService.cs", "**/Services/OpenIddictTokenService.cs", "**/Services/*Email*.cs", "**/Providers/AuthEmailTemplateProvider.cs", "**/Providers/EmailLayouts.cs", "**/Interfaces/ITokenService.cs", "**/Interfaces/IEmailService.cs", "**/Commands/Auth*.cs", "**/Handlers/*PasswordHandler.cs", "**/Handlers/*EmailHandler.cs", "**/Handlers/Register*Handler.cs", "**/Handlers/AuthEventHandler.cs", "**/Events/UserRegisteredEvent.cs", "**/Constants/Roles.cs", "**/Entities/ApplicationUser.cs"] },
281+
{ "condition": "(!UseAuth)", "exclude": ["**/Identity/**", "**/Endpoints/*AuthEndpoints.cs", "**/Endpoints/OpenIddictEndpoints.cs", "**/Endpoints/OAuthEndpoints.cs", "**/Configuration/IdentityServiceExtensions.cs", "**/Configuration/OpenIddict*.cs", "**/Configuration/ConfigureOpenIddictServerOptions.cs", "**/Configuration/CertificateLoader.cs", "**/Configuration/EmailSettings.cs", "**/Services/CustomClaimsPrincipalFactory.cs", "**/Services/PkceService.cs", "**/Services/OpenIddictTokenService.cs", "**/Services/*Email*.cs", "**/Services/*Auth*.cs", "**/Providers/AuthEmailTemplateProvider.cs", "**/Providers/EmailLayouts.cs", "**/Interfaces/ITokenService.cs", "**/Interfaces/IEmailService.cs", "**/Interfaces/I*Auth*.cs", "**/Commands/Auth*.cs", "**/Handlers/*Auth*Handler.cs", "**/Handlers/*PasswordHandler.cs", "**/Handlers/*EmailHandler.cs", "**/Handlers/Register*Handler.cs", "**/Events/UserRegisteredEvent.cs", "**/Constants/Roles.cs", "**/Entities/ApplicationUser.cs"] },
282282
{ "exclude": ["**/bin/**", "**/obj/**", "**/Configuration/AuthSettings.cs"] }
283283
]
284284
},
@@ -290,7 +290,7 @@
290290
{ "condition": "(!UseDocker)", "exclude": ["Dockerfile", "docker-compose.yml", ".dockerignore"] },
291291
{ "condition": "(!UseMessaging)", "exclude": ["**/Events/**", "**/Handlers/*EventHandler.cs"] },
292292
{ "condition": "(!UseValidation)", "exclude": ["**/Validation/**"] },
293-
{ "condition": "(!UseAuth)", "exclude": ["**/Identity/**", "**/Endpoints/*AuthEndpoints.cs", "**/Endpoints/OpenIddictEndpoints.cs", "**/Endpoints/OAuthEndpoints.cs", "**/Configuration/IdentityServiceExtensions.cs", "**/Configuration/OpenIddict*.cs", "**/Configuration/ConfigureOpenIddictServerOptions.cs", "**/Configuration/CertificateLoader.cs", "**/Configuration/EmailSettings.cs", "**/Services/CustomClaimsPrincipalFactory.cs", "**/Services/PkceService.cs", "**/Services/OpenIddictTokenService.cs", "**/Services/*Email*.cs", "**/Providers/AuthEmailTemplateProvider.cs", "**/Providers/EmailLayouts.cs", "**/Interfaces/ITokenService.cs", "**/Interfaces/IEmailService.cs", "**/Commands/Auth*.cs", "**/Handlers/*PasswordHandler.cs", "**/Handlers/*EmailHandler.cs", "**/Handlers/Register*Handler.cs", "**/Handlers/AuthEventHandler.cs", "**/Events/UserRegisteredEvent.cs", "**/Constants/Roles.cs", "**/Entities/ApplicationUser.cs"] },
293+
{ "condition": "(!UseAuth)", "exclude": ["**/Identity/**", "**/Endpoints/*AuthEndpoints.cs", "**/Endpoints/OpenIddictEndpoints.cs", "**/Endpoints/OAuthEndpoints.cs", "**/Configuration/IdentityServiceExtensions.cs", "**/Configuration/OpenIddict*.cs", "**/Configuration/ConfigureOpenIddictServerOptions.cs", "**/Configuration/CertificateLoader.cs", "**/Configuration/EmailSettings.cs", "**/Services/CustomClaimsPrincipalFactory.cs", "**/Services/PkceService.cs", "**/Services/OpenIddictTokenService.cs", "**/Services/*Email*.cs", "**/Services/*Auth*.cs", "**/Providers/AuthEmailTemplateProvider.cs", "**/Providers/EmailLayouts.cs", "**/Interfaces/ITokenService.cs", "**/Interfaces/IEmailService.cs", "**/Interfaces/I*Auth*.cs", "**/Commands/Auth*.cs", "**/Handlers/*Auth*Handler.cs", "**/Handlers/*PasswordHandler.cs", "**/Handlers/*EmailHandler.cs", "**/Handlers/Register*Handler.cs", "**/Events/UserRegisteredEvent.cs", "**/Constants/Roles.cs", "**/Entities/ApplicationUser.cs"] },
294294
{ "exclude": ["**/bin/**", "**/obj/**", "**/Configuration/AuthSettings.cs"] }
295295
]
296296
}

templates/mca/multi/MCA.Api/Endpoints/AuthEndpoints.cs

Lines changed: 53 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
#if (UseAuth)
22
using MCA.Application.Commands;
33
using MCA.Application.Handlers;
4-
using MCA.Domain.Entities;
5-
using Microsoft.AspNetCore.Identity;
64
using Microsoft.AspNetCore.Mvc;
75
using MinimalCleanArch.Domain.Common;
86
using System.Security.Claims;
@@ -161,32 +159,32 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder app, bool isDevel
161159

162160
// SSR login — signs in via cookie for the authorization code flow
163161
auth.MapPost("/login", async (
164-
HttpContext httpContext,
165162
[FromBody] LoginRequest request,
166-
[FromServices] SignInManager<ApplicationUser> signInManager,
167-
[FromServices] UserManager<ApplicationUser> userManager) =>
163+
#if (UseMessaging)
164+
IMessageBus bus,
165+
CancellationToken cancellationToken) =>
166+
#else
167+
[FromServices] AuthLoginHandler handler,
168+
CancellationToken cancellationToken) =>
169+
#endif
168170
{
169-
var user = await userManager.FindByEmailAsync(request.Email)
170-
?? await userManager.FindByNameAsync(request.Email);
171-
172-
if (user == null)
173-
return Results.Unauthorized();
174-
175-
var result = await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
171+
var command = new AuthLoginCommand(request.Email, request.Password, request.ReturnUrl);
172+
#if (UseMessaging)
173+
var result = await bus.InvokeAsync<Result<AuthLoginResult>>(command, cancellationToken);
174+
#else
175+
var result = await handler.Handle(command, cancellationToken);
176+
#endif
177+
if (result.IsSuccess)
178+
{
179+
return string.IsNullOrWhiteSpace(result.Value.RedirectUrl)
180+
? Results.Ok(new { message = "Signed in" })
181+
: Results.Ok(new { message = "Signed in", redirectUrl = result.Value.RedirectUrl });
182+
}
176183

177-
if (result.IsLockedOut)
184+
if (result.Error.Code == "LOCKED_OUT")
178185
return Results.Problem("Account is locked out.", statusCode: 423);
179186

180-
if (!result.Succeeded)
181-
return Results.Unauthorized();
182-
183-
await signInManager.SignInAsync(user, isPersistent: false);
184-
185-
var returnUrl = request.ReturnUrl;
186-
if (!string.IsNullOrEmpty(returnUrl) && Uri.IsWellFormedUriString(returnUrl, UriKind.Relative))
187-
return Results.Ok(new { message = "Signed in", redirectUrl = returnUrl });
188-
189-
return Results.Ok(new { message = "Signed in" });
187+
return Results.Unauthorized();
190188
})
191189
.AllowAnonymous()
192190
#if (UseRateLimiting)
@@ -196,10 +194,19 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder app, bool isDevel
196194
.WithSummary("Sign in via cookie (for SSR/authorization code flow)");
197195

198196
auth.MapPost("/logout", async (
199-
HttpContext httpContext,
200-
[FromServices] SignInManager<ApplicationUser> signInManager) =>
197+
#if (UseMessaging)
198+
IMessageBus bus,
199+
CancellationToken cancellationToken) =>
200+
#else
201+
[FromServices] AuthLogoutHandler handler,
202+
CancellationToken cancellationToken) =>
203+
#endif
201204
{
202-
await signInManager.SignOutAsync();
205+
#if (UseMessaging)
206+
await bus.InvokeAsync<Result>(new AuthLogoutCommand(), cancellationToken);
207+
#else
208+
await handler.Handle(new AuthLogoutCommand(), cancellationToken);
209+
#endif
203210
return Results.Ok(new { message = "Signed out" });
204211
})
205212
.AllowAnonymous()
@@ -238,36 +245,35 @@ public static void MapAuthEndpoints(this IEndpointRouteBuilder app, bool isDevel
238245
// SSR login form handler — POST /auth/login (development only)
239246
app.MapPost("/auth/login", async (
240247
HttpContext context,
241-
[FromServices] SignInManager<ApplicationUser> signInManager,
242-
[FromServices] UserManager<ApplicationUser> userManager) =>
248+
#if (UseMessaging)
249+
IMessageBus bus,
250+
CancellationToken cancellationToken) =>
251+
#else
252+
[FromServices] AuthLoginHandler handler,
253+
CancellationToken cancellationToken) =>
254+
#endif
243255
{
244256
var form = await context.Request.ReadFormAsync();
245257
var email = form["email"].ToString();
246258
var password = form["password"].ToString();
247259
var returnUrl = form["returnUrl"].ToString();
248260

249-
var user = await userManager.FindByEmailAsync(email)
250-
?? await userManager.FindByNameAsync(email);
251-
252-
if (user == null)
253-
return Results.Redirect(
254-
$"/auth/login?error=Invalid+credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
255-
256-
var result = await signInManager.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true);
261+
var command = new AuthLoginCommand(email, password, returnUrl);
262+
#if (UseMessaging)
263+
var result = await bus.InvokeAsync<Result<AuthLoginResult>>(command, cancellationToken);
264+
#else
265+
var result = await handler.Handle(command, cancellationToken);
266+
#endif
267+
if (result.IsSuccess)
268+
{
269+
return Results.Redirect(result.Value.RedirectUrl ?? "/");
270+
}
257271

258-
if (result.IsLockedOut)
272+
if (result.Error.Code == "LOCKED_OUT")
259273
return Results.Redirect("/auth/login?error=Account+is+locked+out");
260274

261-
if (!result.Succeeded)
262-
return Results.Redirect(
263-
$"/auth/login?error=Invalid+credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
264-
265-
await signInManager.SignInAsync(user, isPersistent: false);
266-
267-
if (!string.IsNullOrEmpty(returnUrl) && Uri.IsWellFormedUriString(returnUrl, UriKind.Relative))
268-
return Results.Redirect(returnUrl);
269-
270-
return Results.Redirect("/");
275+
return Results.Redirect(
276+
$"/auth/login?error=Invalid+credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
271277
})
272278
.AllowAnonymous()
273279
#if (UseRateLimiting)

0 commit comments

Comments
 (0)