Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@

builder.Services.AddUltimateAuthServer(o => {
o.Diagnostics.EnableRefreshDetails = true;
o.Login.MaxFailedAttempts = 2;
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
o.Identifiers.Behavior.AllowMultipleUsernames = true;
o.UserProfile.EnableMultiProfile = true;

//o.Session.MaxLifetime = TimeSpan.FromSeconds(32);
//o.Session.Lifetime = TimeSpan.FromSeconds(32);
//o.Session.TouchInterval = TimeSpan.FromSeconds(9);
//o.Session.IdleTimeout = TimeSpan.FromSeconds(15);
//o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30);
//o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32);
o.Login.MaxFailedAttempts = 2;
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
o.Identifiers.Behavior.AllowMultipleUsernames = true;
o.UserProfile.EnableMultiProfile = true;
})
.AddUltimateAuthInMemory()
.AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,29 @@
builder.Services.AddUltimateAuthServer(o =>
{
o.Diagnostics.EnableRefreshDetails = true;
o.Login.MaxFailedAttempts = 2;
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
o.Identifiers.Behavior.AllowMultipleUsernames = true;

// No-op and touch with max lifetime example
//o.Session.MaxLifetime = TimeSpan.FromSeconds(32);
//o.Session.Lifetime = TimeSpan.FromSeconds(32);
//o.Session.TouchInterval = TimeSpan.FromSeconds(9);
//o.Session.IdleTimeout = TimeSpan.FromSeconds(15);
//o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30);
//o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32);
o.Login.MaxFailedAttempts = 2;
o.Login.LockoutDuration = TimeSpan.FromSeconds(10);
o.Identifiers.Behavior.AllowMultipleUsernames = true;

// Infinite sliding expiration example
//o.Session.MaxLifetime = null;
//o.Session.Lifetime = TimeSpan.FromSeconds(20);
//o.Session.TouchInterval = TimeSpan.FromSeconds(9);
//o.Session.IdleTimeout = TimeSpan.FromSeconds(10);
})
.AddUltimateAuthInMemory();

builder.Services.AddUltimateAuthClientBlazor(o =>
{
//o.AutoRefresh.Interval = TimeSpan.FromSeconds(5);
o.AutoRefresh.Interval = TimeSpan.FromSeconds(5);
o.Reauth.Behavior = ReauthBehavior.RaiseEvent;
//o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
o.Reauth.Behavior = ReauthBehavior.RaiseEvent;
o.Login.AllowCredentialPost = true;
o.Pkce.ReturnUrl = "https://localhost:6130/home"; // This application domain + path

o.AutoRefresh.Interval = TimeSpan.FromSeconds(5);
});

builder.Services.AddScoped<ProductApiService>();
Expand Down
21 changes: 21 additions & 0 deletions src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,27 @@ public SessionState GetState(DateTimeOffset at)
return SessionState.Active;
}

public UAuthSession ExtendExpiration(DateTimeOffset expiresAt)
{
if (expiresAt <= ExpiresAt)
return this;

return new UAuthSession(
SessionId,
Tenant,
UserKey,
ChainId,
CreatedAt,
expiresAt,
RevokedAt,
SecurityVersionAtCreation,
Device,
Claims,
Metadata,
Version + 1
);
}

public UAuthSession WithChain(SessionChainId chainId)
{
if (!ChainId.IsUnassigned)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core;
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Infrastructure;
Expand Down Expand Up @@ -69,6 +70,8 @@ public async ValueTask<AuthFlowContext> CreateAsync(HttpContext ctx, AuthFlowTyp
var returnUrlInfo = ReturnUrlParser.Parse(returnUrl);

SessionSecurityContext? sessionSecurityContext = null;
var isAuthenticated = user?.IsAuthenticated ?? false;
var userKey = user?.UserId;

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

sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation);

if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid)
{
isAuthenticated = validation.IsValid;

if (!isAuthenticated)
userKey = null;
}
}
else if (effectiveMode is UAuthMode.PureOpaque or UAuthMode.Hybrid)
{
isAuthenticated = false;
userKey = null;
}

