ResearchCave Technology

ResearchCave.Identity (1.1.70)

Published 2026-10-13 20:47:56 +03:00 by admin

Installation

dotnet nuget add source --name ResearchCave --username your_username --password your_token https://git.researchcave.com/api/packages/ResearchCave/nuget/index.json
dotnet add package --source ResearchCave --version 1.1.70 ResearchCave.Identity

About this package

ResearchCave Identity Integration Framework.

ResearchCave.Identity

Private synchronization snapshots may include a nullable Roles collection containing global roles and roles for the authenticated client (with its application prefix removed). AppUserProfileSynchronization.Roles preserves the distinction between an absent snapshot and an authoritative empty role list. Services that project roles should replace only their identity-managed grants; local permissions remain service-owned. AuthServer role changes reuse IdentityProfileChanged with ChangedFields=["roles"], and synchronization reads bypass the private profile cache.

Shared OIDC authentication and local application-user synchronization for ResearchCave microservices.

How local AppUsers stay synchronized

Microservices commonly keep a small local AppUser table for joins, display names, profile photos, email addresses, phone numbers, and service-specific state. AuthServer remains the authoritative source for identity information.

ResearchCave.Identity supports two complementary synchronization paths:

  1. On login, AddResearchCaveOIDC calls IAppUserRepo.UpsertAsync using the authenticated user's claims.
  2. After a profile change, a durable identity event causes the service to reload the current profile from AuthServer and call IAppUserRepo.SynchronizeProfileAsync.

Login synchronization should remain enabled as a self-healing fallback even when event synchronization is configured.

Required packages

The application host references:

<PackageReference Include="ResearchCave.Identity" Version="..." />
<PackageReference Include="ResearchCave.Messaging" Version="..." />

ResearchCave.Identity does not depend on Wolverine or NATS. Transport and JetStream topology belong to ResearchCave.Messaging, allowing the underlying messaging technology to change without changing the AppUser synchronization contract.

Application registration

Register the local repository, OIDC, messaging, and one stable durable identity consumer:

using ResearchCave.Identity.Core.Extensions;
using ResearchCave.Identity.Core.Handlers;
using ResearchCave.Identity.Core.Interfaces;
using ResearchCave.Messaging.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IAppUserRepo, SentientAppUserRepo>();
builder.Services.AddResearchCaveOIDC(
    builder.Environment,
    builder.Configuration,
    "__Secure-sentient.auth");

builder.AddResearchCaveMessaging(configureWolverine: messaging =>
{
    messaging.ListenForResearchCaveIdentityEvents(
        consumerName: "sentient-identity",
        handlerMarkerType: typeof(IdentityEventHandler));
});

Authentication cookies and SSO

An explicit oidc:Scopes list replaces the default interactive scopes. For example, ["openid", "profile", "email", "roles"] does not request offline_access. When the list is omitted, the existing defaults include offline_access; clients using those defaults must be authorized for it by AuthServer.

Give every independently deployed application a distinct cookie name. Application cookies are host-only by default; AddResearchCaveOIDC no longer derives their domain from WebSite.Domain. When a user opens another ResearchCave application, that application redirects to AuthServer, which uses its central login session to return the same sub without asking the user to sign in again.

Do not share an application cookie across microservices. Sharing would require every service to share data-protection keys and authentication scheme settings and would unnecessarily expand the cookie's security boundary.

An explicit cookie domain remains available for a deployment that genuinely shares one application cookie between hosts:

builder.Services.AddResearchCaveOIDC(
    builder.Environment,
    builder.Configuration,
    cookieName: "__Secure-example.auth",
    cookieDomain: ".example.com");

cookieDomain must be a host name without a scheme, path, or port. It cannot be used with a __Host- cookie name.

Application cookies use a 12-hour sliding lifetime by default. Browser-session persistence follows AuthServer's Remember me choice: AuthServer emits the ResearchCaveClaimTypes.SessionPersistent preference in the signed ID token, and AddResearchCaveOIDC sets AuthenticationProperties.IsPersistent from that validated token. Missing, invalid, or duplicate preferences keep a session cookie. Application-cookie expiry and sliding renewal remain governed by the application's configuration. Deploy the updated AuthServer and Identity integration together; previously issued cookies retain their original persistence.

Cookie authentication does not cause a database query on every request. To periodically enforce locally projected AuthServer access state, register ILocalAppUserAccessValidator; the cookie handler calls it at most once every 10 minutes per active session:

builder.Services.AddScoped<ILocalAppUserAccessValidator, LocalAppUserAccessValidator>();

The implementation should perform one primary-key lookup in the application's local AppUsers table and return false for missing or ecosystem-banned users. It must not call AuthServer. Override the defaults with oidc:LocalAccessValidationMinutes and oidc:ApplicationCookieLifetimeHours.

API audience validation

An API host configures its resource audience separately from the OIDC client it uses for introspection and outbound calls:

{
  "oidc": {
    "Authority": "https://login.researchcave.com",
    "ClientId": "ddb-server",
    "ClientSecret": "__FROM_SECRET_STORE__",
    "Audience": "urn:rc:api:ddb"
  }
}

Register reference-token validation once in the API host:

builder.Services.AddResearchCaveApiAuthentication(
    builder.Environment,
    builder.Configuration);

Audience is the API resource identifier configured in AuthServer. It is not inferred from ClientId. Controllers can continue using role authorization such as [Authorize(Roles = "ddb,admin")]; scopes remain optional unless that API deliberately adds scope-based policies.

Personal and organization automation

AuthServer emits principal_type so APIs never have to infer whether a token represents a person, personal automation, organization automation, or a backend service. Use the shared helpers and policies from ResearchCave.Auth:

if (User.IsOrganizationAutomation())
{
    var organizationId = User.GetOrganizationId();
    var credentialId = User.GetCredentialId();
}

Personal automation keeps the human sub and roles. Organization automation uses a non-user credential subject, carries organization_id, and receives no human roles. Backend services should use client credentials.

A web application that accepts both its normal cookie and API bearer tokens can enable the shared selector without changing every [Authorize] attribute:

builder.Services.AddResearchCaveApiAuthentication(
    builder.Environment,
    builder.Configuration,
    interactiveAuthenticationScheme: "Cookies");

If the application stores a local integer member ID, implement and register IAppUserSubjectResolver. The shared claims transformation then adds app_user_id to a validated user or personal-automation bearer principal. It deliberately never maps an organization or service credential to a local user.

API registration replaces ASP.NET Core's default no-op claims transformation when interactive authentication was registered first. A service-supplied transformation is preserved. This makes cookie-first and API-first registrations resolve local user identities consistently, including persistent SignalR connections.

Consumer naming rules:

  • Every independently deployed microservice must use a different stable consumer name.
  • Replicas of the same microservice must use the same consumer name.
  • Never generate the name at startup. The durable JetStream cursor is identified by this name.

Messaging configuration

Configure JetStream retention for longer than the maximum expected service outage:

{
  "Nats": {
    "Url": "nats://server:4222",
    "EnableJetStream": true,
    "StreamName": "RESEARCHCAVE",
    "StreamSubjects": [ "researchcave.>" ],
    "StreamMaxAge": "30.00:00:00",
    "AutoListenToSubjectWildcard": false
  }
}

StreamMaxAge defaults to 30 days. A service that is offline for three weeks can therefore continue from its durable cursor when it starts again. Broker storage limits must also be large enough to retain that period.

Keep AutoListenToSubjectWildcard disabled when using the explicit identity listener. A catch-all listener and an explicit identity listener could otherwise process the same event twice.

AuthServer client permissions

Event messages do not contain profile values. The handler retrieves the authoritative profile from AuthServer using a client-credentials token.

The service's OIDC client therefore requires:

  • a client secret;
  • permission to use the client-credentials grant;
  • the members.read scope.

