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
71 changes: 49 additions & 22 deletions src/AppwriteHelper/Authentication/AppwriteSignInCallbackHelper.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Appwrite.Models;
using AppwriteHelper.Authentication.AppwriteServer;
using AppwriteHelper.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Json;

Expand All @@ -11,7 +13,17 @@ public class AppwriteSignInCallbackHelper([FromKeyedServices(Constants.APPWRITE_
{
private readonly IAppwriteClientFactory _appwriteClientFactory = appwriteClientFactory;

public async Task<AppwriteSignInResult> CreateSignInAsync(string userId, string secret, TimeSpan? cookieLifetime = null, bool isPersistent = true, string? authenticationType = null)
/// <summary>
/// Creates a sign-in result for the specified user with specific cookie options for lifetime enforcement.
/// </summary>
/// <param name="userId">The user ID to sign in.</param>
/// <param name="secret">The authentication secret.</param>
/// <param name="cookieOptions">Cookie options containing security settings like ExpireTimeSpan.</param>
/// <returns>An AppwriteSignInResult containing the principal, authentication properties, session, and user information.</returns>
public async Task<AppwriteSignInResult> CreateAppwriteCookieSignInAsync(
string userId,
string secret,
IOptionsMonitor<AppwriteCookieAuthenticationOptions>? cookieOptions = null)
{
ArgumentException.ThrowIfNullOrEmpty(userId);
ArgumentException.ThrowIfNullOrEmpty(secret);
Expand All @@ -20,48 +32,63 @@ public async Task<AppwriteSignInResult> CreateSignInAsync(string userId, string
var serverAccount = new Appwrite.Services.Account(serverClient);

var session = await serverAccount.CreateSession(userId, secret);
if (session == null)
throw new InvalidOperationException("Invalid session");
if (session == null || string.IsNullOrEmpty(session.Secret))
throw new InvalidOperationException("Invalid session or session secret");

var userClient = _appwriteClientFactory.CreateUserClientFromSession(session.Secret);
var userAccount = new Appwrite.Services.Account(userClient);

var user = await userAccount.Get();
if (user == null)
throw new InvalidOperationException("User not given");

var claims = new List<Claim>
{
new(ClaimTypes.Name, user?.Name ?? string.Empty),
new(ClaimTypes.Email, user?.Email ?? string.Empty),
new(ClaimTypes.NameIdentifier, user?.Id ?? string.Empty),
new(ClaimTypes.Name, user.Name ?? string.Empty),
new(ClaimTypes.Email, user.Email ?? string.Empty),
new(ClaimTypes.NameIdentifier, user.Id ?? string.Empty),
};

//if (user?.Prefs.Data != null)
//{
// foreach (var p in user.Prefs.Data)
// {
// if (!string.IsNullOrEmpty(p.Key))
// claims.Add(new Claim(AppwriteClaimTypes.Pref(p.Key), p.Value.ToString()));
// }
//}

var identity = new ClaimsIdentity(claims, authenticationType ?? AppwriteAuthenticationDefaults.CookieAuthenticationScheme);
var identity = new ClaimsIdentity(claims, AppwriteAuthenticationDefaults.CookieAuthenticationScheme);
var principal = new ClaimsPrincipal(identity);

var expires = cookieLifetime ?? TimeSpan.FromMinutes(15);
// Calculate session expiration time
if (!DateTimeOffset.TryParse(session.Expire?.ToString(), out var sessionExpireTime))
throw new InvalidOperationException("Invalid session expiration date");

if (sessionExpireTime <= DateTime.UtcNow)
throw new InvalidOperationException("Session has already expired");

// Determine the cookie expiration time
DateTimeOffset cookieExpireTime;
if (cookieOptions?.CurrentValue?.ExpireTimeSpan.HasValue == true)
{
// Use ExpireTimeSpan from options, but cap it to session expiration
var configuredExpire = DateTimeOffset.UtcNow.Add(cookieOptions.CurrentValue.ExpireTimeSpan.Value);
cookieExpireTime = configuredExpire < sessionExpireTime
? configuredExpire
: sessionExpireTime;
}
else
{
// Use session expiration time
cookieExpireTime = sessionExpireTime;
}

var authenticationProperties = new AuthenticationProperties
{
IsPersistent = isPersistent,
ExpiresUtc = DateTimeOffset.UtcNow.Add(expires),
AllowRefresh = true
IsPersistent = true,
ExpiresUtc = cookieExpireTime,
AllowRefresh = false
};

var appwriteSession = new AuthenticationToken
{
Name = AppwriteAuthenticationDefaults.AuthenticationTokenAppwriteSession,
Value = JsonSerializer.Serialize(session.ToMap())
Value = session.Secret
};

authenticationProperties.StoreTokens(new List<AuthenticationToken> { appwriteSession });
authenticationProperties.StoreTokens([appwriteSession]);

return new AppwriteSignInResult(principal, authenticationProperties, session, user);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
using Appwrite;
using Appwrite.Models;
using Appwrite.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;

namespace AppwriteHelper.Authentication.Cookies
{
public sealed class AppwriteCookieAuthenticationEvents : CookieAuthenticationEvents
{
private readonly IOptionsMonitor<AppwriteCookieAuthenticationOptions> _options;
private readonly ILogger<AppwriteCookieAuthenticationEvents> _logger;

public AppwriteCookieAuthenticationEvents(IOptionsMonitor<AppwriteCookieAuthenticationOptions> options)
=> _options = options;
public AppwriteCookieAuthenticationEvents(
IOptionsMonitor<AppwriteCookieAuthenticationOptions> options,
ILogger<AppwriteCookieAuthenticationEvents> logger)
{
_options = options;
_logger = logger;
}

public override Task RedirectToLogin(RedirectContext<CookieAuthenticationOptions> context)
{
Expand All @@ -33,60 +36,34 @@ public override Task RedirectToAccessDenied(RedirectContext<CookieAuthentication
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
var options = _options.Get(context.Scheme.Name);
if (!options.HasEndpointAndProject())
{
await RejectAsync(context);
return;
}

if (!TryGetSession(context, out var session))
var expiresUtc = context.Properties.ExpiresUtc;
if (expiresUtc.HasValue && expiresUtc.Value <= DateTimeOffset.UtcNow)
{
await RejectAsync(context);
return;
}

// Check if session is expired
if (string.IsNullOrWhiteSpace(session.Expire))
{
await RejectAsync(context);
return;
}

if (!DateTimeOffset.TryParse(session.Expire, out var expireDate))
{
await RejectAsync(context);
return;
}

if (expireDate <= DateTimeOffset.UtcNow)
if (!TryGetSession(context, out var session))
{
await RejectAsync(context);
return;
}

// Optional: additional online revoked session check
if (options.CheckForRevokedSessions && !await IsSessionAcceptedByServerAsync(options, session.Secret))
if (options.CheckForRevokedSessions)
{
await RejectAsync(context);
return;
}

var account = CreateAccountClient(options, session.Secret);
if (options.ExtendSessionOnRenewal)
{
if (string.IsNullOrEmpty(session.Id))
if (!options.HasEndpointAndProject())
{
await RejectAsync(context);
return;
}

if (!await TryUpdateSessionAsync(account, session.Id))
if (!await IsSessionAcceptedByServerAsync(options, session))
{
await RejectAsync(context);
return;
}

context.ShouldRenew = true;
}
}

Expand All @@ -100,63 +77,38 @@ private static Account CreateAccountClient(AppwriteCookieAuthenticationOptions o
return new Account(client);
}

private static async Task<bool> IsSessionAcceptedByServerAsync(AppwriteCookieAuthenticationOptions options, string sessionSecret)
private async Task<bool> IsSessionAcceptedByServerAsync(AppwriteCookieAuthenticationOptions options, string sessionSecret)
{
try
{
var user = await CreateAccountClient(options, sessionSecret).Get();
return !string.IsNullOrWhiteSpace(user?.Id);
}
catch
catch (Exception ex)
{
_logger.LogWarning(ex, "Session not accepted by Appwrite");
return false;
}
}

private static async Task<bool> TryUpdateSessionAsync(Account account, string sessionId)
private bool TryGetSession(CookieValidatePrincipalContext context, out string session)
{
try
{
await account.UpdateSession(sessionId);
return true;
}
catch
{
return false;
}
}
session = "";

private static bool TryGetSession(CookieValidatePrincipalContext context, out Session session)
{
var json = context.Properties.GetTokenValue(AppwriteAuthenticationDefaults.AuthenticationTokenAppwriteSession);
if (string.IsNullOrEmpty(json))
if (context?.Properties == null)
{
session = null!;
_logger.LogWarning("Cookie validation context or properties is null");
return false;
}

try
{
var deserializedSession = JsonSerializer.Deserialize<Session>(json);
if (deserializedSession == null || string.IsNullOrEmpty(deserializedSession.Secret))
{
session = null!;
return false;
}

session = deserializedSession;

//check at least for secret and id
if (String.IsNullOrEmpty(session.Secret) || String.IsNullOrEmpty(session.Id))
return false;

return true;
}
catch
session = context.Properties.GetTokenValue(AppwriteAuthenticationDefaults.AuthenticationTokenAppwriteSession) ?? "";
if (string.IsNullOrEmpty(session))
{
session = null!;
_logger.LogDebug("No Appwrite session token found in authentication properties");
return false;
}

return true;
}

private static async Task RejectAsync(CookieValidatePrincipalContext context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,20 @@ public AppwriteCookieAuthenticationOptions()
Cookie.Name = AppwriteAuthenticationDefaults.AppwriteHelperCookieName;
Cookie.HttpOnly = true;
Cookie.SecurePolicy = CookieSecurePolicy.Always;
Cookie.SameSite = SameSiteMode.None;
SlidingExpiration = true;
Cookie.SameSite = SameSiteMode.Lax;
Cookie.Path = "/";
}

public CookieBuilder Cookie { get; } = new CookieBuilder();

public TimeSpan ExpireTimeSpan { get; set; } = TimeSpan.FromMinutes(15);

public bool SlidingExpiration { get; set; }
public TimeSpan? ExpireTimeSpan { get; set; }

/// <summary>
/// If enabled, the middleware checks if the session is still valid by calling the Appwrite account endpoint.
/// This should only be enabled, if you call endpoints not using the Appwrite Client that would fail if the session is beeing revoked.
/// </summary>
public bool CheckForRevokedSessions { get; set; } = false;

/// <summary>
/// If enabled, the middleware extends the Appwrite session when the cookie is renewed.
/// </summary>
public bool ExtendSessionOnRenewal { get; set; } = false;

public string AppwriteEndpoint { get; set; } = string.Empty;
public string AppwriteProject { get; set; } = string.Empty;
}
Expand Down
2 changes: 1 addition & 1 deletion src/AppwriteHelper/AuthenticationBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public static AuthenticationBuilder AddAppwriteCookieAuthentication(this Authent
builder.Services.AddScoped<AppwriteCookieAuthenticationEvents>();

builder.AddCookie(cookieScheme, options =>
{
{
options.EventsType = typeof(AppwriteCookieAuthenticationEvents);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next)
var session = authenticationProperties?.GetTokenValue(AppwriteAuthenticationDefaults.AuthenticationTokenAppwriteSession);

if (authenticateResultFeature?.AuthenticateResult?.Succeeded == true)
{
{
if (_client != null)
{
if (!string.IsNullOrEmpty(session))
Expand Down
Loading