Events
IEventService raises, IEventSink receives. A host
registers a sink to send them somewhere it can query — a table, a queue, a SIEM — and until it does,
DefaultEventSink writes them to the logger, so that switching events on and seeing
nothing means nothing was raised rather than nothing was wired. Customization
shows the smallest sink; the code on this page is included from
samples/Interaction.Events,
whose test finds an issued token in its audit record by fingerprint and checks no event holds the token itself.
Switching them on
var identityServer = builder.Services
.AddIdentityServer(options =>
{
// Each kind separately: all four are off by default.
options.Events.RaiseSuccessEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseErrorEvents = true;
// Off by default: UnhandledExceptionEvent.Details is the exception's type and message, not its stack.
options.Events.IncludeStackTraceInUnhandledExceptionEvents = false;
// Every client must push its authorization requests (RFC 9126); discovery says so.
options.PushedAuthorization.Required = true;
// A confidential client's pushes may name a redirect URI it never registered (RFC 9126 §2.4).
options.PushedAuthorization.AllowUnregisteredPushedRedirectUris = true;
options.KeyManagement.Enabled = false;
});
builder.Services.AddSingleton<IEventSink, SecuritySignalSink>();
All four flags are off by default (the options). Events carry client
ids, subject ids and addresses, and where those end up is the host's decision rather than this server's. They are
separate because they are wanted separately: a deployment that wants an alert on Error rarely wants a record of every
successful token issuance, and one doing security monitoring wants Failure and nothing else.
A sink receives every event of a kind switched on; what it does with each is its own business. This one records them all and raises an alert for the three that mean a credential exists in two places:
/// <summary>
/// Records every event, and raises an alert for the three that mean a credential exists in two places. Alerting keys on
/// <see cref="EventIds"/>: the server's own numbers, which are never reused for another event.
/// </summary>
public sealed partial class SecuritySignalSink(AuditLog log, ILogger<SecuritySignalSink> logger) : IEventSink
{
private static readonly HashSet<int> CopiedCredential =
[
EventIds.RefreshTokenReplayDetected, // a refresh token presented after it was rotated away
EventIds.DPoPProofReplayDetected, // a DPoP proof presented twice
EventIds.SenderConstraintViolation, // a bound token presented without its key or certificate
];
/// <summary>Takes one event: one whose kind is switched on in <c>IdentityServerOptions.Events</c>.</summary>
public Task PersistAsync(Event evt, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(evt);
log.Append(evt);
if (CopiedCredential.Contains(evt.Id))
{
// The address and activity id are filled in by the event service before the sink is called.
LogCopiedCredential(logger, evt.Name, evt.RemoteIpAddress, evt.ActivityId);
}
return Task.CompletedTask;
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Security signal: {EventName} from {RemoteIpAddress} (activity {ActivityId})")]
private static partial void LogCopiedCredential(ILogger logger, string eventName, string? remoteIpAddress, string? activityId);
}
EventIds are this server's numbers, not Duende's. The two assign different values to some of the same events,
because these ids were given out before that comparison was made and a shipped id is never reused for a different event:
renumbering one would silently change what an existing alert rule matches, which is the failure the rule exists to
prevent. Key an alert on the constants in EventIds or on Event.Name.
What is raised
Thirty-four types are raised, and every one has a raise site — an event type that nothing raises is a claim the
server cannot keep. Duende 8.0.1 has twenty-eight; of those, eighteen are here under the same name and five under a
different one for the same thing, and the rest are covered or absent for a reason rather than pending. The count of
types raised here has twice been written down and left to drift, so it is worth saying that this one is derived: every
class deriving from Event, checked against every new …Event( in src/.
| Event | Category | Raised at |
|---|---|---|
UserLoginSuccessEvent |
Authentication | the login page, local and federated |
UserLoginFailureEvent |
Authentication | the login page, and a federated return that produced no identity |
UserLogoutSuccessEvent |
Authentication | the logout page, end_session, and SAML single logout |
ClientAuthenticationSuccessEvent / …FailureEvent |
Authentication | ClientSecretValidator |
ApiAuthenticationSuccessEvent / …FailureEvent |
Authentication | ApiSecretValidator, and the introspection endpoint for a request carrying no credential at all |
TokenIssuedSuccessEvent / …FailureEvent |
Token | the token endpoint, and the authorize endpoint — a code, and every refusal |
TokenRevokedSuccessEvent |
Token | the revocation endpoint |
TokenIntrospectionSuccessEvent / …FailureEvent |
Token | the introspection endpoint |
DeviceAuthorizationSuccessEvent / …FailureEvent |
Device | the device authorization endpoint |
BackchannelAuthenticationSuccessEvent / …FailureEvent, BackChannelAuthenticationNotificationFailureEvent |
BackchannelAuthentication (Device until 0.157.0) | the CIBA endpoint; the notification failure where a ping or push could not be delivered |
PushedAuthorizationSuccessEvent / …FailureEvent |
Authorize | the PAR endpoint, and the authorize endpoint — where a reference comes back, and where one was required and did not |
ConsentGrantedEvent / ConsentDeniedEvent |
Grants | the consent page, the device page and the CIBA page |
GrantsRevokedEvent |
Grants | DefaultPersistedGrantService, and the session-coordination path when a session ending takes its grants |
RefreshTokenReplayDetectedEvent |
Grants | DefaultRefreshTokenService |
DPoPProofReplayDetectedEvent |
Grants | DefaultDPoPProofValidator |
SenderConstraintViolationEvent |
Grants | DefaultSenderConstraintValidator, at all six ways a bound token can arrive without its binding |
BackChannelLogoutFailureEvent |
Authentication | DefaultBackChannelLogoutService, once per relying party that could not be reached |
SessionRevokedEvent |
Authentication | ISessionManagementService.RemoveSessionsAsync, once per session the filter matched |
SessionExpiredEvent |
Authentication | the expired-session sweep, once per session removed |
SamlSignOnSuccessEvent / …FailureEvent |
Saml | the SAML single sign-on endpoints, as the identity provider |
SamlAssertionRejectedEvent |
Saml | SamlSpHandler, as a service provider refusing an upstream provider's assertion |
SamlLogoutFailureEvent |
Saml | the single logout endpoint and SamlSpHandler — a logout message refused in either role |
SamlSingleLogoutSuccessEvent |
Saml | the single logout endpoint, when the sequence reaches its end |
SamlLogoutNotificationFailureEvent |
Saml | the single logout endpoint and SamlSpHandler — a partner a logout could not tell, in either role |
UnhandledExceptionEvent |
Error | the middleware that dispatches the protocol endpoints |
What an event carries
Duende's payloads, without the credentials — new in 0.146.0, Duende 8.0.1's event members, measured from its IL and set where this server raises each event.
Event.ProcessId, stamped by the event service;Event.PrepareAsync(),protected internal virtual, awaited after the time, process and request fields are filled and before the sink, in Duende's order;Event.ToString()is Duende's indented JSON of every member with a value, enums by name.ClientNameon eight events (token issued and refused, revoked, introspected, device authorization and CIBA, success and failure);Endpointby Duende's names —Token,DeviceAuthorization,BackchannelAuthentication,UI; on the refusals, theScopesasked for, theError, theErrorDescriptionthe client was told and theSubjectIdwhere one was known.RequestedScopesonConsentGrantedEventandConsentDeniedEvent, from all three approval screens, beside theScopesgranted;ClientIdon the sign-in events, from the login and external-login pages.- Introspection's
ClaimTypesandTokenScopes(the token's own, before an API's answer is narrowed),ApiScopeson a refusal for missing scopes,IsActive, and the caller'sClientName.TokenIssuedSuccessEvent.Tokenswith Duende's nestedToken(TokenType,TokenValue),TokenRevokedSuccessEvent.TokenandTokenType, the introspection events'Token, andUnhandledExceptionEvent.Details— Duende's names and shapes, so a sink written against Duende compiles.No event carries a usable token, on purpose — the decision of 2026-10-13. Where Duende's carries a token, these carry its type and its fingerprint:
sha256:and the base64url SHA-256 of the token (TokenFingerprint.Compute). A token in an event sink is a credential in a log — RFC 9700's practice, OWASP's logging guidance, CWE-532 — and a sink is where a host sends things to a wider audience than its own logs. Duende'sTokenscarries every token issued, whole; its revocation and introspection events carry the last four characters. The members keep only the fingerprint whatever they are handed,new TokenIssuedSuccessEvent.Token(type, value)included, and there is no option that puts a token back.
UnhandledExceptionEvent.Detailsis the exception's type and message, asMessagehas been since 0.36.0. Duende's is the wholeException.ToString(), stack and inner exceptions included; here that takesEventsOptions.IncludeStackTraceInUnhandledExceptionEvents = true, off by default, because a stack trace names this server's internals and sometimes the values it was working on.Breaking, in a small way:
Event.ToString()returns JSON where it returned the type name, and a host event class with aPrepareAsync()of its own now hides the base's.Ours, recorded:
Scopeson the three refusal events is a collection, as on this server's success events since they shipped (Duende's is a space-separated string);TokenRevokedSuccessEvent.TokenTypeis the kind revoked, where Duende's is the client'stoken_type_hint, which RFC 7009 lets be wrong or absent; a refused token request'sClientNameandScopescome from the authenticated client and the form, because this server's validator answers most refusals without a validated request for them to be read off; the sign-in events'ClientIdis the waiting application's identifier — a SAML service provider's entity id for a SAML sign-in; tokens delivered by CIBA push nameBackchannelAuthenticationas their endpoint; introspection still raises its failure event for an inactive token, soIsActiveis always true here.RedirectUriwas not taken in 0.146.0, because only the authorize endpoint's token events fill it and those were not raised yet; since 0.149.0 they are, with it. Not taken:UserLoginSuccessEvent.ProviderUserId,ConsentGrantedEvent.GrantedScopesandConsentRemembered, here since they shipped asProviderSubjectId,ScopesandRemembered;Event.Obfuscate, which the fingerprint replaces;DeviceAuthorizationFailureEvent.ErrorDescription, because that endpoint tells the client no description; Duende's constructors over its validation results. Found measuring: Duende's federatedUserLoginSuccessEventconstructor setsEndpointtoUIand then, falling through, toToken, so every federated sign-in there names the token endpoint; here an interactive sign-in namesUI.
The same token always gives the same fingerprint, so a revocation can be matched to the issuance that produced the token, and a host holding a token can compute its fingerprint to find it:
/// <summary>
/// Finds what the audit record says about a token a host holds — a support case, a token found in a log. No event
/// carries a token, only its fingerprint, and the same token always has the same one.
/// </summary>
public sealed class TokenAudit(AuditLog log)
{
/// <summary>The issuance that produced <paramref name="token"/>, if the log holds it.</summary>
public TokenIssuedSuccessEvent? FindIssuance(string token)
{
var fingerprint = TokenFingerprint.Compute(token); // "sha256:" and the base64url SHA-256 of the token
return log.All
.OfType<TokenIssuedSuccessEvent>()
.FirstOrDefault(issued => issued.Tokens.Any(issuedToken => issuedToken.TokenValue == fingerprint));
}
}
Where sign-in events come from
The success and failure events are raised by the shipped Razor pages, because signing a user in is the one thing this
library does not do — the page does. A host that replaces the login page raises them itself; IEventService is public
and takes the same types:
/// <summary>
/// What a host's own login page raises. The shipped pages raise these themselves; signing a user in is the page's
/// work, not the server's, so a page that replaces them raises them too.
/// </summary>
public sealed class SignInEvents(IEventService events)
{
/// <summary>A user was authenticated: raised once per sign-in, not once per cookie write.</summary>
public Task SignedInAsync(string username, string subjectId, string? displayName, string? clientId, CancellationToken ct)
=> events.RaiseAsync(new UserLoginSuccessEvent(username, subjectId, displayName) { ClientId = clientId }, ct);
/// <summary>
/// Credentials were refused. The username and no credential: a rejected password is still a password. And the
/// message says the sign-in was refused, not which half was wrong.
/// </summary>
public Task RefusedAsync(string username, string? clientId, CancellationToken ct)
=> events.RaiseAsync(new UserLoginFailureEvent(username, "Invalid credentials") { ClientId = clientId }, ct);
}
Sign-out is different: the end_session endpoint records it whoever owns the UI, because the protocol endpoint is the
server's.
Events are raised where a user is authenticated rather than wherever the sign-in cookie is written. Those are not the
same place — DefaultUserSession re-issues the cookie for each client the session authenticates (and, for a session
signed in before 0.174.0, to persist the session id it was not given at sign-in) — so a host counting sign-ins is counting
sign-ins and not cookie writes.
What a refused sign-in leaves behind
New in 0.35.0, and before it the answer was nothing at all. Wrong credentials on the login page set a message and re-rendered — no event, and no log line either — so a run of guessed passwords was invisible to anything a host could deploy.
UserLoginFailureEventis what a rate limiter or an alert counts, with the caller's address filled in by the event service.
The failure event carries the username and no credential. A rejected password is still a password, very often the right one for some other account, and an event goes somewhere with a wider audience than the server's own logs. For the same reason its message says a sign-in was refused rather than which half was wrong.
Sign-out has three paths and only one of them is a page
A user clicking the logout button, a client ending the session at end_session with a verified id_token_hint, and a
SAML service provider posting a signed LogoutRequest all end the same session (signing out). All
three raise UserLogoutSuccessEvent, so a host asking when somebody's session ended gets one answer rather than one per
protocol. A request from a browser that was never signed in ends nothing and raises nothing.
Sessions
A session ends three ways and only one of them was anybody's decision
New in 0.57.0. Until it, a person signing out was recorded and the other two ways were not. A host ending somebody else's session through
ISessionManagementService, and the sweep removing one that expired, both deleted the row, revoked the grants and notified the relying parties — and told the host nothing. The administrative one is the action an operator is most likely to be asked to account for afterwards, and it was the one with no trace.
| What happened | Event |
|---|---|
| a person signs out | UserLogoutSuccessEvent |
| a host ends their session | SessionRevokedEvent |
| the clock ends it | SessionExpiredEvent |
Three events rather than one with a reason field, because these are three different facts and an operator does
something different about a rise in each: nothing, a security response, or a session lifetime that wants looking at. A
host keys its alerting on EventIds, so a reason field would put the distinction somewhere no alert rule can reach.
Both new ones stay in the Authentication category. SAML earned a category of its own because an operator filtering the
stream is asking about one protocol or the other; a session ending is the same surface as a session beginning whatever
ended it, and a new category would mean the authentication filter stopped showing every way a session finishes.
Each is raised once per session, never once per call or once per sweep. A filter may match several of a subject's
sessions, and "three sessions were ended" is not the same fact as "a session was ended"; a sweep that raised one event
per pass would report an idle deployment as a steady stream of ending sessions. Both are raised before the cleanup that
follows, because by then the row is already gone — so the session has ended whatever happens next, and a relying party
that cannot be reached must not turn that into silence. BackChannelLogoutFailureEvent is what says the notification
failed.
GrantsRevokedEvent gained its second raise site in the same release. Two places in this server remove every grant
matching a filter: DefaultPersistedGrantService, which has raised the event since 0.36.0, and a session ending, which
took the same grants away and said nothing. Its ClientId is null there, because every client of that session went
rather than one.
A session this server ended and a client still believes in
BackChannelLogoutFailureEvent is raised once for each relying party a logout token could not be delivered to. The
sign-out itself succeeded — the cookie is gone and cannot be brought back — so the notification failure is reported
rather than thrown, and until 0.51.0 it was reported only to the log. What the event names is a session this server
believes is over and a relying party believes is live, which is the state somebody has to find before a user reports it.
A caller that cancels mid-notification raises nothing: that is the caller's decision, and recording it against a relying party would put a fault in the audit record that never happened.
Grants
Three screens ask a person to approve scopes
The consent page, the device page and the CIBA page, and all three raise the same pair. That is not symmetry for its own sake: a defect in six ignored consent members was fixed on one of two screens and stayed half-closed for seven releases, because nobody counted the screens.
ConsentGrantedEvent.Scopes is what was granted, never what the client asked for (that is RequestedScopes). Those
differ whenever somebody unticks a box, and an event carrying the request would tell a host that a user agreed to
something they declined.
Clicking Allow with nothing ticked is not one answer but two, and the events say which:
- A required scope has no checkbox to untick — the row renders disabled and posts nothing, and it is added back — so the answer is a grant of exactly the scope the user was never offered a choice about.
- When nothing was required, the grant is empty, and all three screens answer that as
access_deniedrather than storing a grant of nothing.ConsentDeniedEventagrees with them: what is recorded is the answer, not the button.
Only a decision the server accepted is recorded. A user code or a backchannel request that stopped being live between the page load and the post decided nothing, and a client that will never receive a grant must not appear to have one.
GrantsRevokedEvent covers the other direction — every token, refresh handle and stored consent the filter matches
(managing a subject's grants). It is raised in DefaultPersistedGrantService
rather than in the interaction service above it, because that service delegates both of its revoking members here: an
event higher up would miss every caller that has a subject id and no request, which is the reason the service exists.
ClientId is null when the whole subject went, because "one application was disconnected" and "everything this user had
was revoked" are different events. It is raised after the removal and after the guard that refuses an empty subject
— a record of a revocation that never ran is the one somebody would rely on afterwards.
Credentials in the wrong hands
A credential presented twice
RefreshTokenReplayDetectedEvent has existed since 0.11.0 on a simple reasoning: a handle presented twice is not a
client that is broken, it is somebody using a copy. This server detects the same thing for DPoP proofs and, until
0.38.0, refused them in silence — one replay had an event and the other did not.
Both are Information rather than Failure. The request was refused correctly; what a host needs told is not that
something failed but that a credential exists in two places.
The DPoP event is raised for replay alone, not for every proof this server turns down. A malformed proof is a broken
client and a stale one is usually a drifting clock, and reporting those under the same name is how the signal that means
somebody has a copy stops being a signal. It also names no client, because DPoPProofValidationContext carries none —
nothing in validating a proof needs one, and a member added to hold a value the raise site cannot fill is exactly the
defect this codebase keeps finding. The address and activity id the event service fills in are what a host counting
replays per source groups on.
A bound token presented by somebody it was not issued to
SenderConstraintViolationEvent, new in 0.63.0. A token carrying a cnf claim names a key or a certificate; a
request that presents it without demonstrating possession of that key or certificate is, by construction, a token being
used by somebody it was not issued to. This server has refused that since 0.45.0 — correctly, six different ways — and
until 0.63.0 told nobody at all. Not an event, and not a log line either, because the class held no logger. A host
watching for stolen tokens saw an ordinary 401.
It is the third of a set that was two: this server detects the misuse of three credentials — a refresh token replayed after consumption, a DPoP proof replayed, and a bound token presented without its binding — and shipped events for the first two. The detection was built and the audit half was not.
The Reason says which of the six, and is diagnostic rather than an alerting key: all six mean one thing and prompt one
investigation. That is the opposite call from the three session events above, made for the opposite reason — there an
operator does something different about each. It names the client and subject the token was issued to, because the
useful question is whose token somebody else is holding, and it never carries the token itself.
Two requests that both answer active: false
One is routine: a token that expired, or was revoked, or was never issued here. The other is an API asking about a token
that was issued for a different API — the refusal 0.31.0 introduced, and the one somebody would want to look at
twice. RFC 7662 has the introspection endpoint report on the token rather than on the caller, so both are a flat 200
(introspection), and TokenIntrospectionFailureEvent carrying the reason is what tells
them apart. The reason never reaches the response: an API told why a token is nothing it can use has been told
something about a token it is not entitled to know anything about.
Neither introspection event carries the token, and neither device event carries the user code or the device code. All three are live credentials, and an event describing a refused request is exactly the one a host forwards somewhere with a wider audience than its own logs.
A reference that came back from the wrong client
New in 0.38.0, and it closes something this project introduced. 0.36.0 went looking for refusals that leave no trace and found one 0.31.0 had created. 0.37.0 then created another: the
request_uriownership check refuses a reference presented by a client that did not push it, and told nobody.
Of the ways a pushed reference fails, three are somebody holding a stale link — expired, already redeemed, never issued,
all ordinary things to be holding. The fourth is a value that has travelled between clients
(one client's artifacts are not another's), and it is the one worth looking at twice. All four
raise PushedAuthorizationFailureEvent, with the reason on the event; the browser is sent to the error page either way,
because which of them it was is a question about the client and not about the person holding the link.
New in 0.54.0, a fifth refusal that is not about a reference at all. A client required to push — by
Client.RequirePushedAuthorizationor byPushedAuthorization.Requiredserver-wide — that arrives at the authorize endpoint with no reference is refused, and until 0.54.0 that refusal raised nothing. The funnel every other refusal goes through was calledRefusedReferenceAsync, and its own remarks claimed that "every rejection in the resolver above goes through here, so a path added later cannot forget the event". The name is how the exception survived being read: a must-push refusal is not a reference refusal, so routing it through that method looked wrong and leaving it out looked right. It is nowRefusedAsyncand the claim is structural.
What it cost is worth naming, because it is the moment a host most needs the events: turning
PushedAuthorization.Required on server-wide (pushed authorization) shows up as a run of
clients failing at the front channel, with nothing in the event stream saying why. The sample requires pushing, and its
test sees the refusal of a front-channel request recorded.
The return-URL parser resolves the same references, to tell the login and consent pages which application is asking, and deliberately raises nothing: it runs once per render, so an event there would report one refused request several times and one resumed request as several successes.
SAML
The other protocol is audited too
SamlSignOnSuccessEvent is the SAML half of TokenIssuedSuccessEvent, and it is the same act: this server vouching for
a user's identity to a third party. It names the subject, the service provider, the reply address the assertion was
posted to, and the request id it answers — enough to join the two halves of an exchange in a log store. The assertion
itself is absent for the reason the token is absent from its OIDC counterpart: a signed assertion in a log store is a
credential.
SamlSignOnFailureEvent covers all six ways the endpoint refuses, including the one that is delivered to the service
provider as a signed NoPassive response rather than shown to a person. Its ServiceProviderEntityId is nullable, and
null is the interesting case: a request this server could not read far enough to attribute is a different thing from a
registered partner sending something wrong, and only this field tells them apart.
That covered one cell of four, and the sentence that used to stand in the documentation is why nobody noticed. It
read: "Single logout and the SAML SP side were already covered and are not new here — SLO raises
UserLogoutSuccessEvent through the shared sign-out sequence, and an inbound assertion reaches UserLoginSuccessEvent
through the external-login page." Every word of that is true, and all of it is about successes. A refused assertion
never reaches the external-login page and a refused logout never reaches the sign-out sequence, so the routes carrying
the successes carried none of the failures — and a true sentence sat where the gap was.
SAML has two roles and two operations. As of 0.63.0 all four record their refusals:
- as the identity provider, refusing a sign-on —
SamlSignOnFailureEvent(0.51.0); - as a service provider, refusing an assertion —
SamlAssertionRejectedEvent; - as a service provider, refusing an inbound
LogoutRequest—SamlLogoutFailureEvent; - as the identity provider, refusing a service provider's logout message — the same event.
Since 0.178.0 the service provider refuses more, and each refusal is the same event naming its code:
correlation_failed, scheme_mismatch, message_too_large, assertion_replayed, no_audience_restriction,
no_bearer_confirmation, no_recipient, no_subject_confirmation_expiry and destination_mismatch for an assertion;
message_too_large, destination_required, invalid_destination, request_expired, request_not_yet_valid,
replayed_request and unknown_principal for an inbound LogoutRequest
(the service provider). In a host that registers no IEventService — the handler
without an identity server — nothing is raised; until 0.178.0 each refusal there was an exception instead.
SamlAssertionRejectedEvent is a separate type from SamlSignOnFailureEvent rather than a reuse, because the peer it
names is an identity provider: carrying that under ServiceProviderEntityId would be a claim crossing a boundary under
the wrong side's name. It names the provider the scheme is configured to trust, not one read out of the message — a
refused response is exactly the case where what the message says about itself cannot be relied on.
SamlSingleLogoutSuccessEvent is raised where the sequence ends, not where the session does, and the difference is
the whole reason it exists. Single logout is a sequence of browser hops (signing out):
the session ends on the first one, which UserLogoutSuccessEvent has recorded since 0.41.0, and the last participant is
answered several hops later — or never, if the browser does not come back. Both leave exactly one
UserLogoutSuccessEvent, so until 0.65.0 there was no way to tell a logout that completed from one that stalled. It is
raised even when the service provider that asked cannot be told the answer, because the logout did complete; the partner
left uninformed is the other event's business.
SamlLogoutNotificationFailureEvent names the SAML twin of what BackChannelLogoutFailureEvent names — a session
this server believes is over and a partner believes is live. A participant that cannot be sent a LogoutRequest is
skipped, which is correct: holding the sequence open for it would leave the requester unanswered forever. Before 0.65.0
the only record of the skip was a loop iteration ending early. It carries a reason — the registry no longer holds the
partner, the registration has no logout URL, or it has one this server will not navigate a browser to — because all
three mean the same investigation and differ only in which registration to fix. The same event covers the service
provider role, where SamlSpOptions.IdpSingleLogoutUrl is unset and the session upstream outlives the one here.
SamlLogoutFailureEvent is one type for both roles, because a rise in these prompts the same investigation whichever
side it came from, and PeerEntityId and Scheme say which partner to look at. A request carrying no SAML message at
all records nothing, deliberately: the endpoint is named in published metadata, so it is reached by crawlers and
scanners, and an event per drive-by turns the record into noise.
When the server is the thing that broke
New in 0.36.0.
RaiseErrorEventshas existed since 0.12.0 andDefaultEventServiceread it from the start, but no event in the tree was ever of typeError— the flag gated a category with no members, so a host could switch it on, register a sink and correctly receive nothing forever.
UnhandledExceptionEvent is raised where the protocol endpoints are dispatched, which is the one place that sees a fault
from any of them, and the exception is then rethrown. An event is a record, not a handler: swallowing the exception
in order to report it would take away the host's own logging, its error page and the status code the caller is owed. A
caller that went away mid-request is not a fault and raises nothing.
The exception's type and message travel; the stack trace does not, unless IncludeStackTraceInUnhandledExceptionEvents
says so. A stack names this server's internals and sometimes the values it was working on, and the host has already been
handed the exception itself.