Identity module
The Identity module keeps an app-owned user for every Keycloak or Entra External ID account, handles sign-up, login and logout, and creates accounts for invited employees.
- Owns
- Users, sign-up, login and logout
- Schema
identity, one table:Users- Providers
- Keycloak locally, Entra External ID on Azure
- Endpoints
- 3, none needing a permission
What’s inside
Section titled “What’s inside”Identity maps each identity-provider account to an app-owned User, and calls the provider’s admin API when someone signs up or is invited.
| Feature | Folder | What it does |
|---|---|---|
| Users | Users/ |
The User aggregate: the app’s UserId, the provider’s IdentityProviderUserId, name and email. UserIdentityRegistry finds or creates the row for a signed-in caller. |
| Sign-up | Signup/ |
POST /api/v1/signup creates the account in the identity provider and a local User. |
| Login and logout | Login/, Logout/ |
Starts the OIDC sign-in, and ends the cookie session and the provider session. |
| Invitation emails | Users/InviteUser/ |
Handles the Organizations invitation events: creates the provider account and sends the email. |
| Keycloak provider | Keycloak/ |
IIdentityProvider over the Keycloak Admin REST API. |
| Entra provider | Entra/ |
IIdentityProvider over Microsoft Graph invitations. |
| Contracts | AppointMe.Identity.Contracts |
IUserIdentityRegistry, AuthenticatedUser and IdentityProviderUserId, used by the API host. |
Authentication itself isn’t in this module. The cookie and JWT schemes, the claims transformers and the demo login live in the API host, in AppointMe.Api/Authentication. How a request becomes an IIdentity is covered in Identity & principal.
The API host calls IUserIdentityRegistry.GetOrRegister when it resolves the caller. A subject it hasn’t seen before gets a User row on the spot:
public async Task<UserIdentity> GetOrRegister(AuthenticatedUser authenticatedUser, CancellationToken cancellationToken){ var user = await dbContext.Users .AsNoTracking() .FirstOrDefaultAsync(user => user.IdentityProviderUserId == authenticatedUser.IdentityProviderUserId, cancellationToken);
user ??= await RegisterUser(authenticatedUser, cancellationToken);
return new UserIdentity(user.Id, user.Name, user.Email);}Folder map
Section titled “Folder map”Directorysrc/Identity/
DirectoryAppointMe.Identity/
DirectoryConfiguration/ # AddIdentityModule
- …
DirectoryDatabase/ # IdentityDbContext, migrations
- …
DirectoryEntra/ # EntraIdentityProvider, options
- …
DirectoryKeycloak/ # KeycloakIdentityProvider, options
- …
DirectoryLogin/
- …
DirectoryLogout/
- …
DirectorySignup/
- …
DirectoryUsers/
DirectoryDatabase/
- …
DirectoryInviteUser/ # handlers for Organizations events
- …
DirectoryRegisterUser/
- …
- User.cs
- …
- IIdentityProvider.cs
- …
DirectoryAppointMe.Identity.Contracts/
DirectoryIdentity/
- …
DirectoryAppointMe.Identity.Tests/
- …
Endpoints
Section titled “Endpoints”Three endpoints, none of which needs a permission or the X-Company-Id header.
| Method | Route | Permission | Purpose |
|---|---|---|---|
| GET | /api/v1/login |
Anonymous | Starts the OIDC sign-in. returnUrl (relative paths only) sets where the SPA lands. provider becomes Keycloak’s kc_idp_hint. |
| POST | /api/v1/logout |
Signed in | Clears the appointme.auth cookie, signs out of the provider and redirects to Frontend:BaseUrl. |
| POST | /api/v1/signup |
Anonymous | Body { name, email }. Creates the provider account and the local user, then returns 200. |
Identity has no *Permissions class, so it adds nothing to the permission matrix. GET /api/v1/login/demo looks like it belongs here, but the API host owns it: see Configuration.
Events
Section titled “Events”Identity handles two invitation events from Organizations, and raises one event that nothing handles yet.
| Direction | Event | Handler or source | What happens |
|---|---|---|---|
| Consumed | EmployeeInvited (Organizations) |
EmployeeInvitedHandler |
Calls CreateUserWithPasswordSetup with no name and FrontendOptions.InvitationUrl as the redirect. |
| Consumed | EmployeeInvitationResent (Organizations) |
EmployeeInvitationResentHandler |
Calls ResendInvitationEmail. Keycloak looks the user up by email and sends the verify email again. Entra sends a new invitation. |
| Raised | UserRegisteredEvent |
User.Register, on sign-up and on a first sign-in |
A domain event in Users/RegisterUser/, not in Contracts. No handler subscribes to it. |
Both handlers take IDomainEvent messages, so Wolverine runs them as the system identity. See Domain events & messaging.
Database
Section titled “Database”One table, identity.Users, and no company column: a user can work for several companies.
| Table | Entity | Query filters | Notes |
|---|---|---|---|
identity.Users |
User |
SoftDelete (!IsDeleted) |
Id is the app’s UserId. IdentityProviderUserId (up to 255 chars) has a unique index. Version is a row version. |
IdentityDbContext uses the schema identity and keeps its migrations history in identity.__EFMigrationsHistory. Nothing sets IsDeleted to true: there’s no delete-user feature yet. The module has no background jobs.
Why keep a Users table when the identity provider already has users?1 min · optional
Other modules store the app’s UserId, never the provider’s subject: Employee.UserId, Company.RegisteredBy and EmployeeInvitation.InvitedBy are all UserId. The provider’s id appears in exactly one column, IdentityProviderUserId.
That keeps Keycloak and Entra interchangeable behind IIdentityProvider. The app also gets names and emails for its own queries without calling the provider.
Configuration
Section titled “Configuration”Authentication:Provider picks Keycloak or Entra External ID, and the module registers the matching IIdentityProvider at startup.
var provider = configuration["Authentication:Provider"] ?? "Keycloak";switch (provider){ case "Keycloak": services.AddKeycloakIdentityProvider(); break; case "EntraExternalId": services.AddEntraIdentityProvider(); break; default: throw new InvalidOperationException( $"Unknown Authentication:Provider '{provider}'. Expected 'Keycloak' or 'EntraExternalId'.");}| Key | Read by | Notes |
|---|---|---|
Authentication:Provider |
IdentityModule and the API host |
Keycloak (the default) or EntraExternalId. Any other value stops startup. |
KeycloakAdmin:BaseUrl, :Realm, :ClientId, :ClientSecret |
KeycloakAdminOptions |
Client-credentials login to the Keycloak Admin REST API. Validated on start. Locally the realm is appointme and the client is appointme-api. |
EntraIdentity:TenantId, :ClientId, :ClientSecret |
EntraIdentityOptions |
Microsoft Graph app credentials for sending invitations. Validated on start. |
Frontend:BaseUrl |
FrontendOptions (Shared) |
Where login and logout land. InvitationUrl is this URL plus /auth/login. |
Demo:Enabled, Demo:User:Email, :Password, :Name |
DemoOptions, bound in AppointMe.Api/Authentication/DemoLogin |
GET /api/v1/login/demo signs in as this user, or returns 404 when demo mode is off. |
The OIDC settings themselves (Authentication:Keycloak:*, Authentication:EntraExternalId:*) belong to the API host. The full list is in the Configuration reference.
Sign-up, email verification and password reset
Section titled “Sign-up, email verification and password reset”| Step | What happens |
|---|---|
| 1 | The SPA’s sign-up form posts { name, email } to POST /api/v1/signup. |
| 2 | SignupCommandHandler calls CreateUserWithPasswordSetup with the redirect {Frontend:BaseUrl}/auth/login?returnUrl=/onboarding. |
| 3 | Keycloak creates the user unverified, with the required actions VERIFY_EMAIL and UPDATE_PASSWORD, and sends the verify email. Entra sends a Graph invitation instead. |
| 4 | The handler saves a local User with the provider’s user id. |
| 5 | From the email on, Keycloak’s own pages run the flow. The link carries the redirect back to the SPA’s /auth/login, which signs in and opens /onboarding. |
This is the Keycloak call behind step 3:
var response = await usersApi.PostUsersWithHttpInfoAsync(options.Realm, new UserRepresentation{ Username = email, Email = email, FirstName = name?.FirstName, LastName = name?.LastName, EmailVerified = false, Enabled = true, RequiredActions = [RequiredActionVerifyEmail, RequiredActionUpdatePassword]}, cancellationToken);var userId = response.ExtractUserIdFromLocation();
await usersApi.PutUsersSendVerifyEmailByUserIdAsync( options.Realm, userId, options.ClientId, null, redirectUri: redirectUri, cancellationToken);AppointMe has no password-reset endpoint. The realm import, src/AppointMe.Aspire/appointme-realm.json, turns on resetPasswordAllowed, verifyEmail and registrationAllowed, and points Keycloak’s SMTP at the Mailpit container (port 1025, from noreply@appointme.local). Read those emails in Mailpit: see Local services & ports.
Because registrationAllowed is on, people can also register on Keycloak’s own page, or through the google broker the realm defines (/api/v1/login?provider=google). They get a User row on their first request, through GetOrRegister. With EntraExternalId, AppointMe only sends the Graph invitation, and everything after it is set up in your tenant: see Entra External ID.
Extend it
Section titled “Extend it”Most Identity changes start at IIdentityProvider or the User aggregate.
| Change | Start here |
|---|---|
| Add another identity provider | Implement IIdentityProvider (two methods) and add a case to the switch in IdentityModule. The API host switches on the same key in AuthenticationExtensions.cs and DemoModeExtensions.cs, so add it there too. |
| Store more profile data | Add the property to User and UserTypeConfiguration, then a migration for IdentityDbContext (Database migrations). Fill it in UserIdentityRegistry.RegisterUser and SignupCommandHandler. Claims arrive through ClaimsPrincipalExtensions.ToAuthenticatedUser. |
| Let other modules react to new users | Move UserRegisteredEvent into AppointMe.Identity.Contracts, then handle it in the other module. Wolverine only publishes it from the sign-up handler: a first sign-in saves the user in UserIdentityRegistry, outside any handler, so publish it there too. See Cross-module events. |
| Change where invite and sign-up links land | FrontendOptions.InvitationUrl in Shared (/auth/login), and the returnUrl=/onboarding query in SignupCommandHandler. |
| Turn off self sign-up | Delete the Signup/ slice, set registrationAllowed to false in the realm import, and regenerate the typed API client. |
AppointMe.Identity.Tests covers claim mapping and user registration, but not the providers, handlers or registry.
| Test class | Covers |
|---|---|
ClaimsPrincipalExtensionsTests |
Claims to AuthenticatedUser. A missing subject, name or email throws AccessDeniedException. A malformed email or a blank name throws ValidationException. The email is trimmed and lower-cased. |
RegisterUserTests |
User.Register sets the initial state and raises UserRegisteredEvent. |
dotnet test src/Identity/AppointMe.Identity.TestsHow the test projects are organized: Testing.