diff --git a/internal/tokens/service.go b/internal/tokens/service.go index 2d9d4e234b..17b70c030d 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 = session.CreatedAt.UTC().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..9be84705db 100644 --- a/internal/tokens/service_test.go +++ b/internal/tokens/service_test.go @@ -1192,3 +1192,114 @@ 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, session.CreatedAt.UTC().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)) + require.NoError(t, models.AddClaimToSession(conn, session.ID, models.TOTPSignIn)) + + 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) + }) +}