From 20ee19dca060321361a96ba3cd6ebb911101f8a1 Mon Sep 17 00:00:00 2001 From: Aze Gallo Date: Wed, 22 Oct 2025 12:18:57 -0400 Subject: [PATCH 1/4] Fix token refresh logic We were wrongly assuming that Session.ExpiresIn meant the users session was to expire at that time and we should destroy the session. Session.ExpiresIn property is actually meant to be the entire lifetime of the access token (in seconds). We shouldn't be concerned with "ending sessions" in this library. That is handled by Supabase invalidating refresh tokens. We also were providing the wrong value to ExpiresIn in the `SetSession` method. --- Gotrue/Client.cs | 39 +++++++++-------- Gotrue/Session.cs | 14 +----- Gotrue/TokenRefresh.cs | 72 ++++++++++++++----------------- GotrueTests/AnonKeyClientTests.cs | 10 ----- 4 files changed, 55 insertions(+), 80 deletions(-) diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index 544ceeb4..afd28701 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -518,14 +518,19 @@ public async Task SetSession(string accessToken, string refreshToken, b NotifyAuthStateChange(SignedIn); return CurrentSession; } - + + var iat = payload.IssuedAt; + var exp = payload.ValidTo; + var expiresIn = (long)(exp - iat).TotalSeconds; + CurrentSession = new Session { AccessToken = accessToken, RefreshToken = refreshToken, TokenType = "bearer", - ExpiresIn = payload.Expiration!.Value, - User = await _api.GetUser(accessToken) + ExpiresIn = expiresIn, + User = await _api.GetUser(accessToken), + CreatedAt = iat, }; NotifyAuthStateChange(SignedIn); @@ -568,13 +573,16 @@ public async Task SetSession(string accessToken, string refreshToken, b var user = await _api.GetUser(accessToken); + var payload = new JwtSecurityTokenHandler().ReadJwtToken(accessToken).Payload; + var session = new Session { AccessToken = accessToken, ExpiresIn = long.Parse(expiresIn), RefreshToken = refreshToken, TokenType = tokenType, - User = user + User = user, + CreatedAt = payload.IssuedAt, }; if (storeSession) @@ -595,14 +603,6 @@ public async Task SetSession(string accessToken, string refreshToken, b if (CurrentSession == null) return null; - // Check to see if the session has expired. If so go ahead and destroy it. - if (CurrentSession != null && CurrentSession.Expired()) - { - _debugNotification?.Log($"Loaded session has expired"); - DestroySession(); - return null; - } - // If we aren't online, we can't refresh the token if (!Online) { @@ -691,6 +691,9 @@ private void DestroySession() /// public async Task RefreshToken(string accessToken, string refreshToken) { + if (!Online) + throw new GotrueException("Only supported when online", Offline); + if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) throw new GotrueException("No token provided", NoSessionFound); @@ -712,9 +715,6 @@ public async Task RefreshToken() if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession?.AccessToken) || string.IsNullOrEmpty(CurrentSession?.RefreshToken)) throw new GotrueException("No current session.", NoSessionFound); - if (CurrentSession!.Expired()) - throw new GotrueException("Session expired", ExpiredRefreshToken); - var result = await _api.RefreshAccessToken(CurrentSession.AccessToken!, CurrentSession.RefreshToken!); if (result == null || string.IsNullOrEmpty(result.AccessToken)) @@ -792,13 +792,16 @@ public void Shutdown() if (result == null || string.IsNullOrEmpty(result.AccessToken)) throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified); + var payload = new JwtSecurityTokenHandler().ReadJwtToken(result.AccessToken).Payload; + var session = new Session { AccessToken = result.AccessToken, RefreshToken = result.RefreshToken, TokenType = "bearer", ExpiresIn = result.ExpiresIn, - User = result.User + User = result.User, + CreatedAt = payload.IssuedAt, }; UpdateSession(session); @@ -836,13 +839,15 @@ public void Shutdown() if (result == null || string.IsNullOrEmpty(result.AccessToken)) throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified); + var payload = new JwtSecurityTokenHandler().ReadJwtToken(result.AccessToken).Payload; var session = new Session { AccessToken = result.AccessToken, RefreshToken = result.RefreshToken, TokenType = "bearer", ExpiresIn = result.ExpiresIn, - User = result.User + User = result.User, + CreatedAt = payload.IssuedAt, }; UpdateSession(session); diff --git a/Gotrue/Session.cs b/Gotrue/Session.cs index 655e603a..c87b588c 100644 --- a/Gotrue/Session.cs +++ b/Gotrue/Session.cs @@ -17,7 +17,7 @@ public class Session public string? AccessToken { get; set; } /// - /// The number of seconds until the token expires (since it was issued). Returned when a login is confirmed. + /// The number of seconds until the access token expires (since it was issued). Returned when a login is confirmed. /// [JsonProperty("expires_in")] public long ExpiresIn { get; set; } @@ -49,17 +49,5 @@ public class Session [JsonProperty("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - /// - /// The expiration date of this session, in UTC time. - /// - /// - public DateTime ExpiresAt() => new DateTimeOffset(CreatedAt).AddSeconds(ExpiresIn).ToUniversalTime().DateTime; - - /// - /// Returns true if the session has expired - /// - /// - public bool Expired() => ExpiresAt() < DateTime.UtcNow; } } \ No newline at end of file diff --git a/Gotrue/TokenRefresh.cs b/Gotrue/TokenRefresh.cs index 3721302f..7fd3b835 100644 --- a/Gotrue/TokenRefresh.cs +++ b/Gotrue/TokenRefresh.cs @@ -1,5 +1,7 @@ using System; +using System.IdentityModel.Tokens.Jwt; using System.Threading; +using Supabase.Gotrue.Exceptions; using Supabase.Gotrue.Interfaces; using static Supabase.Gotrue.Constants.AuthState; @@ -46,10 +48,11 @@ public void ManageAutoRefresh(IGotrueClient sender, Constants.Aut case SignedIn: if (Debug) _client.Debug("Refresh Timer started"); - InitRefreshTimer(); + CreateNewTimer(); // Turn on auto-refresh timer break; case SignedOut: + case Shutdown: if (Debug) _client.Debug("Refresh Timer stopped"); _refreshTimer?.Dispose(); @@ -58,32 +61,17 @@ public void ManageAutoRefresh(IGotrueClient sender, Constants.Aut case UserUpdated: if (Debug) _client.Debug("Refresh Timer restarted"); - InitRefreshTimer(); + CreateNewTimer(); break; case PasswordRecovery: - // Doesn't affect auto refresh - break; case TokenRefreshed: + case MfaChallengeVerified: // Doesn't affect auto refresh break; - case Shutdown: - if (Debug) - _client.Debug("Refresh Timer stopped"); - _refreshTimer?.Dispose(); - // Turn off auto-refresh timer - break; default: throw new ArgumentOutOfRangeException(nameof(stateChanged), stateChanged, null); } } - /// - /// Sets up the auto-refresh timer - /// - private void InitRefreshTimer() - { - CreateNewTimer(); - } - /// /// The timer calls this method at the configured interval to refresh the token. /// @@ -119,29 +107,21 @@ private async void HandleRefreshTimerTick(object _) /// private void CreateNewTimer() { - if (_client.CurrentSession == null || _client.CurrentSession.ExpiresIn == default) + if (_client.CurrentSession == null) { if (Debug) _client.Debug($"No session, refresh timer not started"); return; } - if (_client.CurrentSession.Expired()) - { - if (Debug) - _client.Debug($"Token expired, signing out"); - _client.NotifyAuthStateChange(SignedOut); - return; - } - try { - TimeSpan interval = GetInterval(); + TimeSpan refreshDueTime = GetSecondsUntilNextRefresh(); _refreshTimer?.Dispose(); - _refreshTimer = new Timer(HandleRefreshTimerTick, null, interval, Timeout.InfiniteTimeSpan); + _refreshTimer = new Timer(HandleRefreshTimerTick, null, refreshDueTime, Timeout.InfiniteTimeSpan); if (Debug) - _client.Debug($"Refresh timer scheduled {interval.TotalMinutes} minutes"); + _client.Debug($"Refresh timer scheduled {refreshDueTime.TotalMinutes} minutes"); } catch (Exception e) { @@ -151,23 +131,35 @@ private void CreateNewTimer() } /// - /// Interval should be t - (1/5(n)) (i.e. if session time (t) 3600s, attempt refresh at 2880s or 720s (1/5) seconds before expiration) + /// Returns remaining seconds until the access token should be refreshed. + /// Interval is calculated as:t - (1/5(n)) (i.e. if session time (t) 3600s, attempt refresh at 2880s or 720s (1/5) seconds before expiration). + /// + /// - The maximum refresh wait time is clamped to + /// + /// + /// - If the access token is expired it will refresh immediately. + /// /// - private TimeSpan GetInterval() + /// The remaining seconds until the token should be refreshed + private TimeSpan GetSecondsUntilNextRefresh() { - if (_client.CurrentSession == null || _client.CurrentSession.ExpiresIn == default) + if (_client.CurrentSession is null || _client.CurrentSession.AccessToken == null) { return TimeSpan.Zero; } - var interval = (long)Math.Floor(_client.CurrentSession.ExpiresIn * 4.0f / 5.0f); - - var timeoutSeconds = Convert.ToInt64((_client.CurrentSession.CreatedAt.AddSeconds(interval) - DateTime.UtcNow).TotalSeconds); + var interval = (long)Math.Floor(_client.CurrentSession.ExpiresIn * 4.0 / 5.0); + var refreshAt = _client.CurrentSession.CreatedAt.AddSeconds(interval); - if (timeoutSeconds > _client.Options.MaximumRefreshWaitTime) - timeoutSeconds = _client.Options.MaximumRefreshWaitTime; - - return TimeSpan.FromSeconds(timeoutSeconds); + var secondsUntilNextRefresh = Convert.ToInt64((refreshAt - DateTime.UtcNow).TotalSeconds); + + if (secondsUntilNextRefresh < 0) + return TimeSpan.Zero; + + if (secondsUntilNextRefresh > _client.Options.MaximumRefreshWaitTime) + secondsUntilNextRefresh = _client.Options.MaximumRefreshWaitTime; + + return TimeSpan.FromSeconds(secondsUntilNextRefresh); } } } diff --git a/GotrueTests/AnonKeyClientTests.cs b/GotrueTests/AnonKeyClientTests.cs index 784fec00..02b35239 100644 --- a/GotrueTests/AnonKeyClientTests.cs +++ b/GotrueTests/AnonKeyClientTests.cs @@ -481,15 +481,5 @@ public async Task ClientCanSetSession() // As this is being forced to regenerate, the original should be different than the cached. AreNotEqual(refreshToken, _client.CurrentSession.RefreshToken); } - - [TestMethod("Session: `ExpiresAt` is Calculated Correctly.")] - public async Task SessionCalculatesExpiresAtCorrectly() - { - var email = $"{RandomString(12)}@supabase.io"; - var session = await _client.SignUp(email, PASSWORD); - - IsFalse(session.Expired()); - AreEqual(session.ExpiresAt().Ticks, session.CreatedAt.ToUniversalTime().AddSeconds(session.ExpiresIn).Ticks); - } } } \ No newline at end of file From af823f766de0beffe76f2c859fbba97b1becd705 Mon Sep 17 00:00:00 2001 From: Aze Gallo Date: Fri, 24 Oct 2025 12:18:14 -0400 Subject: [PATCH 2/4] Remove CreatedAt assignments On second thought, using the default assigned CreatedAt is correct for session creation. --- Gotrue/Client.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index afd28701..46acaead 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -530,7 +530,6 @@ public async Task SetSession(string accessToken, string refreshToken, b TokenType = "bearer", ExpiresIn = expiresIn, User = await _api.GetUser(accessToken), - CreatedAt = iat, }; NotifyAuthStateChange(SignedIn); @@ -573,8 +572,6 @@ public async Task SetSession(string accessToken, string refreshToken, b var user = await _api.GetUser(accessToken); - var payload = new JwtSecurityTokenHandler().ReadJwtToken(accessToken).Payload; - var session = new Session { AccessToken = accessToken, @@ -582,7 +579,6 @@ public async Task SetSession(string accessToken, string refreshToken, b RefreshToken = refreshToken, TokenType = tokenType, User = user, - CreatedAt = payload.IssuedAt, }; if (storeSession) @@ -791,8 +787,6 @@ public void Shutdown() if (result == null || string.IsNullOrEmpty(result.AccessToken)) throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified); - - var payload = new JwtSecurityTokenHandler().ReadJwtToken(result.AccessToken).Payload; var session = new Session { @@ -800,8 +794,7 @@ public void Shutdown() RefreshToken = result.RefreshToken, TokenType = "bearer", ExpiresIn = result.ExpiresIn, - User = result.User, - CreatedAt = payload.IssuedAt, + User = result.User }; UpdateSession(session); @@ -838,8 +831,7 @@ public void Shutdown() if (result == null || string.IsNullOrEmpty(result.AccessToken)) throw new GotrueException("Could not verify MFA.", MfaChallengeUnverified); - - var payload = new JwtSecurityTokenHandler().ReadJwtToken(result.AccessToken).Payload; + var session = new Session { AccessToken = result.AccessToken, @@ -847,7 +839,6 @@ public void Shutdown() TokenType = "bearer", ExpiresIn = result.ExpiresIn, User = result.User, - CreatedAt = payload.IssuedAt, }; UpdateSession(session); From 2e2cbebf0a5b853d575642dd8a5fe515adb019d2 Mon Sep 17 00:00:00 2001 From: Aze Gallo Date: Thu, 4 Dec 2025 15:43:29 -0500 Subject: [PATCH 3/4] Destroy session if invalid refresh token is used --- Gotrue/Client.cs | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/Gotrue/Client.cs b/Gotrue/Client.cs index 46acaead..ae898fc2 100644 --- a/Gotrue/Client.cs +++ b/Gotrue/Client.cs @@ -488,7 +488,7 @@ public async Task ResetPasswordForEmail(ResetPasswor await RefreshToken(); - var user = await _api.GetUser(CurrentSession.AccessToken!); + var user = await _api.GetUser(CurrentSession.AccessToken); CurrentSession.User = user; return CurrentSession; @@ -693,13 +693,22 @@ public async Task RefreshToken(string accessToken, string refreshToken) if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken)) throw new GotrueException("No token provided", NoSessionFound); - var result = await _api.RefreshAccessToken(accessToken, refreshToken); - - if (result == null || string.IsNullOrEmpty(result.AccessToken)) - throw new GotrueException("Could not refresh token from provided session.", NoSessionFound); + try + { + var result = await _api.RefreshAccessToken(accessToken, refreshToken); - CurrentSession = result; - NotifyAuthStateChange(TokenRefreshed); + if (result == null || string.IsNullOrEmpty(result.AccessToken)) + throw new GotrueException("Could not refresh token from provided session.", NoSessionFound); + + CurrentSession = result; + NotifyAuthStateChange(TokenRefreshed); + } + catch (GotrueException ex) when (ex.Reason is InvalidRefreshToken) + { + DestroySession(); + NotifyAuthStateChange(SignedOut); + throw; + } } /// @@ -711,14 +720,22 @@ public async Task RefreshToken() if (CurrentSession == null || string.IsNullOrEmpty(CurrentSession?.AccessToken) || string.IsNullOrEmpty(CurrentSession?.RefreshToken)) throw new GotrueException("No current session.", NoSessionFound); - var result = await _api.RefreshAccessToken(CurrentSession.AccessToken!, CurrentSession.RefreshToken!); - - if (result == null || string.IsNullOrEmpty(result.AccessToken)) - throw new GotrueException("Could not refresh token from provided session.", NoSessionFound); + try + { + var result = await _api.RefreshAccessToken(CurrentSession.AccessToken!, CurrentSession.RefreshToken!); + if (result == null || string.IsNullOrEmpty(result.AccessToken)) + throw new GotrueException("Could not refresh token from provided session.", NoSessionFound); - CurrentSession = result; + CurrentSession = result; - NotifyAuthStateChange(TokenRefreshed); + NotifyAuthStateChange(TokenRefreshed); + } + catch (GotrueException ex) when (ex.Reason is InvalidRefreshToken) + { + DestroySession(); + NotifyAuthStateChange(SignedOut); + throw; + } } From 3fbd3bd41c2be09c97cd37ae5232b5e45bc68525 Mon Sep 17 00:00:00 2001 From: Aze Gallo Date: Thu, 4 Dec 2025 15:44:02 -0500 Subject: [PATCH 4/4] Update bogus refresh and expired token tests --- GotrueTests/AnonKeyClientFailureTests.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/GotrueTests/AnonKeyClientFailureTests.cs b/GotrueTests/AnonKeyClientFailureTests.cs index 83c3f796..41335a69 100644 --- a/GotrueTests/AnonKeyClientFailureTests.cs +++ b/GotrueTests/AnonKeyClientFailureTests.cs @@ -125,7 +125,9 @@ public async Task ClientTriggersTokenRefreshedEvent() { await _client.RefreshSession(); }); + AreEqual(InvalidRefreshToken, x.Reason); + IsNull(_client.CurrentSession); } [TestMethod("Client: expired token")] @@ -138,18 +140,17 @@ public async Task ExpiredTokenTest() IsNotNull(emailSession.RefreshToken); IsNotNull(emailSession.User); + // Set CreatedAt to an old date - this should NOT prevent refresh from working + // Session "expiration" based on CreatedAt is about access token lifetime, not refresh token validity + _client.CurrentSession.CreatedAt = DateTime.UtcNow.AddDays(-10); + + // Refresh should still succeed with a valid refresh token await _client.RefreshSession(); - - IsNotNull(emailSession.AccessToken); - IsNotNull(emailSession.RefreshToken); - IsNotNull(emailSession.User); - _client.CurrentSession.CreatedAt = DateTime.UtcNow.AddDays(-10); - var x = await ThrowsExceptionAsync(async () => - { - await _client.RefreshSession(); - }); - AreEqual(ExpiredRefreshToken, x.Reason); + IsNotNull(_client.CurrentSession); + IsNotNull(_client.CurrentSession.AccessToken); + IsNotNull(_client.CurrentSession.RefreshToken); + IsNotNull(_client.CurrentSession.User); } [TestMethod("Client: Send Reset Password Email for unknown email")]