Skip to content

Add server-side OpenID Connect sessions - #902

Merged
KrzysztofPajak merged 15 commits into
developfrom
feature/oidc-sessions-core
Oct 13, 2026
Merged

KrzysztofPajak merged 15 commits into
developfrom
feature/oidc-sessions-core

Conversation

@KrzysztofPajak

Copy link
Copy Markdown
Member

Resolves no public issue
Type: feature

Issue

OpenID Connect browser sign-in currently relies on the authentication cookie and provider tokens without a server-side session record. GrandNode cannot centrally end provider-backed sessions, clean up expired session data, revoke refresh tokens after subject revocation, or consistently revalidate long-lived back-office sessions against the identity provider.

Solution

Add core OpenID Connect session support: a persisted OidcSession entity, session service, token revocation service, cookie session claim, cleanup scheduled task, migration, default configuration, and tests around session lifecycle, refresh token handling, provider revalidation, and LiteDB no-match updates.

Breaking changes

None expected for existing installations. The change adds new persisted session data, settings, migration, and service contracts used by the OIDC flow.

Testing

Not run during PR creation.

  1. Run dotnet test src/Tests/Grand.Business.Authentication.Tests/Grand.Business.Authentication.Tests.csproj --filter OidcSessionServiceTests.
  2. Run dotnet test src/Tests/Grand.Data.Tests/Grand.Data.Tests.csproj --filter LiteDbRepositoryTests.
  3. Run dotnet test src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj --filter DeleteOidcSessionsScheduleTaskTests.
  4. Configure OpenID Connect login, sign in a customer, confirm the authentication cookie references a server-side session, and confirm sign-out ends the session without keeping a refresh token.

…d in again

A re-sign-in after a password, e-mail or username change used the two-argument
SignIn, which dropped grand:oidcSession, so the new cookie escaped idle and
maximum lifetime, revalidation and back-channel logout. The two-argument SignIn
now carries the session of the current cookie over when it belongs to the same
customer.
If ending the server-side session threw, SignOutAsync never ran and the customer
stayed signed in. The failure is now logged as a warning with the session id and
both schemes are always signed out.
It dereferenced the missing entity and threw, while MongoDB treats no match as a
no-op. Conditional updates such as ending an OpenID Connect session that has
already ended rely on the MongoDB behavior.
Copilot AI balanced review requested due to automatic review settings October 12, 2026 15:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment on lines +172 to +177
catch (Exception ex)
{
//no cancellation token is passed, so a cancellation is a provider timeout: no answer, like any failure
_logger.LogWarning("Refreshing session {SessionId} failed: {ExceptionType}", session.Id, ex.GetType().Name);
result = OidcRefreshResult.Unavailable();
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. Any failure of the refresher (network, timeout, a provider answering with something unparsable) has to count as "no answer" (Unavailable). The session then continues for up to two revalidation intervals instead of failing the request. The exception type is logged. The message and body are not, because they can contain the refresh token.

Comment on lines +37 to +40
catch (Exception ex)
{
logService.LogError(ex, "UpgradeProcess - OidcSessions indexes (2.5)");
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional, and the same pattern as the other upgrade migrations: a failure is logged and the remaining steps still run. An index that could not be created only costs query speed and does not block the upgrade.

Comment on lines +49 to +52
catch (Exception ex)
{
logService.LogError(ex, "UpgradeProcess - OidcSessions schedule task (2.5)");
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: the schedule task insert is logged on failure, like in the other upgrade migrations, so one failed step does not abort the whole upgrade.

@KrzysztofPajak

Copy link
Copy Markdown
Member Author

Code review finding:\n\nFile and line: src/Business/Grand.Business.Authentication/Services/OidcSessionService.cs:32\n\nRule violated: .ai/constraints.md forbids static mutable state.\n\nImpact: RefreshesInProgress is process-local mutable state. It does not protect refresh-token rotation across multiple app instances, so two instances can still refresh the same session concurrently and one can prematurely end the session. It also creates hidden cross-request/test state.\n\nSuggested fix: move the refresh-in-progress claim into persisted state, for example a short session refresh lease updated atomically through the repository, or document and implement an explicit distributed-safe strategy.\n\nReview scope: security, .NET, architecture, scheduled task, database/MongoDB, plugin/module rules, and repo code-review checklist. I did not run the test suites.

Comment on lines +290 to +294
catch (Exception ex)
{
//the cookie that referenced it is replaced anyway; the record goes with the cleanup task
_logger.LogWarning(ex, "Could not end the OpenID Connect session {SessionId}", sessionId);
}
@KrzysztofPajak

Copy link
Copy Markdown
Member Author

Agreed, fixed in f6aa28d.

The process-local RefreshesInProgress dictionary is gone. A refresh now takes a lease stored on the session (RefreshLeaseId, RefreshLeaseUntilUtc): a conditional update takes it only when it is free or has lapsed, and the session is then read back to check who holds it. At any moment one request refreshes a session, across every instance. Other requests continue while the last confirmation is within the grace period. A lease left by a dead holder lapses after 30 seconds. The lease is released in finally. Tests cover a lease held elsewhere (within and past grace), a lapsed lease, and the existing concurrent-request case.

The same commit also fixes three related points:

  • Access tokens are revoked only when the provider actually refuses the refresh token. A session that cannot be confirmed locally (no refresher, login not configured, stored token unreadable after a Data Protection key change) now gets the new NotAsked outcome. It still ends, but the subject's tokens are not revoked.
  • The cleanup task deletes open sessions not confirmed at the provider for 30 days, together with their refresh token.
  • d0eeff9: a new provider sign-in of the same customer ends the session it replaces.

The section on limits in the guide no longer lists the multi-instance race.

@KrzysztofPajak
KrzysztofPajak merged commit 3cacf00 into develop Oct 13, 2026
6 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/oidc-sessions-core branch October 13, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants