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

Source page: https://appointme.dev/docs/modules/identity/

* **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

**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](/docs/concepts/identity-and-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:

src/Identity/AppointMe.Identity/Users/UserIdentityRegistry.cs

```cs
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

* src/Identity/

  * AppointMe.Identity/

    * Configuration/ # AddIdentityModule

      * …

    * Database/ # IdentityDbContext, migrations

      * …

    * Entra/ # EntraIdentityProvider, options

      * …

    * Keycloak/ # KeycloakIdentityProvider, options

      * …

    * Login/

      * …

    * Logout/

      * …

    * Signup/

      * …

    * Users/

      * Database/

        * …

      * InviteUser/ # handlers for Organizations events

        * …

      * RegisterUser/

        * …

      * User.cs

      * …

    * IIdentityProvider.cs

    * …

  * AppointMe.Identity.Contracts/

    * Identity/

      * …

  * AppointMe.Identity.Tests/

    * …

## 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](#configuration).

## 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](/docs/concepts/messaging/).

Gotcha

Inviting an email that already has a Keycloak account sends no email. On a Keycloak `409`, `KeycloakIdentityProvider` returns the existing user’s id without calling `send-verify-email`, and `EntraIdentityProvider` does the same when Graph rejects the invitation for an existing user. The invitee finds the invite the next time they sign in, through `hasPendingInvitations` on `GET /api/v1/me`.

## 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?

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

**`Authentication:Provider` picks Keycloak or Entra External ID, and the module registers the matching `IIdentityProvider` at startup.**

src/Identity/AppointMe.Identity/Configuration/IdentityModule.cs

```cs
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](/docs/reference/configuration/).

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

src/Identity/AppointMe.Identity/Keycloak/KeycloakIdentityProvider.cs

```cs
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](/docs/reference/local-services/).

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](/docs/deploy/entra-external-id/).

Demo login per provider

`GET /api/v1/login/demo` signs in without a browser redirect. On Keycloak it uses the password grant on the OIDC client, which needs Direct Access Grants enabled. On Entra External ID it uses the native authentication API, because Entra External ID doesn’t support ROPC.

## 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](/docs/guides/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](/docs/guides/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](/docs/frontend/api-client/).                                                                                                                                                                        |

## Tests

**`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`.                                                                                                                              |

```bash
dotnet test src/Identity/AppointMe.Identity.Tests
```

How the test projects are organized: [Testing](/docs/testing/overview/).

## Next

* [Identity & principal](/docs/concepts/identity-and-principal/) (Concept)
* [Organizations module](/docs/modules/organizations/) (Module)
* [Entra External ID](/docs/deploy/entra-external-id/) (Deploy)
