ErtisAuth

.NET SDK

The client SDK and the ASP.NET Core integration.

ErtisAuth comes with two .NET libraries:

PackageWhat it does
ErtisAuth.SdkA typed client for the ErtisAuth REST API: sign-in, tokens, users, roles, applications, hooks…
ErtisAuth.Sdk.AspNetCoreProtects 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:

shell
dotnet add package ErtisAuth.Sdk.AspNetCore

2. Add the settings to appsettings.json:

json
{
	"ErtisAuth": {
		"BaseUrl": "https://auth.example.com",
		"MembershipId": "66f1c0d2a4b5c6d7e8f90123"
	}
}

3. Register the SDK in Program.cs:

csharp
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:

csharp
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:

RequestResponse
Without a token, or with an invalid, expired or revoked token401
A user or application whose role doesn't allow orders.read403 AccessDenied
A user or application whose role allows orders.read200

Configuration#

SettingRequiredDescription
BaseUrlyesThe absolute http or https URL of ErtisAuth, including any path prefix.
MembershipIdyesThe membership your application belongs to.
BasicTokenCacheTTLnoSeconds 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:

yaml
env:
  - name: ErtisAuth__BaseUrl
    value: https://auth.example.com
  - name: ErtisAuth__MembershipId
    value: 66f1c0d2a4b5c6d7e8f90123

The settings are validated when the application starts. Invalid settings stop it with a message that lists every problem.

Other ways to give the settings:

CallReads
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
csharp
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.

Note: a program without a .NET host (no 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:

The most specific attribute applies: the action's attribute overrides the controller's.

ControllerActionThe 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.

Warning: without [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.

AttributeOnMeaning
[Authorized]controllerA valid token and the permission described by the rbac attributes are required, on every action.
[SelfAuthorized]controller or actionA valid token is required; the action checks the permission itself.
[Unauthorized]controller or actionPublic: no token is needed.
[RbacResource("orders")]controller or actionThe resource segment of the permission.
[RbacAction("approve")] or [RbacAction(Rbac.CrudActions.Read)]actionThe action segment.
[RbacObject("{id}")]actionThe object segment, usually the id from the route.
[RbacSubject("…")]actionThe 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.

SegmentFromWhen 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.

csharp
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:

csharp
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:

csharp
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:

  1. calls ErtisAuth's /whoami to authenticate the token,
  2. calls /roles/check-permission with 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 APIWhen
401 with an ErtisAuth error bodyNo token, unsupported token type, or an invalid, expired or revoked token
403 AccessDeniedThe token is valid but the permission is missing
503 AuthenticationServiceUnavailableErtisAuth 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:

PlaceholderSource
{id} or {route::id}A route value
{query::id}A query string parameter
{header::X-Tenant}A request header
{env::REGION}An environment variable
csharp
[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, header or env placeholder 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:

DiagnosticSeverityProblem
ERTISAUTH601warningThe placeholder is not a route parameter of the action
ERTISAUTH602errorThe placeholder names an unknown source (only route, query, header and env exist)
ERTISAUTH603error[RbacSubject] reads from a client-controlled source
ERTISAUTH610warningThe action has rbac attributes, but neither the action nor its controller has [Authorized] or [SelfAuthorized]: the endpoint is public
ERTISAUTH611warning[Authorized], [SelfAuthorized] and [Unauthorized] conflict on the same level (the controller with its base classes, or the action)
ERTISAUTH612infoThe rbac attributes of a [SelfAuthorized] or [Unauthorized] action are not checked
ERTISAUTH613warningAn 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).

Warning: anyone can send a token that claims any identity. Never use 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:

csharp
using ErtisAuth.Sdk.Extensions;

builder.Services.AddErtisAuth();

It takes the same settings. Then inject the services you need:

ServiceMethods
IAuthenticationServiceGetTokenAsync, RefreshTokenAsync, VerifyTokenAsync, RevokeTokenAsync, MeAsync, WhoAmIAsync
IUserServiceGetAsync, QueryAsync, CreateAsync, UpdateAsync, DeleteAsync, BulkDeleteAsync, GetActiveTokensAsync, GetRevokedTokensAsync
IPasswordServiceChangePasswordAsync, ResetPasswordAsync, SetPasswordAsync
IRoleServiceCRUD, CheckPermissionAsync, CheckPermissionByRoleAsync
IApplicationService, IWebhookService, IMailHookServiceCRUD
IMembershipServiceGetMembershipAsync, GetMembershipsAsync, QueryMembershipsAsync, CreateMembershipAsync, UpdateMembershipAsync, DeleteMembershipAsync
IActiveTokensService, IRevokedTokensServiceGetAsync, 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 BearerToken of the signed-in user, for calls on their behalf: BearerToken.CreateTemp(accessToken) wraps a raw access token;
  • a BasicToken of 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:

PropertyContent
IsSuccessWhether ErtisAuth answered with a success status
DataThe result, on success
StatusCodeThe HTTP status of ErtisAuth's answer; null when no answer arrived (e.g. a network error)
JsonThe body of the answer: on an error, ErtisAuth's error JSON
ExceptionThe 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:

csharp
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:

csharp
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):

csharp
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:

csharp
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:

csharp
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:

csharp
var users = await userService.QueryAsync(appToken, """{ "where": { "is_active": false } }""", limit: 50, sorting: null);
Note: 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:

csharp
using ErtisAuth.Sdk.Extensions;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddErtisAuth();
builder.Services.AddHostedService<InactiveUsersReport>();

builder.Build().Run();
csharp
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:

csharp
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;
	}
}
csharp
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:

csharp
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)));
	}
}
csharp
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 every ERTISAUTH610 warning.
  • Every protected action has [RbacResource] (or its controller has) and [RbacAction].
  • Authorization decisions use GetUtilizer(), never GetUnverifiedUtilizer().
  • Clients retry on 503 AuthenticationServiceUnavailable instead 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