From 2ed998db79f1285b9db4e402369b961d879965a7 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 27 Mar 2026 17:38:24 +0100 Subject: [PATCH 1/3] chore: limit aal1 sessions correctly --- internal/tokens/service.go | 12 +++- internal/tokens/service_test.go | 110 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/internal/tokens/service.go b/internal/tokens/service.go index 2d9d4e234b..ba3fe0eb75 100644 --- a/internal/tokens/service.go +++ b/internal/tokens/service.go @@ -669,8 +669,18 @@ func (s *Service) GenerateAccessToken(r *http.Request, tx *storage.Connection, p return "", 0, terr } + var expiresAt time.Time + issuedAt := s.now().UTC() - expiresAt := issuedAt.Add(time.Second * time.Duration(config.JWT.Exp)) + if config.Sessions.AllowLowAAL != nil && *config.Sessions.AllowLowAAL != 0 && models.CompareAAL(aal, params.User.HighestPossibleAAL()) < 0 { + // if user has mfa enabled and the session has not yet been upgraded + // and Limit duration of AAL1 sessions is enabled + // expiresAt should be set to the maximum duration for low aal sessions + expiresAt = issuedAt.Add(*config.Sessions.AllowLowAAL) + } else { + expiresAt = issuedAt.Add(time.Second * time.Duration(config.JWT.Exp)) + } + var clientID string if params.ClientID != nil && *params.ClientID != uuid.Nil { clientID = params.ClientID.String() diff --git a/internal/tokens/service_test.go b/internal/tokens/service_test.go index 31f75dda6c..d7db4fc7df 100644 --- a/internal/tokens/service_test.go +++ b/internal/tokens/service_test.go @@ -1192,3 +1192,113 @@ func TestAsRedirectURL(t *testing.T) { require.Contains(t, fragment, "sb", "Fragment should contain Supabase Auth identifier 'sb'") require.Equal(t, "", fragment.Get("sb"), "Supabase Auth identifier should have empty value") } + +func TestGenerateAccessTokenAllowLowAAL(t *testing.T) { + config, err := conf.LoadGlobal("../../hack/test.env") + require.NoError(t, err) + + conn, err := test.SetupDBConnection(config) + require.NoError(t, err) + defer conn.Close() + + allowLowAAL := 5 * time.Minute + + req, err := http.NewRequest("POST", "https://example.com/", nil) + require.NoError(t, err) + + now := time.Now().UTC().Truncate(time.Second) + + t.Run("AAL1 session for MFA user uses AllowLowAAL expiry", func(t *testing.T) { + models.TruncateAll(conn) + + u, err := models.NewUser("", "test@example.com", "password", "authenticated", nil) + require.NoError(t, err) + require.NoError(t, conn.Create(u)) + + // Add a verified TOTP factor so HighestPossibleAAL() returns AAL2 + factor := models.NewFactor(u, "my-totp", models.TOTP, models.FactorStateVerified) + require.NoError(t, conn.Create(factor)) + require.NoError(t, conn.Eager().Find(u, u.ID)) + + session, err := models.NewSession(u.ID, nil) + require.NoError(t, err) + // Session stays at AAL1 (default) + require.NoError(t, conn.Create(session)) + + cfg := *config + cfg.Sessions.AllowLowAAL = &allowLowAAL + + srv := NewService(&cfg, &panicHookManager{}) + srv.SetTimeFunc(func() time.Time { return now }) + + _, expiresAt, err := srv.GenerateAccessToken(req, conn, GenerateAccessTokenParams{ + User: u, + SessionID: &session.ID, + AuthenticationMethod: models.PasswordGrant, + }) + require.NoError(t, err) + require.Equal(t, now.Add(allowLowAAL).Unix(), expiresAt) + }) + + t.Run("AAL2 session for MFA user uses standard JWT expiry", func(t *testing.T) { + models.TruncateAll(conn) + + u, err := models.NewUser("", "test2@example.com", "password", "authenticated", nil) + require.NoError(t, err) + require.NoError(t, conn.Create(u)) + + factor := models.NewFactor(u, "my-totp", models.TOTP, models.FactorStateVerified) + require.NoError(t, conn.Create(factor)) + require.NoError(t, conn.Eager().Find(u, u.ID)) + + session, err := models.NewSession(u.ID, &factor.ID) + require.NoError(t, err) + aal2 := models.AAL2.String() + session.AAL = &aal2 + require.NoError(t, conn.Create(session)) + + cfg := *config + cfg.Sessions.AllowLowAAL = &allowLowAAL + + srv := NewService(&cfg, &panicHookManager{}) + srv.SetTimeFunc(func() time.Time { return now }) + + _, expiresAt, err := srv.GenerateAccessToken(req, conn, GenerateAccessTokenParams{ + User: u, + SessionID: &session.ID, + AuthenticationMethod: models.PasswordGrant, + }) + require.NoError(t, err) + require.Equal(t, now.Add(time.Second*time.Duration(config.JWT.Exp)).Unix(), expiresAt) + }) + + t.Run("AAL1 session without AllowLowAAL uses standard JWT expiry", func(t *testing.T) { + models.TruncateAll(conn) + + u, err := models.NewUser("", "test3@example.com", "password", "authenticated", nil) + require.NoError(t, err) + require.NoError(t, conn.Create(u)) + + factor := models.NewFactor(u, "my-totp", models.TOTP, models.FactorStateVerified) + require.NoError(t, conn.Create(factor)) + require.NoError(t, conn.Eager().Find(u, u.ID)) + + session, err := models.NewSession(u.ID, nil) + require.NoError(t, err) + require.NoError(t, conn.Create(session)) + + cfg := *config + cfg.Sessions.AllowLowAAL = nil + + srv := NewService(&cfg, &panicHookManager{}) + srv.SetTimeFunc(func() time.Time { return now }) + + _, expiresAt, err := srv.GenerateAccessToken(req, conn, GenerateAccessTokenParams{ + User: u, + SessionID: &session.ID, + AuthenticationMethod: models.PasswordGrant, + }) + require.NoError(t, err) + require.Equal(t, now.Add(time.Second*time.Duration(config.JWT.Exp)).Unix(), expiresAt) + }) +} From da20f2a6e9eabaa3d82e0c464615ee83b50f2fb1 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 27 Mar 2026 17:57:01 +0100 Subject: [PATCH 2/3] fix: test for AAL2 --- internal/tokens/service_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tokens/service_test.go b/internal/tokens/service_test.go index d7db4fc7df..b3190dd133 100644 --- a/internal/tokens/service_test.go +++ b/internal/tokens/service_test.go @@ -1256,6 +1256,7 @@ func TestGenerateAccessTokenAllowLowAAL(t *testing.T) { aal2 := models.AAL2.String() session.AAL = &aal2 require.NoError(t, conn.Create(session)) + require.NoError(t, models.AddClaimToSession(conn, session.ID, models.TOTPSignIn)) cfg := *config cfg.Sessions.AllowLowAAL = &allowLowAAL From e1baf44a225552294d9b0580e40a1431839f9d53 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 27 Mar 2026 18:02:03 +0100 Subject: [PATCH 3/3] fix: expire initial jwt based on session creation time --- internal/tokens/service.go | 2 +- internal/tokens/service_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tokens/service.go b/internal/tokens/service.go index ba3fe0eb75..17b70c030d 100644 --- a/internal/tokens/service.go +++ b/internal/tokens/service.go @@ -676,7 +676,7 @@ func (s *Service) GenerateAccessToken(r *http.Request, tx *storage.Connection, p // if user has mfa enabled and the session has not yet been upgraded // and Limit duration of AAL1 sessions is enabled // expiresAt should be set to the maximum duration for low aal sessions - expiresAt = issuedAt.Add(*config.Sessions.AllowLowAAL) + expiresAt = session.CreatedAt.UTC().Add(*config.Sessions.AllowLowAAL) } else { expiresAt = issuedAt.Add(time.Second * time.Duration(config.JWT.Exp)) } diff --git a/internal/tokens/service_test.go b/internal/tokens/service_test.go index b3190dd133..9be84705db 100644 --- a/internal/tokens/service_test.go +++ b/internal/tokens/service_test.go @@ -1237,7 +1237,7 @@ func TestGenerateAccessTokenAllowLowAAL(t *testing.T) { AuthenticationMethod: models.PasswordGrant, }) require.NoError(t, err) - require.Equal(t, now.Add(allowLowAAL).Unix(), expiresAt) + require.Equal(t, session.CreatedAt.UTC().Add(allowLowAAL).Unix(), expiresAt) }) t.Run("AAL2 session for MFA user uses standard JWT expiry", func(t *testing.T) {