Skip to content

Commit 036867e

Browse files
authored
Fix Sliding Expiration on Refresh Flow (#70)
1 parent 34e07f6 commit 036867e

16 files changed

Lines changed: 987 additions & 162 deletions

File tree

‎samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,17 @@
2929

3030
builder.Services.AddUltimateAuthServer(o => {
3131
o.Diagnostics.EnableRefreshDetails = true;
32+
o.Login.MaxFailedAttempts = 2;
33+
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
34+
o.Identifiers.Behavior.AllowMultipleUsernames = true;
35+
o.UserProfile.EnableMultiProfile = true;
36+
3237
//o.Session.MaxLifetime = TimeSpan.FromSeconds(32);
3338
//o.Session.Lifetime = TimeSpan.FromSeconds(32);
3439
//o.Session.TouchInterval = TimeSpan.FromSeconds(9);
3540
//o.Session.IdleTimeout = TimeSpan.FromSeconds(15);
3641
//o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30);
3742
//o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32);
38-
o.Login.MaxFailedAttempts = 2;
39-
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
40-
o.Identifiers.Behavior.AllowMultipleUsernames = true;
41-
o.UserProfile.EnableMultiProfile = true;
4243
})
4344
.AddUltimateAuthInMemory()
4445
.AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL

‎samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,29 @@
4343
builder.Services.AddUltimateAuthServer(o =>
4444
{
4545
o.Diagnostics.EnableRefreshDetails = true;
46+
o.Login.MaxFailedAttempts = 2;
47+
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
48+
o.Identifiers.Behavior.AllowMultipleUsernames = true;
49+
50+
// No-op and touch with max lifetime example
4651
//o.Session.MaxLifetime = TimeSpan.FromSeconds(32);
4752
//o.Session.Lifetime = TimeSpan.FromSeconds(32);
4853
//o.Session.TouchInterval = TimeSpan.FromSeconds(9);
4954
//o.Session.IdleTimeout = TimeSpan.FromSeconds(15);
5055
//o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30);
5156
//o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32);
52-
o.Login.MaxFailedAttempts = 2;
53-
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
54-
o.Identifiers.Behavior.AllowMultipleUsernames = true;
57+
58+
// Infinite sliding expiration example
59+
//o.Session.MaxLifetime = null;
60+
//o.Session.Lifetime = TimeSpan.FromSeconds(20);
61+
//o.Session.TouchInterval = TimeSpan.FromSeconds(9);
62+
//o.Session.IdleTimeout = TimeSpan.FromSeconds(10);
5563
})
5664
.AddUltimateAuthInMemory();
5765

5866
builder.Services.AddUltimateAuthClientBlazor(o =>
5967
{
60-
//o.AutoRefresh.Interval = TimeSpan.FromSeconds(5);
68+
o.AutoRefresh.Interval = TimeSpan.FromSeconds(5);
6169
o.Reauth.Behavior = ReauthBehavior.RaiseEvent;
6270
//o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate;
6371
});

‎samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
o.Reauth.Behavior = ReauthBehavior.RaiseEvent;
2828
o.Login.AllowCredentialPost = true;
2929
o.Pkce.ReturnUrl = "https://localhost:6130/home"; // This application domain + path
30+
31+
o.AutoRefresh.Interval = TimeSpan.FromSeconds(5);
3032
});
3133

3234
builder.Services.AddScoped<ProductApiService>();

‎src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,27 @@ public SessionState GetState(DateTimeOffset at)
139139
return SessionState.Active;
140140
}
141141

142+
public UAuthSession ExtendExpiration(DateTimeOffset expiresAt)
143+
{
144+
if (expiresAt <= ExpiresAt)
145+
return this;
146+
147+
return new UAuthSession(
148+
SessionId,
149+
Tenant,
150+
UserKey,
151+
ChainId,
152+
CreatedAt,
153+
expiresAt,
154+
RevokedAt,
155+
SecurityVersionAtCreation,
156+
Device,
157+
Claims,
158+
Metadata,
159+
Version + 1
160+
);
161+
}
162+
142163
public UAuthSession WithChain(SessionChainId chainId)
143164
{
144165
if (!ChainId.IsUnassigned)

‎src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using CodeBeam.UltimateAuth.Core.Abstractions;
1+
using CodeBeam.UltimateAuth.Core;
2+
using CodeBeam.UltimateAuth.Core.Abstractions;
23
using CodeBeam.UltimateAuth.Core.Contracts;
34
using CodeBeam.UltimateAuth.Core.Domain;
45
using CodeBeam.UltimateAuth.Core.Infrastructure;
@@ -69,6 +70,8 @@ public async ValueTask<AuthFlowContext> CreateAsync(HttpContext ctx, AuthFlowTyp
6970
var returnUrlInfo = ReturnUrlParser.Parse(returnUrl);
7071

7172
SessionSecurityContext? sessionSecurityContext = null;
73+
var isAuthenticated = user?.IsAuthenticated ?? false;
74+
var userKey = user?.UserId;
7275

7376
if (!sessionCtx.IsAnonymous)
7477
{
@@ -84,6 +87,19 @@ public async ValueTask<AuthFlowContext> CreateAsync(HttpContext ctx, AuthFlowTyp
8487
ct);
8588

8689
sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation);
90+
91+
if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid)
92+
{
93+
isAuthenticated = validation.IsValid;
94+
95+
if (!isAuthenticated)
96+
userKey = null;
97+
}
98+
}
99+
else if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid)
100+
{
101+
isAuthenticated = false;
102+
userKey = null;
87103
}
88104

89105
if (!tenant.IsValid || tenant.IsUnresolved)
@@ -98,8 +114,8 @@ public async ValueTask<AuthFlowContext> CreateAsync(HttpContext ctx, AuthFlowTyp
98114
effectiveMode,
99115
deviceContext,
100116
tenant,
101-
user?.IsAuthenticated ?? false,
102-
user?.UserId,
117+
isAuthenticated,
118+
userKey,
103119
sessionSecurityContext,
104120
options,
105121
response,

‎src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@
33
public sealed class SessionTouchPolicy
44
{
55
public TimeSpan? TouchInterval { get; init; }
6+
public TimeSpan Lifetime { get; init; }
7+
public bool SlidingExpiration { get; init; }
8+
public TimeSpan? IdleTimeout { get; init; }
69
}
Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using CodeBeam.UltimateAuth.Core.Abstractions;
22
using CodeBeam.UltimateAuth.Core.Contracts;
3+
using CodeBeam.UltimateAuth.Core.Domain;
34

45
namespace CodeBeam.UltimateAuth.Server.Flows;
56

@@ -14,35 +15,73 @@ public SessionTouchService(ISessionStoreFactory kernelFactory)
1415

1516
// It's designed for PureOpaque sessions, which do not issue new refresh tokens on refresh.
1617
// That's why the service access store direcly: There is no security flow here, only validate and touch session.
17-
public async Task<SessionRefreshResult> RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default)
18+
public async Task<SessionRefreshResult> RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode,
19+
DateTimeOffset now, CancellationToken ct = default)
1820
{
19-
if (!validation.IsValid || validation.ChainId is null)
21+
if (!validation.IsValid || validation.SessionId is null || validation.ChainId is null)
22+
{
2023
return SessionRefreshResult.ReauthRequired();
21-
22-
if (!policy.TouchInterval.HasValue)
23-
return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false);
24+
}
2425

2526
var kernel = _kernelFactory.Create(validation.Tenant);
2627

28+
bool isValid = false;
2729
bool didTouch = false;
2830

29-
await kernel.ExecuteAsync(async _ =>
31+
await kernel.ExecuteAsync(async innerCt =>
3032
{
31-
var chain = await kernel.GetChainAsync(validation.ChainId.Value);
33+
var chain = await kernel.GetChainAsync(validation.ChainId.Value, innerCt);
34+
var session = await kernel.GetSessionAsync(validation.SessionId.Value, innerCt);
35+
36+
if (chain is null || session is null)
37+
return;
3238

33-
if (chain is null || chain.IsRevoked)
39+
if (chain.GetState(now, policy.IdleTimeout) != SessionState.Active || session.GetState(now) != SessionState.Active)
3440
return;
3541

36-
if (now - chain.LastSeenAt < policy.TouchInterval.Value)
42+
if (chain.ActiveSessionId != session.SessionId ||
43+
session.ChainId != chain.ChainId ||
44+
session.Tenant != validation.Tenant ||
45+
chain.Tenant != validation.Tenant ||
46+
session.UserKey != chain.UserKey)
3747
return;
3848

39-
var expectedVersion = chain.Version;
40-
var touched = chain.Touch(now);
49+
if (validation.UserKey != session.UserKey || validation.ChainId != session.ChainId)
50+
return;
51+
52+
isValid = true;
53+
54+
bool shouldTouch = sessionTouchMode == SessionTouchMode.Force || !policy.TouchInterval.HasValue || now - chain.LastSeenAt >= policy.TouchInterval.Value;
55+
56+
if (!shouldTouch)
57+
return;
58+
59+
var touchedChain = chain.Touch(now);
60+
61+
if (policy.SlidingExpiration)
62+
{
63+
var proposedExpiry = now.Add(policy.Lifetime);
64+
65+
var newExpiry = chain.AbsoluteExpiresAt is { } absolute
66+
&& proposedExpiry > absolute
67+
? absolute
68+
: proposedExpiry;
69+
70+
var extendedSession = session.ExtendExpiration(newExpiry);
71+
72+
if (!ReferenceEquals(extendedSession, session))
73+
{
74+
await kernel.SaveSessionAsync(extendedSession, session.Version, innerCt);
75+
}
76+
}
77+
78+
await kernel.SaveChainAsync(touchedChain, chain.Version, innerCt);
4179

42-
await kernel.SaveChainAsync(touched, expectedVersion);
4380
didTouch = true;
4481
}, ct);
4582

46-
return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch);
83+
return isValid
84+
? SessionRefreshResult.Success(validation.SessionId.Value, didTouch)
85+
: SessionRefreshResult.ReauthRequired();
4786
}
4887
}

‎src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@ public async Task<IssuedSession> IssueSessionAsync(SessionIssuanceContext contex
3333
if (!AuthSessionId.TryCreate(opaqueSessionId, out AuthSessionId sessionId))
3434
throw new InvalidCastException("Can't create opaque id.");
3535

36-
var expiresAt = now.Add(sessionOptions.Lifetime);
36+
var absoluteExpiresAt = sessionOptions.MaxLifetime is { } max
37+
? now.Add(max)
38+
: (DateTimeOffset?)null;
3739

38-
if (sessionOptions.MaxLifetime is not null)
40+
var sessionExpiresAt = now.Add(sessionOptions.Lifetime);
41+
42+
if (absoluteExpiresAt is { } absolute && sessionExpiresAt > absolute)
3943
{
40-
var absoluteExpiry = now.Add(sessionOptions.MaxLifetime.Value);
41-
if (absoluteExpiry < expiresAt)
42-
expiresAt = absoluteExpiry;
44+
sessionExpiresAt = absolute;
4345
}
4446

4547
var kernel = _storeFactory.Create(context.Tenant);
@@ -76,7 +78,7 @@ await kernel.ExecuteAsync(async _ =>
7678
context.Tenant,
7779
context.UserKey,
7880
now,
79-
expiresAt,
81+
absoluteExpiresAt,
8082
context.Device,
8183
ClaimsSnapshot.Empty,
8284
root.SecurityVersion
@@ -110,7 +112,7 @@ await kernel.ExecuteAsync(async _ =>
110112
context.Tenant,
111113
context.UserKey,
112114
now,
113-
expiresAt,
115+
absoluteExpiresAt,
114116
context.Device,
115117
ClaimsSnapshot.Empty,
116118
root.SecurityVersion
@@ -141,7 +143,7 @@ await kernel.ExecuteAsync(async _ =>
141143
userKey: context.UserKey,
142144
chainId: chain.ChainId,
143145
now: now,
144-
expiresAt: expiresAt,
146+
expiresAt: sessionExpiresAt,
145147
securityVersion: root.SecurityVersion,
146148
device: context.Device,
147149
claims: context.Claims,

‎src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o)
3838
var c = o.Cookie;
3939
var r = o.AuthResponse;
4040

41-
// Session behavior
42-
s.SlidingExpiration = true;
41+
//s.SlidingExpiration = true; // Default value.
4342

4443
// Default: long-lived idle session (UX friendly)
4544
s.IdleTimeout ??= TimeSpan.FromDays(7);
@@ -75,7 +74,7 @@ private static void ApplyHybridDefaults(UAuthServerOptions o)
7574
var c = o.Cookie;
7675
var r = o.AuthResponse;
7776

78-
s.SlidingExpiration = true;
77+
//s.SlidingExpiration = true; // Default value.
7978
s.TouchInterval = null;
8079

8180
t.IssueJwt = true;

‎src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,21 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
4343
{
4444
if (options.Session.Lifetime <= TimeSpan.Zero)
4545
{
46-
return ValidateOptionsResult.Fail(
47-
"Session.Lifetime must be greater than zero.");
46+
return ValidateOptionsResult.Fail("Session.Lifetime must be greater than zero.");
4847
}
4948

50-
if (options.Session.MaxLifetime is not null &&
51-
options.Session.MaxLifetime <= TimeSpan.Zero)
49+
if (options.Session.MaxLifetime is not null && options.Session.MaxLifetime <= TimeSpan.Zero)
5250
{
53-
return ValidateOptionsResult.Fail(
54-
"Session.MaxLifetime must be greater than zero when specified.");
51+
return ValidateOptionsResult.Fail("Session.MaxLifetime must be greater than zero when specified.");
5552
}
5653
}
5754

58-
5955
// Only add cross-option validation beyond this point, individual options should validate in their own validators.
6056
if (options.Token!.AccessTokenLifetime > options.Session!.MaxLifetime)
6157
{
6258
return ValidateOptionsResult.Fail("Token.AccessTokenLifetime cannot exceed Session.MaxLifetime.");
6359
}
6460

65-
if (options.Token.RefreshTokenLifetime > options.Session.MaxLifetime)
66-
{
67-
return ValidateOptionsResult.Fail("Token.RefreshTokenLifetime cannot exceed Session.MaxLifetime.");
68-
}
69-
7061
return ValidateOptionsResult.Success;
7162
}
7263
}

0 commit comments

Comments
 (0)