The configured oidc:Authority, oidc:ClientId, and oidc:ClientSecret are used by the shared Identity client.

Batch user resolution

Applications can resolve presence subjects without making one AuthServer request per user:

var subjectIds = presenceRecords
    .Select(record => Guid.Parse(record.SubjectId))
    .Distinct()
    .Take(200)
    .ToArray();

var users = await userService.ResolveBySubjectIdsAsync(
    subjectIds,
    cancellationToken);

The client obtains and caches a client-credentials token with the members.read scope, then calls POST /api/users/resolve-batch. Unknown subjects are omitted. Use SubjectId for the stable OIDC user subject; sid refers to an authentication session and is retained only as a deprecated server-side query alias.

Current account statuses

IUserService.GetAccountStatusesAsync calls POST /api/users/account-statuses with up to 200 subject IDs, using a service token for the identity audience and the members.read scope. Responses are not cached. Each distinct requested subject has a UserAccountStatusDto containing Exists, nullable IsEcosystemBanned, and CheckedAt; unknown accounts have Exists=false and IsEcosystemBanned=null.

These are account facts, not authorization or operation eligibility decisions. Consumers own their business rules and must reject unavailable or unknown status. For example, a reward service can require Exists=true and IsEcosystemBanned=false before applying its campaign rules.

Deploy AuthServer with the new endpoint before upgrading consuming services. The legacy reward-eligibility endpoint remains available for older deployed clients; the Identity library's current API uses only the general account-status contract.

Version 1.1.70 removes GetRewardEligibilityAsync and UserRewardEligibilityDto. Update callers to use account facts and decide eligibility in their own service. The Defaults packages use the normal shared release workflow:

.\publish.ps1
.\deploy_nuget.ps1

publish.ps1 chooses the next patch version from the release tag and prepares the whole package set. deploy_nuget.ps1 publishes that exact set and records its tag.

Application catalog

Trusted backend services can use IResearchCaveApplicationsProvider to list or resolve applications registered in AuthServer:

using ResearchCave.Clients.AuthServer;

public sealed class ApplicationPicker(IResearchCaveApplicationsProvider applications)
{
    public Task<IReadOnlyList<ResearchCaveApplication>> GetAllPublicAsync(
        CancellationToken cancellationToken) =>
        applications.GetAllAsync(
            ResearchCaveApplicationFilter.PublicOnly,
            cancellationToken);

    public Task<ResearchCaveApplicationsPage> SearchAsync(
        string? query,
        CancellationToken cancellationToken) =>
        applications.ListAsync(
            query,
            ResearchCaveApplicationFilter.ConfidentialOnly,
            page: 1,
            pageSize: 50,
            cancellationToken);
}

Use ResearchCaveApplicationFilter.All (the default), ConfidentialOnly, or PublicOnly. GetAllAsync follows all catalog pages automatically, while ListAsync supports search and explicit paging. Use GetByClientIdAsync(clientId, cancellationToken) when resolving one application.

The calling AuthServer client requires a client secret, the client-credentials grant, and the applications.read scope. The catalog exposes public application identity and registered URLs; it never exposes client secrets.

Implementing IAppUserRepo

Existing repositories only need to implement the login method:

public sealed class SentientAppUserRepo(AppDbContext db) : IAppUserRepo
{
    public Task<int> UpsertAsync(ClaimsPrincipal principal)
    {
        // Create or update the local user during login.
        throw new NotImplementedException();
    }
}

SynchronizeProfileAsync has a backward-compatible default implementation that calls UpsertAsync. Repositories should override it when login synchronization has side effects such as updating LastLoginUtc, recording an IP address, or creating login activity.