if (!tenant.IsValid || tenant.IsUnresolved)
Expand All @@ -98,8 +114,8 @@ public async ValueTask<AuthFlowContext> CreateAsync(HttpContext ctx, AuthFlowTyp
effectiveMode,
deviceContext,
tenant,
user?.IsAuthenticated ?? false,
user?.UserId,
isAuthenticated,
userKey,
sessionSecurityContext,
options,
response,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@
public sealed class SessionTouchPolicy
{
public TimeSpan? TouchInterval { get; init; }
public TimeSpan Lifetime { get; init; }
public bool SlidingExpiration { get; init; }
public TimeSpan? IdleTimeout { get; init; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;

namespace CodeBeam.UltimateAuth.Server.Flows;

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

// It's designed for PureOpaque sessions, which do not issue new refresh tokens on refresh.
// That's why the service access store direcly: There is no security flow here, only validate and touch session.
public async Task<SessionRefreshResult> RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default)
public async Task<SessionRefreshResult> RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode,
DateTimeOffset now, CancellationToken ct = default)
{
if (!validation.IsValid || validation.ChainId is null)
if (!validation.IsValid || validation.SessionId is null || validation.ChainId is null)
{
return SessionRefreshResult.ReauthRequired();

if (!policy.TouchInterval.HasValue)
return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false);
}

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

bool isValid = false;
bool didTouch = false;

await kernel.ExecuteAsync(async _ =>
await kernel.ExecuteAsync(async innerCt =>
{
var chain = await kernel.GetChainAsync(validation.ChainId.Value);
var chain = await kernel.GetChainAsync(validation.ChainId.Value, innerCt);
var session = await kernel.GetSessionAsync(validation.SessionId.Value, innerCt);

if (chain is null || session is null)
return;

if (chain is null || chain.IsRevoked)
if (chain.GetState(now, policy.IdleTimeout) != SessionState.Active || session.GetState(now) != SessionState.Active)
return;

if (now - chain.LastSeenAt < policy.TouchInterval.Value)
if (chain.ActiveSessionId != session.SessionId ||
session.ChainId != chain.ChainId ||
session.Tenant != validation.Tenant ||
chain.Tenant != validation.Tenant ||
session.UserKey != chain.UserKey)
return;

var expectedVersion = chain.Version;
var touched = chain.Touch(now);
if (validation.UserKey != session.UserKey || validation.ChainId != session.ChainId)
return;

isValid = true;

bool shouldTouch = sessionTouchMode == SessionTouchMode.Force || !policy.TouchInterval.HasValue || now - chain.LastSeenAt >= policy.TouchInterval.Value;

if (!shouldTouch)
return;

var touchedChain = chain.Touch(now);

if (policy.SlidingExpiration)
{
var proposedExpiry = now.Add(policy.Lifetime);

var newExpiry = chain.AbsoluteExpiresAt is { } absolute
&& proposedExpiry > absolute
? absolute
: proposedExpiry;

var extendedSession = session.ExtendExpiration(newExpiry);

if (!ReferenceEquals(extendedSession, session))
{
await kernel.SaveSessionAsync(extendedSession, session.Version, innerCt);
}
}

await kernel.SaveChainAsync(touchedChain, chain.Version, innerCt);

await kernel.SaveChainAsync(touched, expectedVersion);
didTouch = true;
}, ct);

return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch);
return isValid
? SessionRefreshResult.Success(validation.SessionId.Value, didTouch)
: SessionRefreshResult.ReauthRequired();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ public async Task<IssuedSession> IssueSessionAsync(SessionIssuanceContext contex
if (!AuthSessionId.TryCreate(opaqueSessionId, out AuthSessionId sessionId))
throw new InvalidCastException("Can't create opaque id.");

var expiresAt = now.Add(sessionOptions.Lifetime);
var absoluteExpiresAt = sessionOptions.MaxLifetime is { } max
? now.Add(max)
: (DateTimeOffset?)null;

if (sessionOptions.MaxLifetime is not null)
var sessionExpiresAt = now.Add(sessionOptions.Lifetime);

if (absoluteExpiresAt is { } absolute && sessionExpiresAt > absolute)
{
var absoluteExpiry = now.Add(sessionOptions.MaxLifetime.Value);
if (absoluteExpiry < expiresAt)
expiresAt = absoluteExpiry;
sessionExpiresAt = absolute;
}

var kernel = _storeFactory.Create(context.Tenant);
Expand Down Expand Up @@ -76,7 +78,7 @@ await kernel.ExecuteAsync(async _ =>
context.Tenant,
context.UserKey,
now,
expiresAt,
absoluteExpiresAt,
context.Device,
ClaimsSnapshot.Empty,
root.SecurityVersion
Expand Down Expand Up @@ -110,7 +112,7 @@ await kernel.ExecuteAsync(async _ =>
context.Tenant,
context.UserKey,
now,
expiresAt,
absoluteExpiresAt,
context.Device,
ClaimsSnapshot.Empty,
root.SecurityVersion
Expand Down Expand Up @@ -141,7 +143,7 @@ await kernel.ExecuteAsync(async _ =>
userKey: context.UserKey,
chainId: chain.ChainId,
now: now,
expiresAt: expiresAt,
expiresAt: sessionExpiresAt,
securityVersion: root.SecurityVersion,
device: context.Device,
claims: context.Claims,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ private static void ApplyPureOpaqueDefaults(UAuthServerOptions o)
var c = o.Cookie;
var r = o.AuthResponse;

// Session behavior
s.SlidingExpiration = true;
//s.SlidingExpiration = true; // Default value.

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

s.SlidingExpiration = true;
//s.SlidingExpiration = true; // Default value.
s.TouchInterval = null;

t.IssueJwt = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,30 +43,21 @@ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
{
if (options.Session.Lifetime <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
"Session.Lifetime must be greater than zero.");
return ValidateOptionsResult.Fail("Session.Lifetime must be greater than zero.");
}

if (options.Session.MaxLifetime is not null &&
options.Session.MaxLifetime <= TimeSpan.Zero)
if (options.Session.MaxLifetime is not null && options.Session.MaxLifetime <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
"Session.MaxLifetime must be greater than zero when specified.");
return ValidateOptionsResult.Fail("Session.MaxLifetime must be greater than zero when specified.");
}
}


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

if (options.Token.RefreshTokenLifetime > options.Session.MaxLifetime)
{
return ValidateOptionsResult.Fail("Token.RefreshTokenLifetime cannot exceed Session.MaxLifetime.");
}

return ValidateOptionsResult.Success;
}
}
10 changes: 8 additions & 2 deletions src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ private async Task<RefreshFlowResult> HandleSessionOnlyAsync(AuthFlowContext flo

var touchPolicy = new SessionTouchPolicy
{
TouchInterval = flow.Options.Session.TouchInterval
TouchInterval = flow.Options.Session.TouchInterval,
Lifetime = flow.Options.Session.Lifetime,
SlidingExpiration = flow.Options.Session.SlidingExpiration,
IdleTimeout = flow.Options.Session.IdleTimeout
};

var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct);
Expand Down Expand Up @@ -143,7 +146,10 @@ private async Task<RefreshFlowResult> HandleHybridAsync(AuthFlowContext flow, Re

var touchPolicy = new SessionTouchPolicy
{
TouchInterval = flow.Options.Session.TouchInterval
TouchInterval = flow.Options.Session.TouchInterval,
Lifetime = flow.Options.Session.Lifetime,
SlidingExpiration = flow.Options.Session.SlidingExpiration,
IdleTimeout = flow.Options.Session.IdleTimeout
};

var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Server.Options;
using Microsoft.Extensions.Options;

namespace CodeBeam.UltimateAuth.Server.Services;

Expand Down
Loading
Loading