Skip to content

UserSession.ExpirationDateTime is set/reset using the wrong lifetime during token refresh, causing SSO sessions to expire far earlier than UserCookieExpirationTimeInSeconds configured #960

Description

@vkukarau

Summary

UserSession.ExpirationDateTime is supposed to track the SSO/cookie session lifetime (Client.UserCookieExpirationTimeInSeconds), set at login by BaseAuthenticateController.AddSession(). However, two separate code paths that legitimately renew tokens for an existing session fail to maintain this correctly:

  1. RefreshTokenHandler extends the session using the wrong Client property (access-token lifetime instead of cookie lifetime).
  2. AuthorizationCodeHandler doesn't extend/touch the session's ExpirationDateTime at all when reusing an existing SessionId.

Both issues together mean that even when following the maintainers' own documented guidance (UserCookieExpirationTimeInSeconds >> TokenExpirationTimeInSeconds, see #835 / #898), the persisted session still ends up expiring at roughly the access-token lifetime shortly after login — the long cookie value configured on the client has no lasting effect once a single refresh occurs.

Bug 1 — RefreshTokenHandler uses TokenExpirationTimeInSeconds instead of UserCookieExpirationTimeInSeconds

In Api/Token/Handlers/RefreshTokenHandler.cs:

async Task Authenticate(JsonObject previousQueryParameters, HandlerContext handlerContext, Domains.Token tokenResult, CancellationToken token)
{
    ...
    session = await _userSessionRepository.GetById(tokenResult.SessionId, ..., token);
    if (session != null && !session.IsActive()) session = null;
    else
    {
        var currentDateTime = DateTime.UtcNow;
        var expirationTimeInSeconds = GetExpirationTimeInSeconds(handlerContext.Client);   // uses TokenExpirationTimeInSeconds
        session.ExpirationDateTime = currentDateTime.AddSeconds(expirationTimeInSeconds);
        _userSessionRepository.Update(session);
        await transaction.Commit(token);
    }
}

double GetExpirationTimeInSeconds(Client client)
{
    return client == null || client.TokenExpirationTimeInSeconds == null
        ? Options.DefaultTokenExpirationTimeInSeconds
        : client.TokenExpirationTimeInSeconds.Value;   // <-- should be UserCookieExpirationTimeInSeconds
}

Compare with how the session is created at login, in UI/BaseAuthenticateController.cs:

protected async Task AddSession(...)
{
    var expirationTimeInSeconds = GetCookieExpirationTimeInSeconds(client);   // uses UserCookieExpirationTimeInSeconds
    var expirationDateTime = currentDateTime.AddSeconds(expirationTimeInSeconds);
    var session = new UserSession { ..., ExpirationDateTime = expirationDateTime, ... };
    ...
}

private double GetCookieExpirationTimeInSeconds(Client client)
{
    return client == null || client.UserCookieExpirationTimeInSeconds == null
        ? _options.DefaultTokenExpirationTimeInSeconds
        : client.UserCookieExpirationTimeInSeconds.Value;
}

So the session's expiration is initialized from UserCookieExpirationTimeInSeconds, but every subsequent refresh_token grant silently overwrites it using TokenExpirationTimeInSeconds instead — the exact same class of bug already fixed once in #835, but reintroduced specifically in the refresh path.

Bug 2 — AuthorizationCodeHandler never updates ExpirationDateTime when reusing an existing session

In Api/Token/Handlers/AuthorizationCodeHandler.cs:

async Task Authenticate(JsonObject previousQueryParameters, HandlerContext handlerContext, AuthCode authCode, CancellationToken token)
{
    ...
    UserSession session = null;
    if (!string.IsNullOrWhiteSpace(authCode.SessionId))
    {
        session = await _userSessionRepository.GetById(authCode.SessionId, handlerContext.Realm, token);
        if (session != null && !session.IsActive()) session = null;
    }
    handlerContext.SetUser(user, session);   // reads/validates session, never calls _userSessionRepository.Update(...)
}

Any silent renewal that goes through the authorization_code grant while reusing an existing SSO cookie/session (e.g. useSilentRefresh in angular-oauth2-oidc, or a full page reload that re-triggers /authorize) leaves the session's stored ExpirationDateTime completely untouched, even though a brand-new, fully valid access/id/refresh token is issued for that session.

Reproduction

Client config: TokenExpirationTimeInSeconds = 300 (5 min), UserCookieExpirationTimeInSeconds = 1800 (30 min).

  1. t=0: User logs in. UserSession.ExpirationDateTime = t+1800 (30 min), per AddSession().
  2. t=250: Client performs a refresh_token grant (proactive refresh before 5-min access token expires). RefreshTokenHandler sets UserSession.ExpirationDateTime = t+250+300 = t+550 — already far short of the intended 30-minute session, due to Bug 1.
  3. t=300: Browser does a full page reload. The OIDC client re-triggers /authorize with the existing SSO cookie (prompt=none), which succeeds and exchanges an authorization_code for a fresh token via AuthorizationCodeHandler. Per Bug 2, UserSession.ExpirationDateTime remains frozen at t+550 — not extended at all, despite the user obtaining a perfectly valid new token.
  4. t=560 (past the stale t+550 deadline, but well within the intended 30-minute session and within the actual token's/refresh token's real validity): Client attempts another refresh_token grant. RefreshTokenHandler calls _userSessionRepository.GetById(...), finds the session, but session.IsActive() returns false (DateTime.UtcNow > ExpirationDateTime), so session = null — the request proceeds as if there is no session, even though the user has been continuously, legitimately authenticated the entire time.

Expected behavior

UserSession.ExpirationDateTime should be driven exclusively by Client.UserCookieExpirationTimeInSeconds (never TokenExpirationTimeInSeconds/RefreshTokenExpirationTimeInSeconds), and should be refreshed/extended on every legitimate proof of continued session — both refresh_token grants (fixing GetExpirationTimeInSeconds in RefreshTokenHandler) and authorization_code grants that reuse an existing SessionId (adding an Update() call in AuthorizationCodeHandler.Authenticate).

Environment

  • SimpleIdServer.IdServer NuGet package, v7.0.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions