Skip to content

Account Self-Service API

The account self-service API is available at /api/account (configurable via OpenIddictEndpointsOptions.AccountRoutePrefix). All endpoints use MapGranitGroup() for automatic FluentValidation. Endpoints are registered by calling app.MapOpenIddictEndpoints().

GroupEndpointsAuth required
Registration3Anonymous / Authenticated
Profile2Authenticated
Password3Mixed
Two-factor5Authenticated
External logins4Mixed
Passkeys6Mixed
Session2Authenticated
Deletion1Authenticated
POST /api/account/register

Creates a new user account. Sends a confirmation email via IEmailConfirmationService. Publishes UserRegisteredEto via IDistributedEventBus. Increments granit.openiddict.registrations metric.

Request:

{
"email": "[email protected]",
"password": "MyStr0ng!Pass",
"firstName": "Alice",
"lastName": "Doe"
}

Responses:

  • 201 Created with body:
{
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"requiresEmailConfirmation": true
}
  • 409 Conflict — email already taken
  • 422 Validation Problem — invalid email or weak password
GET /api/account/confirm-email?userId=...&token=...

Validates the token from the confirmation email link. Returns 204 No Content on success, 400 Bad Request if the token is invalid or expired.

POST /api/account/resend-confirmation-email

Resends the confirmation email to the authenticated user. Always returns 202 Accepted regardless of outcome to prevent email enumeration attacks.

GET /api/account/profile

Returns the authenticated user’s profile.

Response:

{
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "[email protected]",
"emailConfirmed": true,
"firstName": "Alice",
"lastName": "Doe",
"twoFactorEnabled": false,
"hasPassword": true,
"externalLogins": ["Google"]
}
PUT /api/account/profile

Updates firstName and lastName. Returns the updated AccountProfileResponse.

Request:

{
"firstName": "Alice",
"lastName": "Smith"
}
EndpointAuthDescription
POST /api/account/change-passwordRequiredValidates current password, sets new one
POST /api/account/forgot-passwordAnonymousAlways returns 202 (prevents enumeration)
POST /api/account/reset-passwordAnonymousValidates token from email, sets new password

Validates the current password before setting the new one.

Request:

{
"currentPassword": "OldP@ss123",
"newPassword": "NewSecureP@ss456"
}
  • 204 No Content on success
  • 400 Bad Request if current password is incorrect

Sends a password reset link to the specified email.

Request:

{
"email": "[email protected]"
}

Always returns 202 Accepted regardless of whether the email exists, to prevent user enumeration. If the email does not exist, the request is silently dropped.

Validates the reset token and sets the new password.

Request:

{
"userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"token": "CfDJ8...",
"newPassword": "NewSecureP@ss456"
}
  • 204 No Content on success
  • 400 Bad Request if token is invalid or expired

All 2FA endpoints require authentication. The OpenIddict.TwoFactor feature flag (default: true) controls availability at the tenant level.

  1. Get status: GET /api/account/two-factor
{
"isEnabled": false,
"hasAuthenticatorApp": false,
"recoveryCodesLeft": 0
}
  1. Get authenticator key: GET /api/account/two-factor/authenticator-key
{
"sharedKey": "JBSWY3DPEHPK3PXP",
"qrCodeUri": "otpauth://totp/MyApp:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp"
}

The frontend renders a QR code from qrCodeUri for the user to scan with their authenticator app (Google Authenticator, Authy, 1Password, etc.).

  1. Enable 2FA: POST /api/account/two-factor/enable

Request:

{
"code": "123456"
}

Response (200):

{
"recoveryCodes": [
"ABCD-1234", "EFGH-5678", "IJKL-9012",
"MNOP-3456", "QRST-7890", "UVWX-1234",
"YZAB-5678", "CDEF-9012", "GHIJ-3456",
"KLMN-7890"
]
}

Returns 400 Bad Request if the TOTP code is invalid.

  1. Disable 2FA: POST /api/account/two-factor/disable — returns 204 No Content

  2. Regenerate recovery codes: POST /api/account/two-factor/recovery-codes

Generates 10 new single-use recovery codes. Previously generated codes are invalidated. Recovery codes can be used instead of a TOTP code during login.

When a user with 2FA enabled logs in via POST /connect/token (authorization code flow):

  1. Server returns a challenge_token (short-lived JWT, 5 min TTL)
  2. Client sends POST /connect/token with:
    • grant_type=urn:granit:grant_type:two_factor
    • challenge_token=...
    • totp_code=123456 (or a recovery code)
  3. Server validates the TOTP code and issues standard access + refresh tokens

This flow is stateless — no server-side sessions or cookies are required.

External login support is gated behind the OpenIddict.ExternalLogins feature flag (default: true). Supported providers are configured in GranitOpenIddictClientOptions.

GET /api/account/external-logins

Response:

[
{
"loginProvider": "Google",
"providerKey": "1234567890",
"providerDisplayName": "Google"
}
]
POST /api/account/external-logins/challenge/{provider}

Validates that the provider is configured in GranitOpenIddictClientOptions.Providers. Returns 400 if not configured. The actual OAuth redirect is handled by the OpenIddict client middleware.

GET /api/account/external-logins/callback?provider=Google

Processes the OAuth callback. Behavior depends on GranitOpenIddictClientOptions.AutoRegisterExternalUsers:

ScenarioAutoRegister = trueAutoRegister = false
Existing user with linked providerAuthenticatesAuthenticates
Existing email, no linkLinks + authenticatesLinks + authenticates
No existing userCreates + authenticates403 Forbidden
Email taken by another account409 Conflict409 Conflict
DELETE /api/account/external-logins/{provider}

Removes the provider link. Returns 400 Bad Request if it is the last login method and no password is set (the user would be locked out).

External provider claims are mapped to user properties via ExternalClaimsMapper. The default mapper extracts email, given_name, family_name from standard OIDC claim types. Override by registering a subclass:

public class MyCustomClaimsMapper : ExternalClaimsMapper
{
public override ExternalUserProperties MapToUserProperties(
ClaimsPrincipal principal, string provider)
{
var props = base.MapToUserProperties(principal, provider);
// Custom mapping logic
return props;
}
}
// Register before OpenIddict
services.AddScoped<ExternalClaimsMapper, MyCustomClaimsMapper>();

Passkey endpoints are gated behind the OpenIddict.Passkeys feature flag (default: false). When enabled, requires GranitPasskeyOptions configuration. Uses ASP.NET Core Identity’s built-in .NET 10 WebAuthn support.

MethodPathAuthDescription
GET/api/account/passkeysRequiredList registered passkeys
POST/api/account/passkeys/register/beginRequiredBegin WebAuthn registration ceremony
POST/api/account/passkeys/register/completeRequiredComplete registration with attestation
POST/api/account/passkeys/assertion/beginAnonymousBegin WebAuthn assertion ceremony (login)
PATCH/api/account/passkeys/{id}RequiredRename a passkey
DELETE/api/account/passkeys/{id}RequiredDelete a passkey
  1. POST /api/account/passkeys/register/begin returns PublicKeyCredentialCreationOptions JSON with mediation: "conditional" for browser-native passkey autofill (Conditional UI).

  2. POST /api/account/passkeys/register/complete with the browser’s AuthenticatorAttestationResponse:

Request:

{
"credentialJson": "{...WebAuthn attestation response JSON...}",
"name": "My YubiKey"
}

Response (201):

{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "My YubiKey",
"createdAt": "2026-03-21T10:00:00Z",
"lastUsedAt": null
}

POST /api/account/passkeys/assertion/begin returns PublicKeyCredentialRequestOptions JSON with empty allowCredentials for Conditional UI autofill. The actual assertion verification is handled by the urn:granit:grant_type:passkey custom grant at the /connect/token endpoint.

  • Rename: PATCH /api/account/passkeys/{id} with { "name": "New Name" }
  • Delete: DELETE /api/account/passkeys/{id} — returns 400 if this is the last credential and no password is set on the account (prevents lockout)
POST /api/account/session/heartbeat

Updates LastActivityAt in the distributed cache for idle session tracking (key: session:{userId}:{jti}, TTL: 35 min default). No-op for sessions with remember_me = true claim.

The frontend should call this endpoint periodically (e.g., every 5 minutes) while the user is active. When heartbeats stop, the OpenIddictIdleSessionEnforcementJob background job revokes the refresh token after the configured idle timeout.

Returns 204 No Content.

POST /api/account/session/back-to-impersonator

Ends an impersonation session and returns fresh tokens for the original administrator. Reads the impersonator_id claim from the current token.

Response (200):

{
"accessToken": "eyJ...",
"refreshToken": "CfDJ...",
"expiresIn": 3600
}

Returns 400 Bad Request if the current session is not an impersonation (no impersonator_id claim present).

POST /api/account/delete

Implements GDPR Article 17 (right to erasure). Requires password confirmation to prevent accidental or unauthorized deletion.

Request:

{
"password": "MyCurrentP@ss123"
}

What happens on deletion:

  1. Password verification via IIdentityCredentialVerifier
  2. Soft-delete: IsDeleted = true, DeletedAt = now, DeletedBy = userId
  3. All active tokens and authorizations revoked
  4. AccountDeletedEto published via IDistributedEventBus for downstream cleanup
  5. Audit log entry written

Returns 202 Accepted (downstream cleanup is asynchronous via event subscribers). Returns 400 Bad Request if the password is incorrect.

All request DTOs have corresponding FluentValidation validators auto-discovered by GranitValidationModule. Validation is applied automatically via FluentValidationAutoEndpointFilter because all route groups use MapGranitGroup().

ValidatorRequest type
AccountRegisterRequestValidatorAccountRegisterRequest
AccountProfileUpdateRequestValidatorAccountProfileUpdateRequest
AccountPasswordChangeRequestValidatorAccountPasswordChangeRequest
AccountPasswordResetRequestValidatorAccountPasswordResetRequest
AccountForgotPasswordRequestValidatorAccountForgotPasswordRequest
AccountTwoFactorEnableRequestValidatorAccountTwoFactorEnableRequest
AccountDeleteRequestValidatorAccountDeleteRequest
PasskeyRegistrationRequestValidatorPasskeyRegistrationRequest
PasskeyRenameRequestValidatorPasskeyRenameRequest
AdminUserCreateRequestValidatorAdminUserCreateRequest

Route prefixes and OpenAPI tags are configurable:

app.MapOpenIddictEndpoints(options =>
{
options.AccountRoutePrefix = "api/account"; // default
options.AdminRoutePrefix = "api/admin"; // default
options.AccountTagName = "Account"; // OpenAPI tag
options.AdminTagName = "Administration"; // OpenAPI tag
});