public async Task<int> SynchronizeProfileAsync(
    AppUserProfileSynchronization synchronization,
    CancellationToken cancellationToken = default)
{
    var principal = synchronization.Principal;
    var user = await FindBySubjectAsync(synchronization.SubjectId, cancellationToken);

    user.UserName = principal.FindFirst("preferred_username")?.Value;
    user.DisplayName = principal.FindFirst("name")?.Value;

    if (synchronization.ChangedFields.Contains("Email"))
        user.Email = principal.FindFirst("email")?.Value;

    if (synchronization.ChangedFields.Contains("PhoneNumber"))
        user.Phone = principal.FindFirst("phone_number")?.Value;

    await db.SaveChangesAsync(cancellationToken);
    return user.Id;
}

ChangedFields is important when a value is cleared. A missing email claim normally means "there is no email value"; when ChangedFields contains Email, the repository should persist that null instead of retaining the old address.

The reconstructed principal can include:

  • sub and ClaimTypes.NameIdentifier
  • preferred_username
  • name and ClaimTypes.Name
  • email, email_verified, and ClaimTypes.Email
  • phone_number and phone_number_verified
  • picture and profile
  • given_name, family_name, and their standard claim equivalents
  • birthdate, country, locale, zoneinfo, gender, and website
  • KYC status and verification summary claims

Bans, lockouts, and deletion

Services that persist identity lifecycle state can implement IAppUserLifecycleRepo:

public sealed class AppUserRepo(AppDbContext db) : IAppUserLifecycleRepo
{
    public Task<int> UpsertAsync(ClaimsPrincipal principal) =>
        throw new NotImplementedException();

    public Task<int> SynchronizeProfileAsync(
        AppUserProfileSynchronization synchronization,
        CancellationToken cancellationToken = default) =>
        throw new NotImplementedException();

    public Task ApplyAccessChangeAsync(
        AppUserAccessChange change,
        CancellationToken cancellationToken = default) =>
        throw new NotImplementedException();

    public Task DeleteAsync(
        AppUserDeletion deletion,
        CancellationToken cancellationToken = default) =>
        throw new NotImplementedException();
}

Implementations may soft-delete users instead of physically deleting rows when other local records reference the user.

JetStream delivery is at least once. Synchronization must be idempotent, and lifecycle handlers should compare UserRevision or OccurredAtUtc with their last applied value so an older redelivery cannot overwrite newer state.

Identity events

The shared contracts use these subjects:

  • researchcave.identity.user.profile.changed
  • researchcave.identity.user.access.changed
  • researchcave.identity.user.deleted

IdentityProfileChanged contains the subject ID, event ID, revision, timestamp, and changed field names. It deliberately does not broadcast email addresses, phone numbers, birth dates, national identity numbers, or other profile values.

National identity information such as TCK belongs to the protected KYC domain. A service with a legitimate need for that value should use an explicitly authorized KYC API or KYC-specific contract rather than the general identity profile event.

AuthServer responsibilities

For the synchronization pipeline to work correctly, AuthServer must:

  • publish identity events only after its database transaction commits, preferably through a transactional outbox;
  • publish profile events for user changes, admin changes, and profile-photo changes;
  • publish access events for bans, lockouts, unbans, and re-enablement;
  • publish deletion events when an account is deleted;
  • include a stable user revision so consumers can reject stale events;
  • ensure synchronization reads bypass or invalidate cached user profiles.

The event handler retries failed AuthServer reads through the messaging retry policy. A message is acknowledged only after the local synchronization completes successfully.

Dependencies

ID Version Target Framework
ResearchCave.Common 1.1.70 net10.0
ResearchCave.Contracts 1.1.70 net10.0
Microsoft.AspNetCore.Authentication.OpenIdConnect 10.0.12 net10.0
OpenIddict.Validation.AspNetCore 7.7.1 net10.0
OpenIddict.Validation.SystemNetHttp 7.7.1 net10.0
Details
NuGet
2026-10-13 20:47:56 +03:00
0
ResearchCave.Identity
71 KiB
Assets (2)
Versions (37) View all
1.1.70 2026-10-13
1.1.68 2026-10-12
1.1.67 2026-10-10
1.1.66 2026-10-10
1.1.65 2026-09-05