.NET SDK
The client SDK and the ASP.NET Core integration.
ErtisAuth comes with two .NET libraries:
| Package | What it does |
|---|---|
ErtisAuth.Sdk | A typed client for the ErtisAuth REST API: sign-in, tokens, users, roles, applications, hooks… |
ErtisAuth.Sdk.AspNetCore | Protects your own ASP.NET Core APIs with ErtisAuth tokens and permissions, using attributes. Includes ErtisAuth.Sdk. |
Use ErtisAuth.Sdk.AspNetCore for an ASP.NET Core API whose endpoints ErtisAuth users and applications call. Use ErtisAuth.Sdk alone to call ErtisAuth from a worker, a console application or any other .NET program. Both target .NET 10.
Quick start#
Protect an ASP.NET Core API in four steps.
1. Add the package:
dotnet add package ErtisAuth.Sdk.AspNetCore2. Add the settings to appsettings.json:
{
"ErtisAuth": {
"BaseUrl": "https://auth.example.com",
"MembershipId": "66f1c0d2a4b5c6d7e8f90123"
}
}3. Register the SDK in Program.cs:
using ErtisAuth.Sdk.AspNetCore.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddErtisAuth();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();4. Protect a controller:
using ErtisAuth.Core.Models.Roles;
using ErtisAuth.Extensions.Authorization.Attributes;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("orders")]
[Authorized]
[RbacResource("orders")]
public class OrdersController : ControllerBase
{
// Requires "orders.read"
[HttpGet]
[RbacAction(Rbac.CrudActions.Read)]
public IActionResult List() => this.Ok();
}Call GET /orders with the access token of an ErtisAuth user (Authorization: Bearer <access_token>) or the Basic token of an application:
| Request | Response |
|---|---|
| Without a token, or with an invalid, expired or revoked token | 401 |
A user or application whose role doesn't allow orders.read | 403 AccessDenied |
A user or application whose role allows orders.read | 200 |
Configuration#
| Setting | Required | Description |
|---|---|---|
BaseUrl | yes | The absolute http or https URL of ErtisAuth, including any path prefix. |
MembershipId | yes | The membership your application belongs to. |
BasicTokenCacheTTL | no | Seconds for which a verified Basic token is cached by your API (ErtisAuth.Sdk.AspNetCore). 0 or omitted disables the cache. |
Where the settings come from#
AddErtisAuth() reads the ErtisAuth section of your application's configuration (builder.Configuration). Every configuration source of the application applies: appsettings.json and appsettings.{Environment}.json, environment variables, user secrets, command line arguments, Azure Key Vault and so on. For example, in Kubernetes:
env:
- name: ErtisAuth__BaseUrl
value: https://auth.example.com
- name: ErtisAuth__MembershipId
value: 66f1c0d2a4b5c6d7e8f90123The settings are validated when the application starts. Invalid settings stop it with a message that lists every problem.
Other ways to give the settings:
| Call | Reads |
|---|---|
AddErtisAuth() | The ErtisAuth section |
AddErtisAuth("Identity") | Another section of the configuration |
AddErtisAuth(builder.Configuration.GetSection("Identity")) | The given section, validated at once |
AddErtisAuth(options => { … }) | Only the given values; no configuration is read |
builder.Services.AddErtisAuth(options =>
{
options.BaseUrl = "https://auth.example.com";
options.MembershipId = "66f1c0d2a4b5c6d7e8f90123";
});AddErtisAuth registers the SDK once: further calls are ignored, whatever their settings.
WebApplication.CreateBuilder or Host.CreateApplicationBuilder) has no application configuration. There, AddErtisAuth() reads appsettings.json, appsettings.{ASPNETCORE_ENVIRONMENT}.json from the output directory, and the environment variables.Protecting endpoints#
Which endpoints are protected#
Mark the controller with [Authorized]: every action of the controller then requires a valid token and the permission described by the rbac attributes. To make an exception for a single action, mark the action:
[Unauthorized]: the action is public.[SelfAuthorized]: the action requires a valid token but checks the permission itself.
The most specific attribute applies: the action's attribute overrides the controller's.
| Controller | Action | The endpoint |
|---|---|---|
[Authorized] | requires a token and the permission | |
[Authorized] | [Unauthorized] | is public |
[Authorized] | [SelfAuthorized] | requires a token; the action checks the permission |
[Unauthorized] | [SelfAuthorized] | requires a token; the action checks the permission |
[SelfAuthorized] | requires a token; the action checks the permission | |
| is not protected at all |
[Authorized] can only be put on a controller; there is no need to repeat it on the actions.
[Authorized] or [SelfAuthorized] the endpoint is public, and its rbac attributes do nothing. The SDK reports this while you build (ERTISAUTH610, see Compile-time checks).Attributes#
The attributes are in the ErtisAuth.Extensions.Authorization.Attributes namespace.
| Attribute | On | Meaning |
|---|---|---|
[Authorized] | controller | A valid token and the permission described by the rbac attributes are required, on every action. |
[SelfAuthorized] | controller or action | A valid token is required; the action checks the permission itself. |
[Unauthorized] | controller or action | Public: no token is needed. |
[RbacResource("orders")] | controller or action | The resource segment of the permission. |
[RbacAction("approve")] or [RbacAction(Rbac.CrudActions.Read)] | action | The action segment. |
[RbacObject("{id}")] | action | The object segment, usually the id from the route. |
[RbacSubject("…")] | action | The subject segment. |
The permission that is checked#
The rbac attributes build a permission expression, subject.resource.action.object, which ErtisAuth checks against the caller's role, their own permissions and the scopes of their token.
| Segment | From | When the attribute is missing |
|---|---|---|
| subject | [RbacSubject] | The caller's id, which is what you want almost always |
| resource | [RbacResource] of the action, otherwise of the controller | ⚠️ The last segment of the route template, e.g. {id} for orders/{id} |
| action | [RbacAction] | ⚠️ *: only permissions that allow every action on the resource pass |
| object | [RbacObject] | * |
Always set [RbacResource] and [RbacAction]. An [RbacResource] on an action overrides the controller's, for example [RbacResource("otp")] on one action of a [RbacResource("users")] controller.
using ErtisAuth.Core.Models.Roles;
using ErtisAuth.Extensions.Authorization.Attributes;
using ErtisAuth.Sdk.AspNetCore.Extensions;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("orders")]
[Authorized]
[RbacResource("orders")]
public class OrdersController : ControllerBase
{
// Requires "orders.read"
[HttpGet]
[RbacAction(Rbac.CrudActions.Read)]
public IActionResult List() { … }
// Requires "orders.read.{id}" (granted by "orders.read" too)
[HttpGet("{id}")]
[RbacAction(Rbac.CrudActions.Read)]
[RbacObject("{id}")]
public IActionResult Get(string id) { … }
// A custom action: requires "orders.approve.{id}"
[HttpPost("{id}/approve")]
[RbacAction("approve")]
[RbacObject("{id}")]
public IActionResult Approve(string id)
{
var utilizer = this.GetUtilizer();
// utilizer.Id, utilizer.Username, utilizer.Role, utilizer.Type (User or Application)…
…
}
// Another resource: requires "invoices.read.{id}"
[HttpGet("{id}/invoice")]
[RbacResource("invoices")]
[RbacAction(Rbac.CrudActions.Read)]
[RbacObject("{id}")]
public IActionResult Invoice(string id) { … }
// Public
[HttpGet("statuses")]
[Unauthorized]
public IActionResult Statuses() { … }
}Endpoints that check the permission themselves#
When the permission depends on data the attributes can't reach, for example a field of the requested record, mark the action [SelfAuthorized]. The SDK authenticates the token, and the action checks the permission with IRoleService.CheckPermissionAsync, using the caller's token:
using ErtisAuth.Core.Models.Identity;
using ErtisAuth.Extensions.Authorization.Attributes;
using ErtisAuth.Sdk.AspNetCore.Extensions;
using ErtisAuth.Sdk.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("documents")]
public class DocumentsController(IDocumentStore documents, IRoleService roleService) : ControllerBase
{
// Requires "documents-{category}.read.{id}", where the category is a field of the document
[HttpGet("{id}")]
[SelfAuthorized]
public async Task<IActionResult> Get(string id, CancellationToken cancellationToken)
{
var document = await documents.GetAsync(id, cancellationToken);
if (document == null)
{
return this.NotFound();
}
var utilizer = this.GetUtilizer()!.Value;
TokenBase token = utilizer.TokenType == SupportedTokenTypes.Basic ? new BasicToken(utilizer.Token!) : BearerToken.CreateTemp(utilizer.Token!);
if (!await roleService.CheckPermissionAsync($"documents-{document.Category}.read.{id}", token, cancellationToken))
{
return this.StatusCode(StatusCodes.Status403Forbidden);
}
return this.Ok(document);
}
}CheckPermissionAsync applies the role, the caller's own permissions and the scopes of their token, like the attributes do. It throws when ErtisAuth can't be reached.
Minimal APIs#
Minimal API endpoints take the same attributes as metadata:
app.MapGet("/orders", () => Results.Ok())
.WithMetadata(new AuthorizedAttribute(), new RbacResourceAttribute("orders"), new RbacActionAttribute(Rbac.CrudActions.Read));
app.MapGet("/profile", () => Results.Ok())
.WithMetadata(new SelfAuthorizedAttribute());The compile-time checks don't cover minimal API endpoints, since their attributes are only known at runtime.
What happens on a request#
For a Bearer token, the SDK:
- calls ErtisAuth's
/whoamito authenticate the token, - calls
/roles/check-permissionwith the expression built from the attributes, which applies the role, the user's own permissions and the token's scopes.
For a Basic token, it reads the application from ErtisAuth with the token itself (which authenticates it) and checks the permission the same way. With BasicTokenCacheTTL, a verified Basic token is cached for that many seconds, which saves the calls but delays the effect of a secret rotation by up to that time.
| Response of your API | When |
|---|---|
401 with an ErtisAuth error body | No token, unsupported token type, or an invalid, expired or revoked token |
403 AccessDenied | The token is valid but the permission is missing |
503 AuthenticationServiceUnavailable | ErtisAuth could not be reached or answered with an error. Clients should retry, not sign the user out. |
Placeholders#
The rbac attribute values can contain placeholders, resolved for each request:
| Placeholder | Source |
|---|---|
{id} or {route::id} | A route value |
{query::id} | A query string parameter |
{header::X-Tenant} | A request header |
{env::REGION} | An environment variable |
[HttpGet("reports")]
[RbacAction(Rbac.CrudActions.Read)]
[RbacObject("{query::reportId}")]
public IActionResult Report([FromQuery] string reportId) { … }Rules that keep the check and the action looking at the same thing:
- a
query,headerorenvplaceholder that can't be resolved denies the request; a route placeholder whose value is missing is kept as written; - a resolved value of
*, or one that contains dots (%2E), whitespace or control characters, denies the request; [RbacSubject]can't use client-controlled sources (query,header).
Compile-time checks#
ErtisAuth.Sdk.AspNetCore includes Roslyn analyzers that report mistakes while you build:
| Diagnostic | Severity | Problem |
|---|---|---|
ERTISAUTH601 | warning | The placeholder is not a route parameter of the action |
ERTISAUTH602 | error | The placeholder names an unknown source (only route, query, header and env exist) |
ERTISAUTH603 | error | [RbacSubject] reads from a client-controlled source |
ERTISAUTH610 | warning | The action has rbac attributes, but neither the action nor its controller has [Authorized] or [SelfAuthorized]: the endpoint is public |
ERTISAUTH611 | warning | [Authorized], [SelfAuthorized] and [Unauthorized] conflict on the same level (the controller with its base classes, or the action) |
ERTISAUTH612 | info | The rbac attributes of a [SelfAuthorized] or [Unauthorized] action are not checked |
ERTISAUTH613 | warning | An action that is not authenticated calls GetUtilizer(), which always returns null there |
The caller#
Inside an action, this.GetUtilizer() returns the caller authenticated by ErtisAuth: a user or an application, with Id, Username, Role, Permissions, Forbidden, MembershipId, Type, Token and TokenType. It returns null on endpoints that are not authenticated ([Unauthorized], or without ErtisAuth attributes); a call in such an action is reported by ERTISAUTH613.
this.GetUnverifiedUtilizer() also works on those endpoints: there, it reads the token of the request without verifying it (no signature, expiry or revocation check for Bearer tokens, no secret check for Basic tokens).
GetUnverifiedUtilizer() for authorization decisions or to access data; use GetUtilizer() on an [Authorized] or [SelfAuthorized] endpoint.Calling the ErtisAuth API#
Register the client alone (without the ASP.NET Core integration) with ErtisAuth.Sdk:
using ErtisAuth.Sdk.Extensions;
builder.Services.AddErtisAuth();It takes the same settings. Then inject the services you need:
| Service | Methods |
|---|---|
IAuthenticationService | GetTokenAsync, RefreshTokenAsync, VerifyTokenAsync, RevokeTokenAsync, MeAsync, WhoAmIAsync |
IUserService | GetAsync, QueryAsync, CreateAsync, UpdateAsync, DeleteAsync, BulkDeleteAsync, GetActiveTokensAsync, GetRevokedTokensAsync |
IPasswordService | ChangePasswordAsync, ResetPasswordAsync, SetPasswordAsync |
IRoleService | CRUD, CheckPermissionAsync, CheckPermissionByRoleAsync |
IApplicationService, IWebhookService, IMailHookService | CRUD |
IMembershipService | GetMembershipAsync, GetMembershipsAsync, QueryMembershipsAsync, CreateMembershipAsync, UpdateMembershipAsync, DeleteMembershipAsync |
IActiveTokensService, IRevokedTokensService | GetAsync, QueryAsync |
IAuthenticationService has the same name as ASP.NET Core's Microsoft.AspNetCore.Authentication.IAuthenticationService; use the full name or an alias if both namespaces are imported.
Tokens#
The resource services take the token to call with as a parameter:
- a
BearerTokenof the signed-in user, for calls on their behalf:BearerToken.CreateTemp(accessToken)wraps a raw access token; - a
BasicTokenof your application, for calls of your backend:new BasicToken($"{applicationId}:{applicationSecret}").
Keep the application secret on the server: in a secret store or an environment variable, never in a browser or a mobile app.
Handling responses#
Every method returns an IResponseResult (or IResponseResult<T>) instead of throwing on HTTP errors:
| Property | Content |
|---|---|
IsSuccess | Whether ErtisAuth answered with a success status |
Data | The result, on success |
StatusCode | The HTTP status of ErtisAuth's answer; null when no answer arrived (e.g. a network error) |
Json | The body of the answer: on an error, ErtisAuth's error JSON |
Exception | The exception, when no answer arrived |
response.IsServiceUnavailable() (namespace ErtisAuth.Sdk.Extensions) tells an outage of ErtisAuth (no answer, or a 5xx status) from a rejection (e.g. 401, 403). Treat them differently: an outage should be retried, a rejection should not.
A sign-in endpoint of your backend that passes ErtisAuth's errors on to the client:
using System.Net;
using ErtisAuth.Extensions.Authorization.Attributes;
using ErtisAuth.Sdk.Extensions;
using Microsoft.AspNetCore.Mvc;
using IErtisAuthAuthenticationService = ErtisAuth.Sdk.Services.Interfaces.IAuthenticationService;
[ApiController]
[Route("account")]
public class AccountController(IErtisAuthAuthenticationService authenticationService) : ControllerBase
{
[HttpPost("sign-in")]
[Unauthorized]
public async Task<IActionResult> SignIn(SignInRequest request, CancellationToken cancellationToken)
{
var response = await authenticationService.GetTokenAsync(
request.Username,
request.Password,
ipAddress: this.HttpContext.Connection.RemoteIpAddress?.ToString(),
userAgent: this.Request.Headers.UserAgent,
cancellationToken: cancellationToken);
if (response.IsSuccess)
{
return this.Ok(response.Data);
}
if (response.IsServiceUnavailable())
{
return this.StatusCode((int)HttpStatusCode.ServiceUnavailable);
}
// e.g. 401 InvalidCredentials, as ErtisAuth answered it
return new ContentResult { StatusCode = (int?)response.StatusCode, Content = response.Json, ContentType = "application/json" };
}
}Common tasks#
Refresh a token pair before the access token expires:
var response = await authenticationService.RefreshTokenAsync(refreshToken, cancellationToken);
if (response.IsSuccess)
{
var tokens = response.Data!; // tokens.AccessToken, tokens.RefreshToken…
}Sign out (on every device with logoutFromAllDevices: true):
await authenticationService.RevokeTokenAsync(accessToken, logoutFromAllDevices: false, cancellationToken);Create a user with the application's token. Custom fields of the user type are properties of a class derived from UserWithPassword:
public class Employee : UserWithPassword
{
[JsonPropertyName("department")]
public string? Department { get; set; }
}
var response = await userService.CreateAsync(new Employee
{
MembershipId = membershipId, // the MembershipId of the SDK settings
Username = "ada",
EmailAddress = "ada@example.com",
FirstName = "Ada",
LastName = "Lovelace",
Password = password,
Role = "support",
UserType = "employee",
Department = "Engineering"
}, appToken, cancellationToken);Reset a password (see Account Recovery): start the reset with the URL of your reset page, then set the new password with the token from the link:
await passwordService.ResetPasswordAsync(emailAddress, "https://app.example.com/reset-password", appToken, cancellationToken);
// On the reset page, with the token of the link
await passwordService.SetPasswordAsync(emailAddress, newPassword, resetToken, appToken, cancellationToken);Query users:
var users = await userService.QueryAsync(appToken, """{ "where": { "is_active": false } }""", limit: 50, sorting: null);GetAsync and QueryAsync have two overloads that differ only in their sorting parameters (sorting, or orderBy and sortDirection). When you don't sort, name one of them (sorting: null); otherwise the call is ambiguous and doesn't compile (CS0121).Applications without ASP.NET Core#
A worker or a console application uses ErtisAuth.Sdk with the .NET generic host, which gives it the same configuration:
using ErtisAuth.Sdk.Extensions;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddErtisAuth();
builder.Services.AddHostedService<InactiveUsersReport>();
builder.Build().Run();using ErtisAuth.Core.Models.Identity;
using ErtisAuth.Sdk.Services.Interfaces;
public class InactiveUsersReport(IUserService userService, IConfiguration configuration, ILogger<InactiveUsersReport> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var appToken = new BasicToken($"{configuration["Report:ApplicationId"]}:{configuration["Report:ApplicationSecret"]}");
var response = await userService.QueryAsync(appToken, """{ "where": { "is_active": false } }""", withCount: true, sorting: null, cancellationToken: stoppingToken);
if (response.IsSuccess)
{
logger.LogInformation("{Count} inactive users", response.Data!.Count);
}
}
}Custom authentication handler#
AddErtisAuth<THandler>() registers your own authentication handler instead of the SDK's. It takes the same arguments as AddErtisAuth(). Derive the handler from ErtisAuthAuthenticationHandler to keep the ErtisAuth checks and add your own behavior around them:
using System.Text.Encodings.Web;
using ErtisAuth.Core.Models.Identity;
using ErtisAuth.Sdk.AspNetCore.Middleware;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
public class AuditedAuthenticationHandler(
IAuthorizationHandler<BasicToken> basicAuthorizationHandler,
IAuthorizationHandler<BearerToken> bearerAuthorizationHandler,
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: ErtisAuthAuthenticationHandler(basicAuthorizationHandler, bearerAuthorizationHandler, options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var result = await base.HandleAuthenticateAsync();
if (result.Failure != null)
{
this.Logger.LogWarning("Request to {Path} was not authenticated: {Reason}", this.Request.Path, result.Failure.Message);
}
return result;
}
}builder.Services.AddErtisAuth<AuditedAuthenticationHandler>();Testing your API#
Integration tests of your API (for example with WebApplicationFactory) shouldn't need a running ErtisAuth. Replace the handler with one that signs in a test caller, without changing Program.cs: AddErtisAuth registers only once, so register the test handler as ErtisAuthAuthenticationHandler instead:
using System.Security.Claims;
using System.Text.Encodings.Web;
using ErtisAuth.Core.Models.Identity;
using ErtisAuth.Extensions.Authorization.Extensions;
using ErtisAuth.Sdk.AspNetCore.Middleware;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
public class TestAuthenticationHandler(
IAuthorizationHandler<BasicToken> basicAuthorizationHandler,
IAuthorizationHandler<BearerToken> bearerAuthorizationHandler,
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: ErtisAuthAuthenticationHandler(basicAuthorizationHandler, bearerAuthorizationHandler, options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var utilizer = new Utilizer
{
Id = "test-user",
Username = "test",
Role = "admin",
Type = Utilizer.UtilizerType.User,
MembershipId = "test-membership",
Token = "test-token",
TokenType = SupportedTokenTypes.Bearer
};
var principal = new ClaimsPrincipal(utilizer.ToClaimsIdentity());
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, this.Scheme.Name)));
}
}using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
public class OrdersApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private HttpClient CreateClient() => factory
.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
services.AddTransient<ErtisAuthAuthenticationHandler, TestAuthenticationHandler>()))
.CreateClient();
}Every request is then authenticated as the test caller, and no permission is checked. Token and TokenType must be set: GetUtilizer() requires them.
Checklist#
- Every controller with endpoints to protect has
[Authorized], or its actions have[SelfAuthorized]. Fix everyERTISAUTH610warning. - Every protected action has
[RbacResource](or its controller has) and[RbacAction]. - Authorization decisions use
GetUtilizer(), neverGetUnverifiedUtilizer(). - Clients retry on
503 AuthenticationServiceUnavailableinstead of signing the user out. - The application secret is on the server only.
- With
BasicTokenCacheTTL, a rotated secret keeps working on your API for up to that many seconds. - The settings are in the application's configuration (
appsettings.json, environment variables…), not in code.
Found a mistake in the docs? Open an issue