ResearchCave.Identity (1.1.70)
Installation
dotnet nuget add source --name ResearchCave --username your_username --password your_token https://git.researchcave.com/api/packages/ResearchCave/nuget/index.jsondotnet add package --source ResearchCave --version 1.1.70 ResearchCave.IdentityAbout 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:
- On login,
AddResearchCaveOIDCcallsIAppUserRepo.UpsertAsyncusing the authenticated user's claims. - 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.readscope.
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:
subandClaimTypes.NameIdentifierpreferred_usernamenameandClaimTypes.Nameemail,email_verified, andClaimTypes.Emailphone_numberandphone_number_verifiedpictureandprofilegiven_name,family_name, and their standard claim equivalentsbirthdate,country,locale,zoneinfo,gender, andwebsite- 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.changedresearchcave.identity.user.access.changedresearchcave.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.